From af5d38e0880a85f161da0fef35291fde8d36ecea Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 26 Feb 2026 04:35:48 +0000 Subject: [PATCH 01/50] feat: add integrations namespace for optional third-party head tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit script-tag now passes through all attributes (was dropping anything besides :src and :nonce). integrations.clj is a stub with commented examples — configure via env vars to enable. --- src/clj/orcpub/index.clj | 12 ++++--- src/clj/orcpub/integrations.clj | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 src/clj/orcpub/integrations.clj diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index 9df5fb5e2..db3d4df4a 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -3,6 +3,7 @@ [orcpub.oauth :as oauth] [orcpub.dnd.e5.views-2 :as views-2] [orcpub.favicon :as fi] + [orcpub.integrations :as integrations] [environ.core :refer [env]])) (def devmode? (env :dev-mode)) @@ -21,10 +22,10 @@ (defn script-tag "Generate a script tag with optional nonce for CSP strict mode. - For external scripts, pass :src. For inline scripts, pass content as body." - [{:keys [src nonce]} & body] - (let [attrs (cond-> {} - src (assoc :src src) + For external scripts, pass :src. For inline scripts, pass content as body. + Extra attributes (e.g. :async, :crossorigin) are passed through to the tag." + [{:keys [nonce] :as opts} & body] + (let [attrs (cond-> (dissoc opts :nonce) nonce (assoc :nonce nonce))] (if (seq body) (into [:script attrs] body) @@ -132,7 +133,8 @@ table { html { min-height: 100%; }"] - [:title title]] + [:title title] + (integrations/head-tags nonce)] [:body {:style "margin:0;line-height:1"} [:div#app (if splash? diff --git a/src/clj/orcpub/integrations.clj b/src/clj/orcpub/integrations.clj new file mode 100644 index 000000000..7ab6fb410 --- /dev/null +++ b/src/clj/orcpub/integrations.clj @@ -0,0 +1,58 @@ +(ns orcpub.integrations + "Optional third-party integrations (analytics, SDKs, etc.). + Configure via environment variables; disabled when unset. + See commented examples below for the pattern." + (:require [environ.core :refer [env]])) + +;; ─── How to add an integration ─────────────────────────────────────── +;; +;; 1. Define env-var-gated config: +;; (def my-service-id (env :my-service-id)) +;; +;; 2. Write a tag function that returns hiccup (or nil when disabled): +;; (defn- my-service-tag [nonce] +;; (when (seq my-service-id) +;; [:script {:nonce nonce :async "" +;; :src (str "https://example.com/sdk.js?id=" my-service-id)}])) +;; +;; 3. Add it to head-tags's concat list below. +;; +;; +;; ─── Example: Analytics ────────────────────────────────────────────── +;; +;; (def analytics-url (env :analytics-url)) +;; (def analytics-site-id (env :analytics-site-id)) +;; +;; (defn- analytics-tags +;; "Self-hosted analytics. Returns a list of hiccup elements." +;; [nonce] +;; (when (and (seq analytics-url) (seq analytics-site-id)) +;; (list +;; [:link {:rel "preconnect" :href analytics-url :crossorigin ""}] +;; [:script {:nonce nonce} +;; (str "(function(){var u='" analytics-url "';" +;; "/* tracker init */})();")]))) +;; +;; +;; ─── Example: External SDK ──────────────────────────────────────────── +;; +;; (def sdk-client (env :sdk-client)) +;; +;; (defn- sdk-tag +;; "External SDK loader." +;; [nonce] +;; (when (seq sdk-client) +;; [:script {:nonce nonce :async "" +;; :src (str "https://cdn.example.com/sdk.js?client=" sdk-client) +;; :crossorigin "anonymous"}])) + +(defn head-tags + "All third-party integration tags for . + Returns a flat seq of hiccup elements, empty when nothing is configured. + Uncomment integration calls as you enable them." + [_nonce] + ;; (remove nil? + ;; (concat + ;; [(sdk-tag nonce)] + ;; (analytics-tags nonce))) + ()) From 7865ef6d591645457fb9e4140fe2cc7fe64eeae9 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 26 Feb 2026 04:57:08 +0000 Subject: [PATCH 02/50] feat: make CSP extensible for third-party integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit integrations.clj exports csp-domains (connect-src, frame-src lists) which csp.clj merges into the Content-Security-Policy header via pedestal.clj. Stub returns empty map — no behavior change until integrations are enabled via env vars. --- src/clj/orcpub/csp.clj | 16 ++++++++++++---- src/clj/orcpub/integrations.clj | 12 ++++++++++++ src/clj/orcpub/pedestal.clj | 8 ++++++-- test/clj/orcpub/csp_test.clj | 22 +++++++++++++++++++++- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/clj/orcpub/csp.clj b/src/clj/orcpub/csp.clj index 5ed53758a..f4f2e15d5 100644 --- a/src/clj/orcpub/csp.clj +++ b/src/clj/orcpub/csp.clj @@ -3,6 +3,7 @@ Generates per-request cryptographic nonces and builds CSP headers with 'strict-dynamic' for XSS protection." + (:require [clojure.string :as str]) (:import [java.security SecureRandom] [java.util Base64])) @@ -24,21 +25,28 @@ "Build a Content-Security-Policy header string with strict-dynamic and nonce. Options: - :dev-mode? - When true, adds ws://localhost:3449 to connect-src for - Figwheel hot-reload WebSocket support. + :dev-mode? - When true, adds ws://localhost:3449 to connect-src + for Figwheel hot-reload WebSocket support. + :extra-connect-src - Seq of additional connect-src origins (from integrations). + :extra-frame-src - Seq of additional frame-src origins (from integrations). The resulting CSP: - Uses 'strict-dynamic' for script-src (only nonced scripts execute) - Allows Google Fonts for styles and fonts + - Merges integration domains into connect-src and frame-src - Restricts all other sources to 'self' - Blocks object embeds, restricts base-uri, frame-ancestors, and form-action" - [nonce & {:keys [dev-mode?]}] + [nonce & {:keys [dev-mode? extra-connect-src extra-frame-src]}] (str "default-src 'self'; " "script-src 'strict-dynamic' 'nonce-" nonce "'; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "font-src 'self' https://fonts.gstatic.com; " "img-src 'self' data: https:; " - "connect-src 'self'" (when dev-mode? " ws://localhost:3449") "; " + "connect-src 'self'" + (when (seq extra-connect-src) (str " " (str/join " " extra-connect-src))) + (when dev-mode? " ws://localhost:3449") "; " + (when (seq extra-frame-src) + (str "frame-src 'self' " (str/join " " extra-frame-src) "; ")) "object-src 'none'; " "base-uri 'self'; " "frame-ancestors 'self'; " diff --git a/src/clj/orcpub/integrations.clj b/src/clj/orcpub/integrations.clj index 7ab6fb410..6a29f7d8e 100644 --- a/src/clj/orcpub/integrations.clj +++ b/src/clj/orcpub/integrations.clj @@ -46,6 +46,18 @@ ;; :src (str "https://cdn.example.com/sdk.js?client=" sdk-client) ;; :crossorigin "anonymous"}])) +(def csp-domains + "Extra CSP domains required by enabled integrations. + Returns {:connect-src [\"https://...\"] :frame-src [\"https://...\"]}. + csp.clj merges these into the Content-Security-Policy header." + ;; (merge-with into + ;; (when (seq analytics-url) + ;; {:connect-src [analytics-url]}) + ;; (when (seq sdk-client) + ;; {:connect-src ["https://cdn.example.com"] + ;; :frame-src ["https://cdn.example.com"]})) + {}) + (defn head-tags "All third-party integration tags for . Returns a flat seq of hiccup elements, empty when nothing is configured. diff --git a/src/clj/orcpub/pedestal.clj b/src/clj/orcpub/pedestal.clj index 3ca9276c3..0ea097c5b 100644 --- a/src/clj/orcpub/pedestal.clj +++ b/src/clj/orcpub/pedestal.clj @@ -7,7 +7,8 @@ [clojure.string :as s] [java-time.api :as t] [orcpub.csp :as csp] - [orcpub.config :as config]) + [orcpub.config :as config] + [orcpub.integrations :as integrations]) (:import [java.io File] [java.time.format DateTimeFormatter])) @@ -66,7 +67,10 @@ :leave (fn [ctx] (if-let [nonce (get-in ctx [:request :csp-nonce])] (assoc-in ctx [:response :headers "Content-Security-Policy"] - (csp/build-csp-header nonce :dev-mode? false)) + (csp/build-csp-header nonce + :dev-mode? false + :extra-connect-src (:connect-src integrations/csp-domains) + :extra-frame-src (:frame-src integrations/csp-domains))) ctx))})) ;; Create the nonce interceptor with current dev-mode? setting diff --git a/test/clj/orcpub/csp_test.clj b/test/clj/orcpub/csp_test.clj index 357ad2f17..e1cc6b21d 100644 --- a/test/clj/orcpub/csp_test.clj +++ b/test/clj/orcpub/csp_test.clj @@ -51,7 +51,27 @@ (testing "Dev mode adds Figwheel WebSocket" (let [header (csp/build-csp-header "test" :dev-mode? true)] (is (str/includes? header "ws://localhost:3449") - "Dev mode header should include Figwheel WebSocket")))) + "Dev mode header should include Figwheel WebSocket"))) + + (testing "Extra connect-src domains are merged" + (let [header (csp/build-csp-header "test" + :extra-connect-src ["https://analytics.example.com" + "https://cdn.example.com"])] + (is (str/includes? header "https://analytics.example.com") + "Should include extra connect-src domain") + (is (str/includes? header "https://cdn.example.com") + "Should include second extra connect-src domain"))) + + (testing "Extra frame-src domains add frame-src directive" + (let [header (csp/build-csp-header "test" + :extra-frame-src ["https://embed.example.com"])] + (is (str/includes? header "frame-src 'self' https://embed.example.com") + "Should add frame-src directive with extra domains"))) + + (testing "No frame-src directive when no extra frame-src provided" + (let [header (csp/build-csp-header "test")] + (is (not (str/includes? header "frame-src")) + "Should not include frame-src when no extras provided")))) (deftest csp-header-does-not-have-unsafe-inline-for-scripts (testing "Strict mode should NOT have unsafe-inline in script-src" From 667ae3c38cb84f37e4be26bb309cff4704673b8a Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 26 Feb 2026 05:05:40 +0000 Subject: [PATCH 03/50] fix: typos in classes/email, remove debug statement from views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - classes.cljc: "exhaustrion" → "exhaustion" (Barbarian Frenzy trait) - email.clj: "please do no click" → "please do NOT click" - views.cljs: remove `prn "FRAME?"` debug output from character-page --- src/clj/orcpub/email.clj | 2 +- src/cljc/orcpub/dnd/e5/classes.cljc | 2 +- src/cljs/orcpub/dnd/e5/views.cljs | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 263ad2d5d..825d76025 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -134,7 +134,7 @@ [:a {:href reset-url} reset-url] [:br] [:br] - "If you did NOT request a reset, please do no click on the link." + "If you did NOT request a reset, please do NOT click on the link." [:br] [:br] "Sincerely," diff --git a/src/cljc/orcpub/dnd/e5/classes.cljc b/src/cljc/orcpub/dnd/e5/classes.cljc index 814712152..8e4656529 100644 --- a/src/cljc/orcpub/dnd/e5/classes.cljc +++ b/src/cljc/orcpub/dnd/e5/classes.cljc @@ -152,7 +152,7 @@ :traits [{:name "Frenzy" :level 3 :page 49 - :summary "You can frenzy when you rage, affording you a single melee weapon attack as a bonus action on each turn until the rage ends. When the rage ends, you suffer 1 level of exhaustrion"} + :summary "You can frenzy when you rage, affording you a single melee weapon attack as a bonus action on each turn until the rage ends. When the rage ends, you suffer 1 level of exhaustion"} {:name "Mindless Rage" :level 6 :page 49 diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 81a4dc9d0..435eced39 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -3653,7 +3653,6 @@ (fn [{:keys [id] :as arg}] (let [id (js/parseInt id) frame? (= "true" (get-in arg [:query "frame"])) - _ (prn "FRAME?" frame?) {:keys [::entity/owner] :as character} @(subscribe [::char/character id]) built-template (subs/built-template @(subscribe [::char/template]) From 53ad4d644949f4381c359fce692f68802e5a1216 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 26 Feb 2026 05:28:18 +0000 Subject: [PATCH 04/50] feat: add branding namespace for fork-neutral identity config Centralizes app name, logos, copyright, email sender, and social links behind env vars with neutral defaults. Forks override via APP_NAME, APP_LOGO_PATH, etc. instead of find-and-replace across source files. --- src/clj/orcpub/branding.clj | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/clj/orcpub/branding.clj diff --git a/src/clj/orcpub/branding.clj b/src/clj/orcpub/branding.clj new file mode 100644 index 000000000..c773e0c0e --- /dev/null +++ b/src/clj/orcpub/branding.clj @@ -0,0 +1,62 @@ +(ns orcpub.branding + "Centralized branding configuration for fork-neutral deployment. + All values have sensible defaults; forks override via env vars. + + Server-side only (.clj). Client-side branding in views.cljs reads + these values indirectly via server-rendered HTML (splash page, OG tags) + or can be centralized separately for CLJS in a future pass." + (:require [environ.core :refer [env]])) + +;; ─── App Identity ────────────────────────────────────────────────── + +(def app-name + "Full display name. Used in emails, OG tags, page titles." + (or (env :app-name) "OrcPub")) + +(def app-tagline + "One-line description for OG/meta tags." + (or (env :app-tagline) + "D&D 5e character builder/generator and digital character sheet far beyond any other in the multiverse.")) + +(def default-page-title + "Default and og:title when no page-specific title is set." + (or (env :app-page-title) + (str app-name ": D&D 5e Character Builder/Generator"))) + +;; ─── Logos & Images ──────────────────────────────────────────────── + +(def logo-path + "Path to the main SVG logo (splash page, header, privacy page)." + (or (env :app-logo-path) "/image/orcpub-logo.svg")) + +(def og-image-filename + "Filename for the OG meta image (social sharing preview). + Combined with the request host to form the full URL." + (or (env :app-og-image) "/image/orcpub-logo.png")) + +;; ─── Copyright ───────────────────────────────────────────────────── + +(def copyright-holder + "Entity name shown in legal footer." + (or (env :app-copyright-holder) "OrcPub")) + +(def copyright-year + "Copyright year string." + (or (env :app-copyright-year) "2025")) + +;; ─── Email ───────────────────────────────────────────────────────── + +(def email-sender-name + "Display name for outbound emails (verification, password reset)." + (or (env :app-email-sender-name) (str app-name " Team"))) + +;; ─── Social Links ────────────────────────────────────────────────── +;; Set any of these to "" to hide the link in the UI. + +(def social-links + "Map of social platform links. Empty string = hidden." + {:patreon (or (env :app-social-patreon) "") + :facebook (or (env :app-social-facebook) "") + :twitter (or (env :app-social-twitter) "") + :reddit (or (env :app-social-reddit) "") + :discord (or (env :app-social-discord) "")}) From 6f6bc124edf0e4b6a2c9718ed7ff1a91cfe9c2c2 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 05:31:39 +0000 Subject: [PATCH 05/50] refactor: wire routes, email, and splash page to branding namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - routes.clj: default title/description/image read from branding vars, fix http→https for OG meta image URL - email.clj: all sender names, subjects, and greeting text use branding/app-name and branding/email-sender-name - views_2.cljc: logo path and copyright use branding vars (CLJ side), with neutral CLJS fallbacks for compilation --- src/clj/orcpub/email.clj | 29 +++++++++++++++-------------- src/clj/orcpub/routes.clj | 13 +++++++------ src/cljc/orcpub/dnd/e5/views_2.cljc | 8 +++++--- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 825d76025..e9242ed18 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -10,14 +10,15 @@ [clojure.pprint :as pprint] [clojure.string :as s] [orcpub.route-map :as routes] + [orcpub.branding :as branding] [cuerdas.core :as str])) (defn verification-email-html [first-and-last-name username verification-url] [:div - "Dear Dungeon Master's Vault Patron," + (str "Dear " branding/app-name " Patron,") [:br] [:br] - "Your Dungeon Master's Vault account is almost ready, we just need you to verify your email address going the following URL to confirm that you are authorized to use this email address:" + (str "Your " branding/app-name " account is almost ready, we just need you to verify your email address going the following URL to confirm that you are authorized to use this email address:") [:br] [:br] [:a {:href verification-url} verification-url] @@ -26,7 +27,7 @@ "Sincerely," [:br] [:br] - "The Dungeon Master's Vault Team"]) + (str "The " branding/email-sender-name)]) (defn verification-email [first-and-last-name username verification-url] [{:type "text/html" @@ -36,7 +37,7 @@ "Email body for existing users changing their email (distinct from registration)." [username verification-url] [:div - "Dear Dungeon Master's Vault Patron," + (str "Dear " branding/app-name " Patron,") [:br] [:br] "You requested to change the email address on your account (" username "). " @@ -52,7 +53,7 @@ "Sincerely," [:br] [:br] - "The Dungeon Master's Vault Team"]) + (str "The " branding/email-sender-name)]) (defn email-change-verification-email [username verification-url] [{:type "text/html" @@ -91,9 +92,9 @@ [base-url {:keys [email username first-and-last-name]} verification-key] (try (let [result (postal/send-message (email-cfg) - {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") + {:from (str branding/email-sender-name " <" (emailfrom) ">") :to email - :subject "Dungeon Master's Vault Email Verification" + :subject (str branding/app-name " Email Verification") :body (verification-email first-and-last-name username @@ -116,16 +117,16 @@ "Send a verification email for an email-change request (not registration)." [base-url {:keys [email username]} verification-key] (postal/send-message (email-cfg) - {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") + {:from (str branding/email-sender-name " <" (emailfrom) ">") :to email - :subject "Dungeon Master's Vault Email Change Verification" + :subject (str branding/app-name " Email Change Verification") :body (email-change-verification-email username (str base-url (routes/path-for routes/verify-route) "?key=" verification-key))})) (defn reset-password-email-html [first-and-last-name reset-url] [:div - "Dear Dungeon Master's Vault Patron" + (str "Dear " branding/app-name " Patron") [:br] [:br] "We received a request to reset your password, to do so please go to the following URL to complete the reset." @@ -140,7 +141,7 @@ "Sincerely," [:br] [:br] - "The Dungeon Master's Vault Team"]) + (str "The " branding/email-sender-name)]) (defn reset-password-email [first-and-last-name reset-url] [{:type "text/html" @@ -162,9 +163,9 @@ [base-url {:keys [email username first-and-last-name]} reset-key] (try (let [result (postal/send-message (email-cfg) - {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") + {:from (str branding/email-sender-name " <" (emailfrom) ">") :to email - :subject "Dungeon Master's Vault Password Reset" + :subject (str branding/app-name " Password Reset") :body (reset-password-email first-and-last-name (str base-url (routes/path-for routes/reset-password-page-route) "?key=" reset-key))})] @@ -199,7 +200,7 @@ (when (not-empty (environ/env :email-errors-to)) (try (let [result (postal/send-message (email-cfg) - {:from (str "Dungeon Master's Vault Errors <" (emailfrom) ">") + {:from (str branding/app-name " Errors <" (emailfrom) ">") :to (str (environ/env :email-errors-to)) :subject "Exception" :body [{:type "text/plain" diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index c93671c24..6f87a9d8b 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -33,6 +33,7 @@ [orcpub.errors :as errors] [orcpub.privacy :as privacy] [orcpub.email :as email] + [orcpub.branding :as branding] [orcpub.index :refer [index-page]] [orcpub.pdf :as pdf] [orcpub.registration :as registration] @@ -631,14 +632,14 @@ :in $ ?key :where [?e :orcpub.user/password-reset-key ?key]]) -(def default-title - "Dungeon Master's Vault: D&D 5e Character Builder/Generator") +(def default-title branding/default-page-title) -(def default-description - "Dungeons & Dragons 5th Edition (D&D 5e) character builder/generator and digital character sheet far beyond any other in the multiverse.") +(def default-description branding/app-tagline) -(defn default-image-url [host] - (str "http://" host "/image/dmv-box-logo.png")) +(defn default-image-url + "OG meta image URL. Uses https:// for social sharing compatibility." + [host] + (str "https://" host branding/og-image-filename)) (defn index-page-response [{:keys [headers uri csp-nonce] :as request} {:keys [title description image-url]} diff --git a/src/cljc/orcpub/dnd/e5/views_2.cljc b/src/cljc/orcpub/dnd/e5/views_2.cljc index a59e6fdb3..e1e9a7d2d 100644 --- a/src/cljc/orcpub/dnd/e5/views_2.cljc +++ b/src/cljc/orcpub/dnd/e5/views_2.cljc @@ -1,6 +1,7 @@ (ns orcpub.dnd.e5.views-2 (:require [orcpub.route-map :as routes] - [clojure.string :as s])) + [clojure.string :as s] + #?(:clj [orcpub.branding :as branding]))) (defn style [style] #?(:cljs style) @@ -39,7 +40,8 @@ (defn legal-footer [] [:div.m-l-15.m-b-10.m-t-10.t-a-l - [:span "© 2020 OrcPub"] + [:span #?(:clj (str "\u00a9 " branding/copyright-year " " branding/copyright-holder) + :cljs "\u00a9 2025 OrcPub")] [:a.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"] [:a.m-l-5 {:href "/privacy-policy" :target :_blank} "Privacy Policy"]]) @@ -63,7 +65,7 @@ {:style (style {:display :flex :justify-content :space-around})} [:img.w-30-p - {:src "/image/dmv-logo.svg" }]] + {:src #?(:clj branding/logo-path :cljs "/image/orcpub-logo.svg")}]] [:div {:style (style {:text-align :center :text-shadow "1px 2px 1px black" From 9c852f810f10aa97cd6fc2234e73e315be58bdc2 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 05:39:22 +0000 Subject: [PATCH 06/50] refactor: wire privacy.clj to branding namespace Replace ~60 hardcoded "OrcPub" and "Dungeon Master's Vault" references with branding/app-name across privacy policy, terms of use, community guidelines, and cookie policy sections. Logo path uses branding/logo-path. Legal uppercase sections use (.toUpperCase branding/app-name). --- src/clj/orcpub/privacy.clj | 132 ++++++++++++++++++------------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/src/clj/orcpub/privacy.clj b/src/clj/orcpub/privacy.clj index d4170e75d..60933a731 100644 --- a/src/clj/orcpub/privacy.clj +++ b/src/clj/orcpub/privacy.clj @@ -1,7 +1,8 @@ (ns orcpub.privacy (:require [hiccup.page :as page] [clojure.string :as s] - [environ.core :as environ])) + [environ.core :as environ] + [orcpub.branding :as branding])) (defn section [{:keys [title font-size paragraphs subsections]}] [:div @@ -19,7 +20,7 @@ {:title "Privacy Policy" :font-size 48 :subsections - [{:title "Thank you for using OrcPub!" + [{:title (str "Thank you for using " branding/app-name "!") :font-size 32 :paragraphs ["We wrote this policy to help you understand what information we collect, how we use it, and what choices you have. Because we're an internet company, some of the concepts below are a little technical, but we've tried our best to explain things in a simple and clear way. We welcome your questions and comments on this policy."]} @@ -29,58 +30,58 @@ [{:title "When you give it to us or give us permission to obtain it" :font-size 28 :paragraphs - ["When you sign up for or use our products, you voluntarily give us certain information. This can include your name, profile photo, role-playing game characters, comments, likes, the email address or phone number you used to sign up, and any other information you provide us. If you're using OrcPub on your mobile device, you can also choose to provide us with location data. And if you choose to buy something on OrcPub, you provide us with payment information, contact information (ex., address and phone number), and what you purchased. If you buy something for someone else on OrcPub, you'd also provide us with their shipping details and contact information." - "You also may give us permission to access your information in other services. For example, you may link your Facebook or Twitter account to OrcPub, which allows us to obtain information from those accounts (like your friends or contacts). The information we get from those services often depends on your settings or their privacy policies, so be sure to check what those are."]} + [(str "When you sign up for or use our products, you voluntarily give us certain information. This can include your name, profile photo, role-playing game characters, comments, likes, the email address or phone number you used to sign up, and any other information you provide us. If you're using " branding/app-name " on your mobile device, you can also choose to provide us with location data. And if you choose to buy something on " branding/app-name ", you provide us with payment information, contact information (ex., address and phone number), and what you purchased. If you buy something for someone else on " branding/app-name ", you'd also provide us with their shipping details and contact information.") + (str "You also may give us permission to access your information in other services. For example, you may link your Facebook or Twitter account to " branding/app-name ", which allows us to obtain information from those accounts (like your friends or contacts). The information we get from those services often depends on your settings or their privacy policies, so be sure to check what those are.")]} {:title "We also get technical information when you use our products" :font-size 28 :paragraphs - ["These days, whenever you use a website, mobile application, or other internet service, there's certain information that almost always gets created and recorded automatically. The same is true when you use our products. Here are some of the types of information we collect:" - "Log data. When you use OrcPub, our servers may automatically record information (\"log data\"), including information that your browser sends whenever you visit a website or your mobile app sends when you're using it. This log data may include your Internet Protocol address, the address of the web pages you visited that had OrcPub features, browser type and settings, the date and time of your request, how you used OrcPub, and cookie data." - "Cookie data. Depending on how you're accessing our products, we may use \"cookies\" (small text files sent by your computer each time you visit our website, unique to your OrcPub account or your browser) or similar technologies to record log data. When we use cookies, we may use \"session\" cookies (that last until you close your browser) or \"persistent\" cookies (that last until you or your browser delete them). For example, we may use cookies to store your language preferences or other OrcPub settings so you don‘t have to set them up every time you visit OrcPub. Some of the cookies we use are associated with your OrcPub account (including personal information about you, such as the email address you gave us), and other cookies are not." - "Device information. In addition to log data, we may also collect information about the device you're using OrcPub on, including what type of device it is, what operating system you're using, device settings, unique device identifiers, and crash data. Whether we collect some or all of this information often depends on what type of device you're using and its settings. For example, different types of information are available depending on whether you're using a Mac or a PC, or an iPhone or an Android phone. To learn more about what information your device makes available to us, please also check the policies of your device manufacturer or software provider."]} + ["These days, whenever you use a website, mobile application, or other internet service, there’s certain information that almost always gets created and recorded automatically. The same is true when you use our products. Here are some of the types of information we collect:" + (str "Log data. When you use " branding/app-name ", our servers may automatically record information (\"log data\"), including information that your browser sends whenever you visit a website or your mobile app sends when you’re using it. This log data may include your Internet Protocol address, the address of the web pages you visited that had " branding/app-name " features, browser type and settings, the date and time of your request, how you used " branding/app-name ", and cookie data.") + (str "Cookie data. Depending on how you’re accessing our products, we may use \"cookies\" (small text files sent by your computer each time you visit our website, unique to your " branding/app-name " account or your browser) or similar technologies to record log data. When we use cookies, we may use \"session\" cookies (that last until you close your browser) or \"persistent\" cookies (that last until you or your browser delete them). For example, we may use cookies to store your language preferences or other " branding/app-name " settings so you don’t have to set them up every time you visit " branding/app-name ". Some of the cookies we use are associated with your " branding/app-name " account (including personal information about you, such as the email address you gave us), and other cookies are not.") + (str "Device information. In addition to log data, we may also collect information about the device you’re using " branding/app-name " on, including what type of device it is, what operating system you’re using, device settings, unique device identifiers, and crash data. Whether we collect some or all of this information often depends on what type of device you’re using and its settings. For example, different types of information are available depending on whether you’re using a Mac or a PC, or an iPhone or an Android phone. To learn more about what information your device makes available to us, please also check the policies of your device manufacturer or software provider.")]} {:title "Our partners and advertisers may share information with us" :font-size 28 :paragraphs - ["We may get information about you and your activity off OrcPub from our affiliates, advertisers, partners and other third parties we work with. For example:" + [(str "We may get information about you and your activity off " branding/app-name " from our affiliates, advertisers, partners and other third parties we work with. For example:") "Online advertisers typically share information with the websites or apps where they run ads to measure and/or improve those ads. We also receive this information, which may include information like whether clicks on ads led to purchases or a list of criteria to use in targeting ads."]}]} {:title "How do we use the information we collect?" :font-size 32 :paragraphs - ["We use the information we collect to provide our products to you and make them better, develop new products, and protect OrcPub and our users. For example, we may log how often people use two different versions of a product, which can help us understand which version is better. If you make a purchase on OrcPub, we'll save your payment information and contact information so that you can use them the next time you want to buy something on OrcPub." + [(str "We use the information we collect to provide our products to you and make them better, develop new products, and protect " branding/app-name " and our users. For example, we may log how often people use two different versions of a product, which can help us understand which version is better. If you make a purchase on " branding/app-name ", we'll save your payment information and contact information so that you can use them the next time you want to buy something on " branding/app-name ".") "We also use the information we collect to offer you customized content, including:" "Showing you ads you might be interested in." "We also use the information we collect to:" - "Send you updates (such as when certain activity, like shares or comments, happens on OrcPub), newsletters, marketing materials and other information that may be of interest to you. For example, depending on your email notification settings, we may send you weekly updates that include content you may like. You can decide to stop getting these updates by updating your account settings (or through other settings we may provide)." - "Help your friends and contacts find you on OrcPub. For example, if you sign up using a Facebook account, we may help your Facebook friends find your account on OrcPub when they first sign up for OrcPub. Or, we may allow people to search for your account on OrcPub using your email address." + (str "Send you updates (such as when certain activity, like shares or comments, happens on " branding/app-name "), newsletters, marketing materials and other information that may be of interest to you. For example, depending on your email notification settings, we may send you weekly updates that include content you may like. You can decide to stop getting these updates by updating your account settings (or through other settings we may provide).") + (str "Help your friends and contacts find you on " branding/app-name ". For example, if you sign up using a Facebook account, we may help your Facebook friends find your account on " branding/app-name " when they first sign up for " branding/app-name ". Or, we may allow people to search for your account on " branding/app-name " using your email address.") "Respond to your questions or comments."]} {:title "Transferring your Information" :font-size 32 :paragraphs - ["OrcPub is headquartered in the United States. By using our products or services, you authorize us to transfer and store your information inside the United States, for the purposes described in this policy. The privacy protections and the rights of authorities to access your personal information in such countries may not be equivalent to those in your home country."]} + [(str branding/app-name " is headquartered in the United States. By using our products or services, you authorize us to transfer and store your information inside the United States, for the purposes described in this policy. The privacy protections and the rights of authorities to access your personal information in such countries may not be equivalent to those in your home country.")]} {:title "How and when do we share information" :font-size 32 :paragraphs - ["Anyone can see the public role-playing game characters and other content you create, and the profile information you give us. We may also make this public information available through what are called \"APIs\" (basically a technical way to share information quickly). For example, a partner might use a OrcPub API to integrate with other applications our users may be interested in. The other limited instances where we may share your personal information include:" - "When we have your consent. This includes sharing information with other services (like Facebook or Twitter) when you've chosen to link to your OrcPub account to those services or publish your activity on OrcPub to them. For example, you can choose to share your characters on Facebook or Twitter." - "When you buy something on OrcPub using your credit card, we may share your credit card information, contact information, and other information about the transaction with the merchant you're buying from. The merchants treat this information just as if you had made a purchase from their website directly, which means their privacy policies and marketing policies apply to the information we share with them." - "Online advertisers typically use third party companies to audit the delivery and performance of their ads on websites and apps. We also allow these companies to collect this information on OrcPub. To learn more, please see our Help Center." + [(str "Anyone can see the public role-playing game characters and other content you create, and the profile information you give us. We may also make this public information available through what are called \"APIs\" (basically a technical way to share information quickly). For example, a partner might use a " branding/app-name " API to integrate with other applications our users may be interested in. The other limited instances where we may share your personal information include:") + (str "When we have your consent. This includes sharing information with other services (like Facebook or Twitter) when you've chosen to link to your " branding/app-name " account to those services or publish your activity on " branding/app-name " to them. For example, you can choose to share your characters on Facebook or Twitter.") + (str "When you buy something on " branding/app-name " using your credit card, we may share your credit card information, contact information, and other information about the transaction with the merchant you're buying from. The merchants treat this information just as if you had made a purchase from their website directly, which means their privacy policies and marketing policies apply to the information we share with them.") + (str "Online advertisers typically use third party companies to audit the delivery and performance of their ads on websites and apps. We also allow these companies to collect this information on " branding/app-name ". To learn more, please see our Help Center.") "We may employ third party companies or individuals to process personal information on our behalf based on our instructions and in compliance with this Privacy Policy. For example, we share credit card information with the payment companies we use to store your payment information. Or, we may share data with a security consultant to help us get better at identifying spam. In addition, some of the information we request may be collected by third party providers on our behalf." - "If we believe that disclosure is reasonably necessary to comply with a law, regulation or legal request; to protect the safety, rights, or property of the public, any person, or OrcPub; or to detect, prevent, or otherwise address fraud, security or technical issues. We may share the information described in this Policy with our wholly-owned subsidiaries and affiliates. We may engage in a merger, acquisition, bankruptcy, dissolution, reorganization, or similar transaction or proceeding that involves the transfer of the information described in this Policy. We may also share aggregated or non-personally identifiable information with our partners, advertisers or others."]} + (str "If we believe that disclosure is reasonably necessary to comply with a law, regulation or legal request; to protect the safety, rights, or property of the public, any person, or " branding/app-name "; or to detect, prevent, or otherwise address fraud, security or technical issues. We may share the information described in this Policy with our wholly-owned subsidiaries and affiliates. We may engage in a merger, acquisition, bankruptcy, dissolution, reorganization, or similar transaction or proceeding that involves the transfer of the information described in this Policy. We may also share aggregated or non-personally identifiable information with our partners, advertisers or others.")]} {:title "What choices do you have about your information?" :font-size 32 :paragraphs (if (not (s/blank? (environ/env :email-access-key))) - ["You may close your account at any time by emailing " (environ/env :email-access-key) "We will then inactivate your account and remove your content from OrcPub. We may retain archived copies of you information as required by law or for legitimate business purposes (including to help address fraud and spam). "] - ["You may remove any content you create from OrcPub at any time, although we may retain archived copies of the information. You may also disable sharing of content you create at any time, whether publicly shared or privately shared with specific users." + ["You may close your account at any time by emailing " (environ/env :email-access-key) (str "We will then inactivate your account and remove your content from " branding/app-name ". We may retain archived copies of you information as required by law or for legitimate business purposes (including to help address fraud and spam). ")] + [(str "You may remove any content you create from " branding/app-name " at any time, although we may retain archived copies of the information. You may also disable sharing of content you create at any time, whether publicly shared or privately shared with specific users.") "Also, we support the Do Not Track browser setting."])} {:title "Our policy on children's information" :font-size 32 :paragraphs - ["OrcPub is not directed to children under 13. If you learn that your minor child has provided us with personal information without your consent, please contact us."]} + [(str branding/app-name " is not directed to children under 13. If you learn that your minor child has provided us with personal information without your consent, please contact us.")]} {:title "How do we make changes to this policy?" :font-size 32 :paragraphs - ["We may change this policy from time to time, and if we do we'll post any changes on this page. If you continue to use OrcPub after those changes are in effect, you agree to the revised policy. If the changes are significant, we may provide more prominent notice or get your consent as required by law."]} + [(str "We may change this policy from time to time, and if we do we'll post any changes on this page. If you continue to use " branding/app-name " after those changes are in effect, you agree to the revised policy. If the changes are significant, we may provide more prominent notice or get your consent as required by law.")]} (when (not (s/blank? (environ/env :email-access-key))) {:title "How can you contact us?" :font-size 32 @@ -98,7 +99,7 @@ {:style "background-color:#2c3445"} [:div.content [:div.flex.justify-cont-s-b.align-items-c.w-100-p.p-l-20.p-r-20 - [:img.h-60 {:src "/image/dmv-logo.svg"}]]]] + [:img.h-60 {:src branding/logo-path}]]]] [:div.container [:div.content [:div.f-s-24 @@ -111,17 +112,17 @@ {:title "Terms of Service" :font-size 48 :subsections - [{:title "Thank you for using OrcPub!" + [{:title (str "Thank you for using " branding/app-name "!") :font-size 32 :paragraphs - [[:div "These Terms of Service (\"Terms\") govern your access to and use of OrcPub's website, products, and services (\"Products\"). Please read these Terms carefully, and contact us if you have any questions. By accessing or using our Products, you agree to be bound by these Terms and by our " [:a {:href "/privacy-policy" :target :_blank} "Privacy Policy"] ". You also confirm you have read and agreed to our " [:a {:href "/community-guidelines" :target :_blank} "Community guidelines"] " and our " [:a {:href "/cookies-policy"} "Cookies policy"] "."]]} - {:title "1. Using OrcPub" + [[:div (str "These Terms of Service (\"Terms\") govern your access to and use of " branding/app-name "'s website, products, and services (\"Products\"). Please read these Terms carefully, and contact us if you have any questions. By accessing or using our Products, you agree to be bound by these Terms and by our ") [:a {:href "/privacy-policy" :target :_blank} "Privacy Policy"] ". You also confirm you have read and agreed to our " [:a {:href "/community-guidelines" :target :_blank} "Community guidelines"] " and our " [:a {:href "/cookies-policy"} "Cookies policy"] "."]]} + {:title (str "1. Using " branding/app-name) :font-size 32 :subsections - [{:title "a. Who can use OrcPub" + [{:title (str "a. Who can use " branding/app-name) :font-size 28 :paragraphs - ["You may use our Products only if you can form a binding contract with OrcPub, and only in compliance with these Terms and all applicable laws. When you create your OrcPub account, you must provide us with accurate and complete information. Any use or access by anyone under the age of 13 is prohibited. If you open an account on behalf of a company, organization, or other entity, then (a) \"you\" includes you and that entity, and (b) you represent and warrant that you are authorized to grant all permissions and licenses provided in these Terms and bind the entity to these Terms, and that you agree to these Terms on the entity's behalf. Some of our Products may be software that is downloaded to your computer, phone, tablet, or other device. You agree that we may automatically upgrade those Products, and these Terms will apply to such upgrades."]} + [(str "You may use our Products only if you can form a binding contract with " branding/app-name ", and only in compliance with these Terms and all applicable laws. When you create your " branding/app-name " account, you must provide us with accurate and complete information. Any use or access by anyone under the age of 13 is prohibited. If you open an account on behalf of a company, organization, or other entity, then (a) \"you\" includes you and that entity, and (b) you represent and warrant that you are authorized to grant all permissions and licenses provided in these Terms and bind the entity to these Terms, and that you agree to these Terms on the entity's behalf. Some of our Products may be software that is downloaded to your computer, phone, tablet, or other device. You agree that we may automatically upgrade those Products, and these Terms will apply to such upgrades.")]} {:title "b. Our license to you" :font-size 28 :paragraphs @@ -132,54 +133,54 @@ [{:title "a. Posting Content" :font-size 28 :paragraphs - ["OrcPub allows you to post content, including photos, comments, links, and other materials. Anything that you post or otherwise make available on our Products is referred to as \"User Content.\" You retain all rights in, and are solely responsible for, the User Content you post to OrcPub."]} - {:title "b. How OrcPub and other users can use your content" + [(str branding/app-name " allows you to post content, including photos, comments, links, and other materials. Anything that you post or otherwise make available on our Products is referred to as \"User Content.\" You retain all rights in, and are solely responsible for, the User Content you post to " branding/app-name ".")]} + {:title (str "b. How " branding/app-name " and other users can use your content") :font-size 28 :paragraphs - ["You grant OrcPub and our users a non-exclusive, royalty-free, transferable, sublicensable, worldwide license to use, store, display, reproduce, save, modify, create derivative works, perform, and distribute your User Content on OrcPub solely for the purposes of operating, developing, providing, and using the OrcPub Products. Nothing in these Terms shall restrict other legal rights OrcPub may have to User Content, for example under other licenses. We reserve the right to remove or modify User Content for any reason, including User Content that we believe violates these Terms or our policies."]} + [(str "You grant " branding/app-name " and our users a non-exclusive, royalty-free, transferable, sublicensable, worldwide license to use, store, display, reproduce, save, modify, create derivative works, perform, and distribute your User Content on " branding/app-name " solely for the purposes of operating, developing, providing, and using the " branding/app-name " Products. Nothing in these Terms shall restrict other legal rights " branding/app-name " may have to User Content, for example under other licenses. We reserve the right to remove or modify User Content for any reason, including User Content that we believe violates these Terms or our policies.")]} {:title "c. How long we keep your content" :font-size 28 :paragraphs - ["Following termination or deactivation of your account, or if you remove any User Content from OrcPub, we may retain your User Content for a commercially reasonable period of time for backup, archival, or audit purposes. Furthermore, OrcPub and its users may retain and continue to use, store, display, reproduce, modify, create derivative works, perform, and distribute any of your User Content that other users have stored or shared through OrcPub."]} + [(str "Following termination or deactivation of your account, or if you remove any User Content from " branding/app-name ", we may retain your User Content for a commercially reasonable period of time for backup, archival, or audit purposes. Furthermore, " branding/app-name " and its users may retain and continue to use, store, display, reproduce, modify, create derivative works, perform, and distribute any of your User Content that other users have stored or shared through " branding/app-name ".")]} {:title "d. Feedback you provide" :font-size 28 :paragraphs - ["We value hearing from our users, and are always interested in learning about ways we can make OrcPub more awesome. If you choose to submit comments, ideas or feedback, you agree that we are free to use them without any restriction or compensation to you. By accepting your submission, OrcPub does not waive any rights to use similar or related Feedback previously known to OrcPub, or developed by its employees, or obtained from sources other than you"]}]} + [(str "We value hearing from our users, and are always interested in learning about ways we can make " branding/app-name " more awesome. If you choose to submit comments, ideas or feedback, you agree that we are free to use them without any restriction or compensation to you. By accepting your submission, " branding/app-name " does not waive any rights to use similar or related Feedback previously known to " branding/app-name ", or developed by its employees, or obtained from sources other than you")]}]} {:title "3. Copyright policy" :font-size 32 :paragraphs - ["OrcPub has adopted and implemented the OrcPub Copyright policy in accordance with the Digital Millennium Copyright Act and other applicable copyright laws. For more information, please read our Copyright policy."]} + [(str branding/app-name " has adopted and implemented the " branding/app-name " Copyright policy in accordance with the Digital Millennium Copyright Act and other applicable copyright laws. For more information, please read our Copyright policy.")]} {:title "4. Security" :font-size 32 :paragraphs - ["We care about the security of our users. While we work to protect the security of your content and account, OrcPub cannot guarantee that unauthorized third parties will not be able to defeat our security measures. We ask that you keep your password secure. Please notify us immediately of any compromise or unauthorized use of your account."]} + [(str "We care about the security of our users. While we work to protect the security of your content and account, " branding/app-name " cannot guarantee that unauthorized third parties will not be able to defeat our security measures. We ask that you keep your password secure. Please notify us immediately of any compromise or unauthorized use of your account.")]} {:title "5. Third-party links, sites, and services" :font-size 32 :paragraphs - ["Our Products may contain links to third-party websites, advertisers, services, special offers, or other events or activities that are not owned or controlled by OrcPub. We do not endorse or assume any responsibility for any such third-party sites, information, materials, products, or services. If you access any third party website, service, or content from OrcPub, you do so at your own risk and you agree that OrcPub will have no liability arising from your use of or access to any third-party website, service, or content."]} + [(str "Our Products may contain links to third-party websites, advertisers, services, special offers, or other events or activities that are not owned or controlled by " branding/app-name ". We do not endorse or assume any responsibility for any such third-party sites, information, materials, products, or services. If you access any third party website, service, or content from " branding/app-name ", you do so at your own risk and you agree that " branding/app-name " will have no liability arising from your use of or access to any third-party website, service, or content.")]} {:title "6. Termination" :font-size 32 :paragraphs - ["OrcPub may terminate or suspend this license at any time, with or without cause or notice to you. Upon termination, you continue to be bound by Sections 2 and 6-12 of these Terms."]} + [(str branding/app-name " may terminate or suspend this license at any time, with or without cause or notice to you. Upon termination, you continue to be bound by Sections 2 and 6-12 of these Terms.")]} {:title "7. Indemnity" :font-size 32 :paragraphs - ["If you use our Products for commercial purposes without agreeing to our Business Terms as required by Section 1(c), as determined in our sole and absolute discretion, you agree to indemnify and hold harmless OrcPub and its respective officers, directors, employees and agents, from and against any claims, suits, proceedings, disputes, demands, liabilities, damages, losses, costs and expenses, including, without limitation, reasonable legal and accounting fees (including costs of defense of claims, suits or proceedings brought by third parties), in any way related to (a) your access to or use of our Products, (b) your User Content, or (c) your breach of any of these Terms."]} + [(str "If you use our Products for commercial purposes without agreeing to our Business Terms as required by Section 1(c), as determined in our sole and absolute discretion, you agree to indemnify and hold harmless " branding/app-name " and its respective officers, directors, employees and agents, from and against any claims, suits, proceedings, disputes, demands, liabilities, damages, losses, costs and expenses, including, without limitation, reasonable legal and accounting fees (including costs of defense of claims, suits or proceedings brought by third parties), in any way related to (a) your access to or use of our Products, (b) your User Content, or (c) your breach of any of these Terms.")]} {:title "8. Disclaimers" :font-size 32 :paragraphs ["The Products and all included content are provided on an \"as is\" basis without warranty of any kind, whether express or implied." - "ORCPUB SPECIFICALLY DISCLAIMS ANY AND ALL WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF DEALING OR USAGE OF TRADE." - "OrcPub takes no responsibility and assumes no liability for any User Content that you or any other user or third party posts or transmits using our Products. You understand and agree that you may be exposed to User Content that is inaccurate, objectionable, inappropriate for children, or otherwise unsuited to your purpose."]} + (str (.toUpperCase branding/app-name) " SPECIFICALLY DISCLAIMS ANY AND ALL WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF DEALING OR USAGE OF TRADE.") + (str branding/app-name " takes no responsibility and assumes no liability for any User Content that you or any other user or third party posts or transmits using our Products. You understand and agree that you may be exposed to User Content that is inaccurate, objectionable, inappropriate for children, or otherwise unsuited to your purpose.")]} {:title "9. Limitation of liability" :font-size 32 :paragraphs - ["TO THE MAXIMUM EXTENT PERMITTED BY LAW, ORCPUB SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOOD-WILL, OR OTHER INTANGIBLE LOSSES, RESULTING FROM (A) YOUR ACCESS TO OR USE OF OR INABILITY TO ACCESS OR USE THE PRODUCTS; (B) ANY CONDUCT OR CONTENT OF ANY THIRD PARTY ON THE PRODUCTS, INCLUDING WITHOUT LIMITATION, ANY DEFAMATORY, OFFENSIVE OR ILLEGAL CONDUCT OF OTHER USERS OR THIRD PARTIES; OR (C) UNAUTHORIZED ACCESS, USE OR ALTERATION OF YOUR TRANSMISSIONS OR CONTENT. IN NO EVENT SHALL ORCPUB'S AGGREGATE LIABILITY FOR ALL CLAIMS RELATING TO THE PRODUCTS EXCEED ONE HUNDRED U.S. DOLLARS (U.S. $100.00)."]} + [(str "TO THE MAXIMUM EXTENT PERMITTED BY LAW, " (.toUpperCase branding/app-name) " SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOOD-WILL, OR OTHER INTANGIBLE LOSSES, RESULTING FROM (A) YOUR ACCESS TO OR USE OF OR INABILITY TO ACCESS OR USE THE PRODUCTS; (B) ANY CONDUCT OR CONTENT OF ANY THIRD PARTY ON THE PRODUCTS, INCLUDING WITHOUT LIMITATION, ANY DEFAMATORY, OFFENSIVE OR ILLEGAL CONDUCT OF OTHER USERS OR THIRD PARTIES; OR (C) UNAUTHORIZED ACCESS, USE OR ALTERATION OF YOUR TRANSMISSIONS OR CONTENT. IN NO EVENT SHALL " (.toUpperCase branding/app-name) "'S AGGREGATE LIABILITY FOR ALL CLAIMS RELATING TO THE PRODUCTS EXCEED ONE HUNDRED U.S. DOLLARS (U.S. $100.00).")]} :title "10. Arbitration" :font-size 32 :paragraphs - ["For any dispute you have with OrcPub, you agree to first contact us and attempt to resolve the dispute with us informally. If OrcPub has not been able to resolve the dispute with you informally, we each agree to resolve any claim, dispute, or controversy (excluding claims for injunctive or other equitable relief) arising out of or in connection with or relating to these Terms by binding arbitration by the American Arbitration Association (\"AAA\") under the Commercial Arbitration Rules and Supplementary Procedures for Consumer Related Disputes then in effect for the AAA, except as provided herein. Unless you and OrcPub agree otherwise, the arbitration will be conducted in the county where you reside. Each party will be responsible for paying any AAA filing, administrative and arbitrator fees in accordance with AAA rules, except that OrcPub will pay for your reasonable filing, administrative, and arbitrator fees if your claim for damages does not exceed $75,000 and is non-frivolous (as measured by the standards set forth in Federal Rule of Civil Procedure 11(b)). The award rendered by the arbitrator shall include costs of arbitration, reasonable attorneys' fees and reasonable costs for expert and other witnesses, and any judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction. Nothing in this Section shall prevent either party from seeking injunctive or other equitable relief from the courts for matters related to data security, intellectual property or unauthorized access to the Service. ALL CLAIMS MUST BE BROUGHT IN THE PARTIES' INDIVIDUAL CAPACITY, AND NOT AS A PLAINTIFF OR CLASS MEMBER IN ANY PURPORTED CLASS OR REPRESENTATIVE PROCEEDING, AND, UNLESS WE AGREE OTHERWISE, THE ARBITRATOR MAY NOT CONSOLIDATE MORE THAN ONE PERSON'S CLAIMS. YOU AGREE THAT, BY ENTERING INTO THESE TERMS, YOU AND ORCPUB ARE EACH WAIVING THE RIGHT TO A TRIAL BY JURY OR TO PARTICIPATE IN A CLASS ACTION." - "To the extent any claim, dispute or controversy regarding OrcPub or our Products isn't arbitrable under applicable laws or otherwise: you and OrcPub both agree that any claim or dispute regarding OrcPub will be resolved exclusively in accordance with Clause 11 of these Terms."] + [(str "For any dispute you have with " branding/app-name ", you agree to first contact us and attempt to resolve the dispute with us informally. If " branding/app-name " has not been able to resolve the dispute with you informally, we each agree to resolve any claim, dispute, or controversy (excluding claims for injunctive or other equitable relief) arising out of or in connection with or relating to these Terms by binding arbitration by the American Arbitration Association (\"AAA\") under the Commercial Arbitration Rules and Supplementary Procedures for Consumer Related Disputes then in effect for the AAA, except as provided herein. Unless you and " branding/app-name " agree otherwise, the arbitration will be conducted in the county where you reside. Each party will be responsible for paying any AAA filing, administrative and arbitrator fees in accordance with AAA rules, except that " branding/app-name " will pay for your reasonable filing, administrative, and arbitrator fees if your claim for damages does not exceed $75,000 and is non-frivolous (as measured by the standards set forth in Federal Rule of Civil Procedure 11(b)). The award rendered by the arbitrator shall include costs of arbitration, reasonable attorneys' fees and reasonable costs for expert and other witnesses, and any judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction. Nothing in this Section shall prevent either party from seeking injunctive or other equitable relief from the courts for matters related to data security, intellectual property or unauthorized access to the Service. ALL CLAIMS MUST BE BROUGHT IN THE PARTIES' INDIVIDUAL CAPACITY, AND NOT AS A PLAINTIFF OR CLASS MEMBER IN ANY PURPORTED CLASS OR REPRESENTATIVE PROCEEDING, AND, UNLESS WE AGREE OTHERWISE, THE ARBITRATOR MAY NOT CONSOLIDATE MORE THAN ONE PERSON'S CLAIMS. YOU AGREE THAT, BY ENTERING INTO THESE TERMS, YOU AND " (.toUpperCase branding/app-name) " ARE EACH WAIVING THE RIGHT TO A TRIAL BY JURY OR TO PARTICIPATE IN A CLASS ACTION.") + (str "To the extent any claim, dispute or controversy regarding " branding/app-name " or our Products isn't arbitrable under applicable laws or otherwise: you and " branding/app-name " both agree that any claim or dispute regarding " branding/app-name " will be resolved exclusively in accordance with Clause 11 of these Terms.")] {:title "11. Governing law and jurisdiction" :font-size 32 :paragraphs @@ -190,24 +191,23 @@ [{:title "Notification procedures and changes to these Terms" :font-size 28 :paragraphs - ["OrcPub reserves the right to determine the form and means of providing notifications to you, and you agree to receive legal notices electronically if we so choose. We may revise these Terms from time to time and the most current version will always be posted on our website. If a revision, in our sole discretion, is material we will notify you. By continuing to access or use the Products after revisions become effective, you agree to be bound by the revised Terms. If you do not agree to the new terms, please stop using the Products."]} + [(str branding/app-name " reserves the right to determine the form and means of providing notifications to you, and you agree to receive legal notices electronically if we so choose. We may revise these Terms from time to time and the most current version will always be posted on our website. If a revision, in our sole discretion, is material we will notify you. By continuing to access or use the Products after revisions become effective, you agree to be bound by the revised Terms. If you do not agree to the new terms, please stop using the Products.")]} {:title "Assignment" :font-size 28 :paragraphs - ["These Terms, and any rights and licenses granted hereunder, may not be transferred or assigned by you, but may be assigned by OrcPub without restriction. Any attempted transfer or assignment in violation hereof shall be null and void. -"]} + [(str "These Terms, and any rights and licenses granted hereunder, may not be transferred or assigned by you, but may be assigned by " branding/app-name " without restriction. Any attempted transfer or assignment in violation hereof shall be null and void.\n")]} {:title "Entire agreement/severability" :font-size 28 :paragraphs - ["These Terms, together with the Privacy policy and any amendments and any additional agreements you may enter into with OrcPub in connection with the Products, shall constitute the entire agreement between you and OrcPub concerning the Products. If any provision of these Terms is deemed invalid, then that provision will be limited or eliminated to the minimum extent necessary, and the remaining provisions of these Terms will remain in full force and effect."]} + [(str "These Terms, together with the Privacy policy and any amendments and any additional agreements you may enter into with " branding/app-name " in connection with the Products, shall constitute the entire agreement between you and " branding/app-name " concerning the Products. If any provision of these Terms is deemed invalid, then that provision will be limited or eliminated to the minimum extent necessary, and the remaining provisions of these Terms will remain in full force and effect.")]} {:title "No waiver" :font-size 28 :paragraphs - ["No waiver of any term of these Terms shall be deemed a further or continuing waiver of such term or any other term, and OrcPub's failure to assert any right or provision under these Terms shall not constitute a waiver of such right or provision."]} + [(str "No waiver of any term of these Terms shall be deemed a further or continuing waiver of such term or any other term, and " branding/app-name "'s failure to assert any right or provision under these Terms shall not constitute a waiver of such right or provision.")]} {:title "Parties" :font-size 28 :paragraphs - ["These Terms are a contract between you and OrcPub" + [(str "These Terms are a contract between you and " branding/app-name) "Effective May 1, 2017"]}]}]}) (defn terms-of-use [] @@ -220,8 +220,8 @@ [{:title "Our Mission" :font-size 32 :paragraphs - ["At OrcPub, our mission is to help you discover and do what you love. That means showing you ideas that are relevant, interesting and personal to you, and making sure you don't see anything that's inappropriate or spammy." - "These are guidelines for what we do and don't allow on OrcPub. If you come across content that seems to break these rules, you can report it to us."]} + [(str "At " branding/app-name ", our mission is to help you discover and do what you love. That means showing you ideas that are relevant, interesting and personal to you, and making sure you don't see anything that's inappropriate or spammy.") + (str "These are guidelines for what we do and don't allow on " branding/app-name ". If you come across content that seems to break these rules, you can report it to us.")]} {:title "Safety" :font-size 32 :paragraphs @@ -237,20 +237,20 @@ {:title "Intellectual property and other rights" :font-size 32 :paragraphs - ["To respect the rights of people on and off OrcPub, please:" + [(str "To respect the rights of people on and off " branding/app-name ", please:") "Don't infringe anyone's intellectual property, privacy or other rights." "Don't do anything or post any content that violates laws or regulations." - "Don't use OrcPub's name, logo or trademark in a way that confuses people (check out our brand guidelines for more details)."]} + (str "Don't use " branding/app-name "'s name, logo or trademark in a way that confuses people (check out our brand guidelines for more details).")]} {:title "Site security and access" :font-size 32 :paragraphs - ["To keep OrcPub secure, we ask that you please:" + [(str "To keep " branding/app-name " secure, we ask that you please:") "Don't access, use or tamper with our systems or our technical providers' systems." "Don't break or circumvent our security measures or test the vulnerability of our systems or networks." - "Don't use any undocumented or unsupported method to access, search, scrape, download or change any part of OrcPub." + (str "Don't use any undocumented or unsupported method to access, search, scrape, download or change any part of " branding/app-name ".") "Don't try to reverse engineer our software." - "Don't try to interfere with people on OrcPub or our hosts or networks, like sending a virus, overloading, spamming or mail-bombing." - "Don't collect or store personally identifiable information from OrcPub or people on OrcPub without permission." + (str "Don't try to interfere with people on " branding/app-name " or our hosts or networks, like sending a virus, overloading, spamming or mail-bombing.") + (str "Don't collect or store personally identifiable information from " branding/app-name " or people on " branding/app-name " without permission.") "Don't share your password, let anyone access your account or do anything that might put your account at risk." "Don't sell access to your account, boards, or username, or otherwise transfer account features for compensation."]} {:title "Spam" @@ -270,10 +270,10 @@ {:title "Cookies" :font-size 48 :subsections - [{:title "Cookies on OrcPub" + [{:title (str "Cookies on " branding/app-name) :font-size 32 :paragraphs - ["Our privacy policy describes how we collect and use information, and what choices you have. One way we collect information is through the use of a technology called \"cookies.\" We use cookies for all kinds of things on OrcPub."]} + [(str "Our privacy policy describes how we collect and use information, and what choices you have. One way we collect information is through the use of a technology called \"cookies.\" We use cookies for all kinds of things on " branding/app-name ".")]} {:title "What's a cookie?" :font-size 32 :paragraphs @@ -281,7 +281,7 @@ {:title "How we use cookies" :font-size 32 :paragraphs - ["We use cookies for lots of essential things on OrcPub—like helping you log in and tailoring your OrcPub experience. Here are some specifics on how we use cookies."]} + [(str "We use cookies for lots of essential things on " branding/app-name "—like helping you log in and tailoring your " branding/app-name " experience. Here are some specifics on how we use cookies.")]} {:title "What we use cookies for" :font-size 32 :subsections @@ -297,29 +297,29 @@ {:title "Login" :font-size 32 :paragraphs - ["Cookies let you log in and out of OrcPub."]} + [(str "Cookies let you log in and out of " branding/app-name ".")]} {:title "Security" :font-size 32 :paragraphs - ["Cookies are just one way we protect you from security risks. For example, we use them to detect when someone might be trying to hack your OrcPub account or spam the OrcPub community."]} + [(str "Cookies are just one way we protect you from security risks. For example, we use them to detect when someone might be trying to hack your " branding/app-name " account or spam the " branding/app-name " community.")]} {:title "Analytics" :font-size 32 :paragraphs - ["We use cookies to make OrcPub better. For example, these cookies tell us how many people use a certain feature and how popular it is, or whether people open an email we send." + [(str "We use cookies to make " branding/app-name " better. For example, these cookies tell us how many people use a certain feature and how popular it is, or whether people open an email we send.") "We also use cookies to help advertisers understand who sees and interacts with their ads, and who visits their website or purchases their products."]} {:title "Service providers" :font-size 32 :paragraphs - ["Sometimes we hire security vendors or use third-party analytics providers to help us understand how people are using OrcPub. Just like we do, these providers may use cookies. Learn more about the third party providers we use."]}]} + [(str "Sometimes we hire security vendors or use third-party analytics providers to help us understand how people are using " branding/app-name ". Just like we do, these providers may use cookies. Learn more about the third party providers we use.")]}]} {:title "Where we use cookies" :font-size 32 :paragraphs - ["We use cookies on orcpub.com, in our mobile applications, and in our products and services (like ads, emails and applications). We also use them on the websites of partners who use OrcPub's Save button, OrcPub widgets, or ad tools like conversion tracking."]} + [(str "We use cookies on orcpub.com, in our mobile applications, and in our products and services (like ads, emails and applications). We also use them on the websites of partners who use " branding/app-name "'s Save button, " branding/app-name " widgets, or ad tools like conversion tracking.")]} {:title "Your options" :font-size 32 :paragraphs ["Your browser probably gives you cookie choices. For example, most browsers let you block \"third party cookies,\" which are cookies from sites other than the one you're visiting. Those options vary from browser to browser, so check your browser settings for more info." - "Some browsers also have a privacy setting called \"Do Not Track,\" which we support. This setting is another way for you to decide whether we use info from our partners and other services to customize OrcPub for you." + (str "Some browsers also have a privacy setting called \"Do Not Track,\" which we support. This setting is another way for you to decide whether we use info from our partners and other services to customize " branding/app-name " for you.") "Effective November 1, 2016"]}]}) (defn cookie-policy [] From af228f12a3e060746983fea6f503b7b3a87e91b0 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 05:41:09 +0000 Subject: [PATCH 07/50] feat: add client-side integration stubs (integrations.cljs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New integrations.cljs provides no-op hooks for page view tracking and ad banner placement. Forks override with real implementations. Wire track-page-view! into :route event handler (single choke point for all navigation). Document server→client config bridge pattern in integrations.clj. --- src/clj/orcpub/integrations.clj | 15 ++++++++++++ src/cljs/orcpub/dnd/e5/events.cljs | 4 +++- src/cljs/orcpub/integrations.cljs | 38 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/cljs/orcpub/integrations.cljs diff --git a/src/clj/orcpub/integrations.clj b/src/clj/orcpub/integrations.clj index 6a29f7d8e..008102f04 100644 --- a/src/clj/orcpub/integrations.clj +++ b/src/clj/orcpub/integrations.clj @@ -46,6 +46,21 @@ ;; :src (str "https://cdn.example.com/sdk.js?client=" sdk-client) ;; :crossorigin "anonymous"}])) +;; ─── Client-Side Config Bridge ────────────────────────────────── +;; Server-side integrations load SDK scripts in <head>. +;; Client-side components (ad banners, tracking) live in +;; integrations.cljs — forks override those no-op stubs. +;; +;; To pass server-side config (env vars) to CLJS components: +;; 1. Add a client-config function here: +;; (defn client-config [] {:sdk-client sdk-client}) +;; 2. Inject it in index.clj as a JS global: +;; (script-tag {:nonce nonce} +;; (str "window.__INTEGRATIONS__=" +;; (cheshire.core/generate-string (integrations/client-config)) ";")) +;; 3. Read it in CLJS: +;; (def config (js->clj js/window.__INTEGRATIONS__ :keywordize-keys true)) + (def csp-domains "Extra CSP domains required by enabled integrations. Returns {:connect-src [\"https://...\"] :frame-src [\"https://...\"]}. diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index a26bfab43..2eaef38b8 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -79,7 +79,8 @@ [orcpub.errors :as errors] [clojure.set :as sets] [cljsjs.filesaverjs] - [clojure.pprint :as pprint]) + [clojure.pprint :as pprint] + [orcpub.integrations :as integrations]) (:require-macros [cljs.core.async.macros :refer [go]])) ;; ============================================================================= @@ -1547,6 +1548,7 @@ (reg-event-fx :route (fn [{:keys [db]} [_ {:keys [handler route-params] :as new-route} {:keys [no-return? skip-path? event secure?] :as options}]] + (integrations/track-page-view! new-route) (let [{:keys [route route-history]} db seq-params (seq route-params) flat-params (flatten seq-params) diff --git a/src/cljs/orcpub/integrations.cljs b/src/cljs/orcpub/integrations.cljs new file mode 100644 index 000000000..0d6a44ac3 --- /dev/null +++ b/src/cljs/orcpub/integrations.cljs @@ -0,0 +1,38 @@ +(ns orcpub.integrations + "Client-side integration hooks. No-op by default. + Forks override these functions with real implementations + (analytics tracking, ad placements, etc.). + + Companion to integrations.clj (server-side head tags). + Server-side loads third-party SDKs in <head>; + this namespace provides the in-app component hooks.") + +;; ─── Page View Tracking ───────────────────────────────────────── +;; Call from the :route event handler (events.cljs), NOT from render +;; function bodies (which fire on every React re-render). +;; +;; Example override (Matomo): +;; (defn track-page-view! [route] +;; (when (exists? js/_paq) +;; (.push js/_paq #js ["setCustomUrl" js/window.location.href]) +;; (.push js/_paq #js ["trackPageView"]))) + +(defn track-page-view! + "Track a page navigation. No-op by default." + [_route]) + +;; ─── Ad Components ────────────────────────────────────────────── +;; Hook for ad banner placement in the page body. +;; The SDK script tag is loaded server-side via integrations.clj; +;; this component renders the actual ad placement element. +;; +;; Example override (AdSense): +;; (defn ad-banner [] +;; [:ins.adsbygoogle {:style {:display "block"} +;; :data-ad-client "ca-pub-xxx" +;; :data-ad-slot "yyy"}]) + +(defn ad-banner + "Ad banner component. Returns nil (renders nothing) by default." + [] + nil) From 0982c84cfc03fa530003a90a264c30d22b6dd0e2 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 05:42:23 +0000 Subject: [PATCH 08/50] docs: add branding and integrations env vars to .env.example Document all new APP_* branding vars and integration vars with examples and cross-reference to fork-customization KB doc. --- .env.example | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.env.example b/.env.example index 8a95039de..9270c0c38 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,31 @@ EMAIL_ERRORS_TO= EMAIL_SSL=FALSE EMAIL_TLS=FALSE +# --- Branding (optional) --- +# Override to customize the app identity for your deployment. +# All values have neutral defaults; set only what you want to change. +# APP_NAME=My D&D Toolkit +# APP_TAGLINE=A custom character builder +# APP_PAGE_TITLE=My Toolkit: D&D 5e Character Builder +# APP_LOGO_PATH=/image/my-logo.svg +# APP_OG_IMAGE=/image/my-og-preview.png +# APP_COPYRIGHT_HOLDER=My Org +# APP_COPYRIGHT_YEAR=2025 +# APP_EMAIL_SENDER_NAME=My Toolkit Team +# APP_SOCIAL_PATREON= +# APP_SOCIAL_FACEBOOK= +# APP_SOCIAL_TWITTER= +# APP_SOCIAL_REDDIT= +# APP_SOCIAL_DISCORD= + +# --- Third-Party Integrations (optional) --- +# Server-side <head> tags (analytics, ad SDKs) loaded via integrations.clj. +# Client-side hooks (ad banners, page tracking) in integrations.cljs. +# See docs/kb/fork-customization.md for the full pattern. +# ANALYTICS_URL=https://your-analytics.example.com/ +# ANALYTICS_SITE_ID=1 +# SDK_CLIENT=your-sdk-client-id + # --- Initial Admin User (optional) --- # Set these then run: ./docker-user.sh init # Safe to run multiple times — duplicates are skipped. From 69b3e24d05eff26c8055aa81d6f919164106db6e Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 06:40:13 +0000 Subject: [PATCH 09/50] fix: add PORT fallback (8890) in prod service map System/getenv returns nil when PORT isn't in shell env, causing Jetty to fail with "HTTP was turned off with nil port". Now falls back to 8890 (matching dev config). --- src/clj/orcpub/system.clj | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/clj/orcpub/system.clj b/src/clj/orcpub/system.clj index 5f6591a45..c221c047f 100644 --- a/src/clj/orcpub/system.clj +++ b/src/clj/orcpub/system.clj @@ -34,15 +34,14 @@ ::http/host "0.0.0.0" ;; Pedestal 0.7+ requires explicit interceptor coercion for maps/functions ::http/enable-session false ; Disable default session handling if not needed - ::http/port (let [port-str (System/getenv "PORT")] - (when port-str - (try - (Integer/parseInt port-str) - (catch NumberFormatException e - (throw (ex-info "Invalid PORT environment variable. Expected a number." - {:error :invalid-port - :port port-str} - e)))))) + ::http/port (let [port-str (or (System/getenv "PORT") "8890")] + (try + (Integer/parseInt port-str) + (catch NumberFormatException e + (throw (ex-info "Invalid PORT environment variable. Expected a number." + {:error :invalid-port + :port port-str} + e))))) ::http/join false ::http/resource-path "/public" ;; CSP configured via CSP_POLICY env var (strict|permissive|none) From 4295ce0e489669f705e716c79c06dc565b2810f4 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 14:23:28 +0000 Subject: [PATCH 10/50] refactor: add branding config bridge, user-tier/user-data stubs, neutral integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B: public repo abstraction layer matching DMV overrides. New files: - branding.cljs: reads window.__BRANDING__ with OrcPub defaults - user_tier.cljs: :user-tier sub always returns :free - user_data.clj: pass-through enrich-response + empty registration-defaults Modified: - branding.clj: add support-email, help-url, email-from-address, client-config - index.clj: inject window.__BRANDING__ JSON via cheshire - integrations.cljs: rename ad-banner → content-slot, add on-app-mount!, strip ad/analytics example comments - integrations.clj: strip analytics/SDK example comments, simplify docs - views.cljs: wire branding (logo, social links, copyright, app-name), require user-tier + integrations - character_builder.cljs: patreon-link-props → supporter-link-props, wire to branding/social-links - routes.clj: wire user-data hooks (enrich-response, registration-defaults) - email.clj: emailfrom → branding/email-from-address - .env.example: add APP_SUPPORT_EMAIL, APP_HELP_URL, clean integration comments --- .env.example | 9 ++-- src/clj/orcpub/branding.clj | 35 ++++++++++++-- src/clj/orcpub/email.clj | 2 +- src/clj/orcpub/index.clj | 6 ++- src/clj/orcpub/integrations.clj | 67 +++++--------------------- src/clj/orcpub/routes.clj | 26 ++++++---- src/clj/orcpub/user_data.clj | 30 ++++++++++++ src/cljs/orcpub/branding.cljs | 46 ++++++++++++++++++ src/cljs/orcpub/character_builder.cljs | 8 ++- src/cljs/orcpub/dnd/e5/views.cljs | 33 ++++++++----- src/cljs/orcpub/integrations.cljs | 46 +++++++++--------- src/cljs/orcpub/user_tier.cljs | 16 ++++++ 12 files changed, 211 insertions(+), 113 deletions(-) create mode 100644 src/clj/orcpub/user_data.clj create mode 100644 src/cljs/orcpub/branding.cljs create mode 100644 src/cljs/orcpub/user_tier.cljs diff --git a/.env.example b/.env.example index 9270c0c38..61941e5c5 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,8 @@ EMAIL_TLS=FALSE # APP_COPYRIGHT_HOLDER=My Org # APP_COPYRIGHT_YEAR=2025 # APP_EMAIL_SENDER_NAME=My Toolkit Team +# APP_SUPPORT_EMAIL=support@example.com +# APP_HELP_URL=https://example.com/help/ # APP_SOCIAL_PATREON= # APP_SOCIAL_FACEBOOK= # APP_SOCIAL_TWITTER= @@ -83,12 +85,9 @@ EMAIL_TLS=FALSE # APP_SOCIAL_DISCORD= # --- Third-Party Integrations (optional) --- -# Server-side <head> tags (analytics, ad SDKs) loaded via integrations.clj. -# Client-side hooks (ad banners, page tracking) in integrations.cljs. +# Server-side <head> tags loaded via integrations.clj. +# Client-side lifecycle hooks in integrations.cljs. # See docs/kb/fork-customization.md for the full pattern. -# ANALYTICS_URL=https://your-analytics.example.com/ -# ANALYTICS_SITE_ID=1 -# SDK_CLIENT=your-sdk-client-id # --- Initial Admin User (optional) --- # Set these then run: ./docker-user.sh init diff --git a/src/clj/orcpub/branding.clj b/src/clj/orcpub/branding.clj index c773e0c0e..661b7d42f 100644 --- a/src/clj/orcpub/branding.clj +++ b/src/clj/orcpub/branding.clj @@ -2,9 +2,9 @@ "Centralized branding configuration for fork-neutral deployment. All values have sensible defaults; forks override via env vars. - Server-side only (.clj). Client-side branding in views.cljs reads - these values indirectly via server-rendered HTML (splash page, OG tags) - or can be centralized separately for CLJS in a future pass." + Server-side (.clj) is the source of truth. Client-side branding + is delivered via the config bridge: index.clj injects client-config + as window.__BRANDING__ JSON in <head>, and branding.cljs reads it." (:require [environ.core :refer [env]])) ;; ─── App Identity ────────────────────────────────────────────────── @@ -50,6 +50,20 @@ "Display name for outbound emails (verification, password reset)." (or (env :app-email-sender-name) (str app-name " Team"))) +(def email-from-address + "From address for outbound emails. Falls back to env EMAIL_FROM_ADDRESS." + (or (env :email-from-address) "no-reply@orcpub.com")) + +;; ─── Support & Help ────────────────────────────────────────────── + +(def support-email + "Contact email shown on privacy page, error messages, etc. Empty = hidden." + (or (env :app-support-email) "")) + +(def help-url + "URL for the help/FAQ page. Empty string = hidden." + (or (env :app-help-url) "")) + ;; ─── Social Links ────────────────────────────────────────────────── ;; Set any of these to "" to hide the link in the UI. @@ -60,3 +74,18 @@ :twitter (or (env :app-social-twitter) "") :reddit (or (env :app-social-reddit) "") :discord (or (env :app-social-discord) "")}) + +;; ─── Client-Side Config Bridge ─────────────────────────────────── +;; index.clj injects this as window.__BRANDING__ JSON in <head>. +;; branding.cljs reads it at runtime for CLJS components. + +(defn client-config + "Map of branding values for CLJS injection. Serialized to JSON by index.clj." + [] + {:app-name app-name + :logo-path logo-path + :copyright-holder copyright-holder + :copyright-year copyright-year + :support-email support-email + :help-url help-url + :social-links social-links}) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index e9242ed18..6e8ce1765 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -74,7 +74,7 @@ e))))) (defn emailfrom [] - (if (not (s/blank? (environ/env :email-from-address))) (environ/env :email-from-address) "no-reply@dungeonmastersvault.com")) + branding/email-from-address) (defn send-verification-email "Sends account verification email to a new user. diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index db3d4df4a..015cdd54c 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -4,6 +4,8 @@ [orcpub.dnd.e5.views-2 :as views-2] [orcpub.favicon :as fi] [orcpub.integrations :as integrations] + [orcpub.branding :as branding] + [cheshire.core :as cheshire] [environ.core :refer [env]])) (def devmode? (env :dev-mode)) @@ -134,7 +136,9 @@ html { min-height: 100%; }"] [:title title] - (integrations/head-tags nonce)] + (integrations/head-tags nonce) + (script-tag {:nonce nonce} + (str "window.__BRANDING__=" (cheshire/generate-string (branding/client-config)) ";"))] [:body {:style "margin:0;line-height:1"} [:div#app (if splash? diff --git a/src/clj/orcpub/integrations.clj b/src/clj/orcpub/integrations.clj index 008102f04..9b56efdaf 100644 --- a/src/clj/orcpub/integrations.clj +++ b/src/clj/orcpub/integrations.clj @@ -1,7 +1,7 @@ (ns orcpub.integrations - "Optional third-party <head> integrations (analytics, SDKs, etc.). + "Optional third-party <head> integrations. Configure via environment variables; disabled when unset. - See commented examples below for the pattern." + Fork overrides: uncomment examples and add real service config." (:require [environ.core :refer [env]])) ;; ─── How to add an integration ─────────────────────────────────────── @@ -16,70 +16,27 @@ ;; :src (str "https://example.com/sdk.js?id=" my-service-id)}])) ;; ;; 3. Add it to head-tags's concat list below. -;; -;; -;; ─── Example: Analytics ────────────────────────────────────────────── -;; -;; (def analytics-url (env :analytics-url)) -;; (def analytics-site-id (env :analytics-site-id)) -;; -;; (defn- analytics-tags -;; "Self-hosted analytics. Returns a list of hiccup elements." -;; [nonce] -;; (when (and (seq analytics-url) (seq analytics-site-id)) -;; (list -;; [:link {:rel "preconnect" :href analytics-url :crossorigin ""}] -;; [:script {:nonce nonce} -;; (str "(function(){var u='" analytics-url "';" -;; "/* tracker init */})();")]))) -;; -;; -;; ─── Example: External SDK ──────────────────────────────────────────── -;; -;; (def sdk-client (env :sdk-client)) -;; -;; (defn- sdk-tag -;; "External SDK loader." -;; [nonce] -;; (when (seq sdk-client) -;; [:script {:nonce nonce :async "" -;; :src (str "https://cdn.example.com/sdk.js?client=" sdk-client) -;; :crossorigin "anonymous"}])) ;; ─── Client-Side Config Bridge ────────────────────────────────── -;; Server-side integrations load SDK scripts in <head>. -;; Client-side components (ad banners, tracking) live in -;; integrations.cljs — forks override those no-op stubs. -;; -;; To pass server-side config (env vars) to CLJS components: -;; 1. Add a client-config function here: -;; (defn client-config [] {:sdk-client sdk-client}) -;; 2. Inject it in index.clj as a JS global: -;; (script-tag {:nonce nonce} -;; (str "window.__INTEGRATIONS__=" -;; (cheshire.core/generate-string (integrations/client-config)) ";")) -;; 3. Read it in CLJS: -;; (def config (js->clj js/window.__INTEGRATIONS__ :keywordize-keys true)) +;; Server-side integrations load scripts in <head>. +;; Client-side hooks live in integrations.cljs — forks override +;; those no-op stubs with real implementations. +;; +;; To pass server-side config (env vars) to CLJS: +;; 1. Add a client-config function here +;; 2. Inject it in index.clj as a JS global via cheshire +;; 3. Read it in CLJS at namespace load time +;; See branding.clj/client-config for a working example. (def csp-domains "Extra CSP domains required by enabled integrations. Returns {:connect-src [\"https://...\"] :frame-src [\"https://...\"]}. csp.clj merges these into the Content-Security-Policy header." - ;; (merge-with into - ;; (when (seq analytics-url) - ;; {:connect-src [analytics-url]}) - ;; (when (seq sdk-client) - ;; {:connect-src ["https://cdn.example.com"] - ;; :frame-src ["https://cdn.example.com"]})) {}) (defn head-tags "All third-party integration tags for <head>. Returns a flat seq of hiccup elements, empty when nothing is configured. - Uncomment integration calls as you enable them." + Fork overrides: add integration tag calls here." [_nonce] - ;; (remove nil? - ;; (concat - ;; [(sdk-tag nonce)] - ;; (analytics-tags nonce))) ()) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 6f87a9d8b..1cd421e35 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -34,6 +34,7 @@ [orcpub.privacy :as privacy] [orcpub.email :as email] [orcpub.branding :as branding] + [orcpub.user-data :as user-data] [orcpub.index :refer [index-page]] [orcpub.pdf :as pdf] [orcpub.registration :as registration] @@ -235,10 +236,15 @@ (map :orcpub.user/username (d/pull-many db '[:orcpub.user/username] ids))) -(defn user-body [db user] - (cond-> {:username (:orcpub.user/username user) - :email (:orcpub.user/email user) - :following (following-usernames db (map :db/id (:orcpub.user/following user)))} +(defn user-body + "Build the user API response. Core fields are inline; fork-specific + fields (e.g. tier data) are added by user-data/enrich-response." + [db user] + (cond-> (user-data/enrich-response + {:username (:orcpub.user/username user) + :email (:orcpub.user/email user) + :following (following-usernames db (map :db/id (:orcpub.user/following user)))} + user) (:orcpub.user/pending-email user) (assoc :pending-email (:orcpub.user/pending-email user)))) @@ -347,11 +353,13 @@ request json-params conn - {:orcpub.user/email email - :orcpub.user/username username - :orcpub.user/password (hashers/encrypt password) - :orcpub.user/send-updates? send-updates? - :orcpub.user/created (java.util.Date.)})) + (merge + {:orcpub.user/email email + :orcpub.user/username username + :orcpub.user/password (hashers/encrypt password) + :orcpub.user/send-updates? send-updates? + :orcpub.user/created (java.util.Date.)} + (user-data/registration-defaults)))) (catch Throwable e (prn e) (throw e))))) (def user-for-verification-key-query diff --git a/src/clj/orcpub/user_data.clj b/src/clj/orcpub/user_data.clj new file mode 100644 index 000000000..69ce6e78e --- /dev/null +++ b/src/clj/orcpub/user_data.clj @@ -0,0 +1,30 @@ +(ns orcpub.user-data + "User data enrichment hooks. Pass-through by default. + Fork overrides: replace this file to add custom fields to the + user API response and registration defaults. + + Routes.clj calls these hooks at two points: + 1. user-body — enrich-response can add fields to the API response + 2. registration — registration-defaults can set initial values + + Datomic queries pull [*], so all attributes are available on the + user entity passed to enrich-response.") + +;; ─── Response Enrichment ─────────────────────────────────────── +;; No-op by default. Forks override to map additional Datomic +;; attributes into the API response sent to the client. + +(defn enrich-response + "Add fork-specific fields to the user API response map. + `data` is the base response, `user` is the full Datomic entity." + [data _user] + data) + +;; ─── Registration Defaults ───────────────────────────────────── +;; No-op by default. Forks override to set initial attribute values +;; for newly registered users. + +(defn registration-defaults + "Default Datomic attributes for newly registered users." + [] + {}) diff --git a/src/cljs/orcpub/branding.cljs b/src/cljs/orcpub/branding.cljs new file mode 100644 index 000000000..cf5d6f176 --- /dev/null +++ b/src/cljs/orcpub/branding.cljs @@ -0,0 +1,46 @@ +(ns orcpub.branding + "Client-side branding config. Reads server-injected window.__BRANDING__. + Fallback values are used in dev/REPL where no server injection exists. + + Server-side source of truth: branding.clj + Bridge: index.clj injects branding/client-config as JSON in <head>.") + +;; ─── Config Bridge ─────────────────────────────────────────────── +;; Server injects branding.clj/client-config as window.__BRANDING__ JSON. +;; We read it once at namespace load time. + +(def ^:private config + (when (exists? js/window.__BRANDING__) + (js->clj js/window.__BRANDING__ :keywordize-keys true))) + +;; ─── Branding Values ───────────────────────────────────────────── +;; Each def reads from the bridge config with a hardcoded fallback +;; for dev/REPL environments where the server isn't injecting. + +(def app-name + "Full display name." + (:app-name config "OrcPub")) + +(def logo-path + "Path to the main SVG logo." + (:logo-path config "/image/orcpub-logo.svg")) + +(def copyright-holder + "Entity name for legal footer." + (:copyright-holder config "OrcPub")) + +(def copyright-year + "Copyright year string." + (:copyright-year config "2025")) + +(def support-email + "Contact email for error messages and privacy page." + (:support-email config "")) + +(def help-url + "URL for the help/FAQ page. Empty = hidden." + (:help-url config "")) + +(def social-links + "Map of social platform links. Empty string = hidden." + (:social-links config {})) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 8a6144183..05f97c70a 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -36,6 +36,7 @@ [orcpub.dnd.e5.db :as db] [orcpub.dnd.e5.views :as views5e] [orcpub.dnd.e5.subs :as subs5e] + [orcpub.branding :as branding] [orcpub.route-map :as routes] [orcpub.pdf-spec :as pdf-spec] [orcpub.user-agent :as user-agent] @@ -1903,8 +1904,11 @@ :mobile [mobile-columns] [desktop-or-tablet-columns device-type]))) -(def patreon-link-props - {:href "https://www.patreon.com/user?u=5892323" :target "_blank"}) +(defn supporter-link-props + "Link props for the supporter/funding page. Returns nil when no URL configured." + [] + (when-let [url (not-empty (:patreon branding/social-links))] + {:href url :target "_blank"})) ;; ============================================================================ ;; Missing Content Warning diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 435eced39..d13db79c5 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -42,6 +42,9 @@ [orcpub.dnd.e5.options :as opt] [orcpub.dnd.e5.events :as events] [orcpub.ver :as v] + [orcpub.branding :as branding] + [orcpub.user-tier] + [orcpub.integrations :as integrations] [clojure.string :as s] [cljs.reader :as reader] [orcpub.user-agent :as user-agent] @@ -354,7 +357,7 @@ (dispatch [:route routes/dnd-e5-my-encounters-route])) (def logo [:img.h-60.pointer - {:src "/image/dmv-logo.svg" + {:src branding/logo-path :on-click route-to-default-route}]) (defn app-header [] @@ -391,16 +394,20 @@ {:class (if mobile? "justify-cont-s-b" "justify-cont-s-b")} [:div {:style {:min-width "53px"}} - [:a {:href "https://www.patreon.com/DungeonMastersVault" :target :_blank} - [:img.h-32.m-l-10.m-b-5.pointer.opacity-7.hover-opacity-full - {:src (if mobile? - "https://c5.patreon.com/external/logo/downloads_logomark_color_on_navy.png" - "https://c5.patreon.com/external/logo/become_a_patron_button.png")}]] + (when-let [url (not-empty (:patreon branding/social-links))] + [:a {:href url :target :_blank} + [:img.h-32.m-l-10.m-b-5.pointer.opacity-7.hover-opacity-full + {:src (if mobile? + "https://c5.patreon.com/external/logo/downloads_logomark_color_on_navy.png" + "https://c5.patreon.com/external/logo/become_a_patron_button.png")}]]) (when (not mobile?) [:div.main-text-color.p-10 - (social-icon "facebook-f" "https://www.facebook.com/groups/252484128656613/") - (social-icon "twitter" "https://twitter.com/thDMV") - (social-icon "reddit-alien" "https://www.reddit.com/r/dungeonmastersvault/")])] + (when-let [url (not-empty (:facebook branding/social-links))] + (social-icon "facebook-f" url)) + (when-let [url (not-empty (:twitter branding/social-links))] + (social-icon "twitter" url)) + (when-let [url (not-empty (:reddit branding/social-links))] + (social-icon "reddit-alien" url))])] [:div.flex.m-b-5.m-t-5.justify-cont-s-b.app-header-menu [header-tab "characters" @@ -529,7 +536,7 @@ [:div.flex.justify-cont-s-a.align-items-c {:style registration-header-style} [:img.h-55.pointer - {:src "/image/dmv-logo.svg" + {:src branding/logo-path :on-click route-to-default-page}]] [:div.flex-grow-1 content] [views-2/legal-footer]] @@ -816,7 +823,7 @@ :border-width "1px" :border-bottom-width "3px"} :on-click #(dispatch [:registration-send-updates? (not send-updates?)])}] - [:span.m-l-5 "Yes! Send me updates about OrcPub."]] + [:span.m-l-5 (str "Yes! Send me updates about " branding/app-name ".")]] [:div.m-t-30 [:div.p-10 [:span "Already have an account?"] @@ -1463,7 +1470,7 @@ [:a.orange {:href "/privacy-policy" :target :_blank} "Privacy Policy"] [:a.orange.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"]] [:div.legal-footer - [:p "© " (.getFullYear (js/Date.)) " " [:a.orange {:href "https://github.com/Orcpub/orcpub/" :target :_blank} "Orcpub"]] + [:p "© " branding/copyright-year " " branding/copyright-holder] [:p "This site is based on " srd-link " - Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © " (.getFullYear (js/Date.)) " Wizards. All Rights Reserved."] [:p "This site is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] [:p "Version " (v/version) " (" (v/date) ") " (v/description) " edition"]]] @@ -3471,7 +3478,7 @@ (defn share-link [id] [:a.m-r-5.f-s-14 - {:href (str "mailto:?subject=My%20OrcPub%20Character%20" + {:href (str "mailto:?subject=My%20" (js/encodeURIComponent branding/app-name) "%20Character%20" @(subscribe [::char/character-name id]) "&body=https://" js/window.location.hostname diff --git a/src/cljs/orcpub/integrations.cljs b/src/cljs/orcpub/integrations.cljs index 0d6a44ac3..7e220314b 100644 --- a/src/cljs/orcpub/integrations.cljs +++ b/src/cljs/orcpub/integrations.cljs @@ -1,38 +1,36 @@ (ns orcpub.integrations "Client-side integration hooks. No-op by default. - Forks override these functions with real implementations - (analytics tracking, ad placements, etc.). + Fork overrides: replace these functions with real implementations. Companion to integrations.clj (server-side head tags). - Server-side loads third-party SDKs in <head>; - this namespace provides the in-app component hooks.") + Server-side loads third-party scripts in <head>; + this namespace provides the in-app lifecycle hooks.") ;; ─── Page View Tracking ───────────────────────────────────────── -;; Call from the :route event handler (events.cljs), NOT from render +;; Called from the :route event handler (events.cljs), NOT from render ;; function bodies (which fire on every React re-render). -;; -;; Example override (Matomo): -;; (defn track-page-view! [route] -;; (when (exists? js/_paq) -;; (.push js/_paq #js ["setCustomUrl" js/window.location.href]) -;; (.push js/_paq #js ["trackPageView"]))) (defn track-page-view! - "Track a page navigation. No-op by default." + "Track a page navigation. No-op by default. + Fork overrides: call your analytics provider here." [_route]) -;; ─── Ad Components ────────────────────────────────────────────── -;; Hook for ad banner placement in the page body. -;; The SDK script tag is loaded server-side via integrations.clj; -;; this component renders the actual ad placement element. -;; -;; Example override (AdSense): -;; (defn ad-banner [] -;; [:ins.adsbygoogle {:style {:display "block"} -;; :data-ad-client "ca-pub-xxx" -;; :data-ad-slot "yyy"}]) +;; ─── App Mount Hook ─────────────────────────────────────────────── +;; Called from the app root component-did-mount. Handles mount-time +;; integration setup (e.g. user identification, external service init). -(defn ad-banner - "Ad banner component. Returns nil (renders nothing) by default." +(defn on-app-mount! + "Mount-time integrations. Called once from app root component-did-mount. + Context map: {:user-tier :free|... :username str :email str} + Fork overrides: wire analytics user identification, etc." + [_context]) + +;; ─── Content Slot ────────────────────────────────────────────── +;; Hook for rendering supplementary content in the page body. +;; Fork overrides: return hiccup for banners, promotions, etc. + +(defn content-slot + "Supplementary content component. Returns nil (renders nothing) by default. + Fork overrides: return hiccup to render content in designated slots." [] nil) diff --git a/src/cljs/orcpub/user_tier.cljs b/src/cljs/orcpub/user_tier.cljs new file mode 100644 index 000000000..60b784e0d --- /dev/null +++ b/src/cljs/orcpub/user_tier.cljs @@ -0,0 +1,16 @@ +(ns orcpub.user-tier + "User tier abstraction for feature gating. + No tier system on the public repo — all users are :free. + Fork overrides: replace this file with real tier logic. + + UI code gates on :user-tier subscription. This keeps fork-specific + vocabulary out of shared view code." + (:require [re-frame.core :refer [reg-sub]])) + +;; ─── Tier Subscription ─────────────────────────────────────────── +;; Returns :free for all users. Forks override this file to derive +;; tier from their own user data model. + +(reg-sub + :user-tier + (fn [_ _] :free)) From 458244bb0ad696e958ef04d1995da0ff302cb780 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 14:30:58 +0000 Subject: [PATCH 11/50] fix: views_2.cljc branding reader conditionals + add track-character-list! stub - views_2.cljc: remove CLJS reader conditionals, use branding on both sides - integrations.cljs: add track-character-list! no-op stub --- src/cljc/orcpub/dnd/e5/views_2.cljc | 7 +++---- src/cljs/orcpub/integrations.cljs | 8 ++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/cljc/orcpub/dnd/e5/views_2.cljc b/src/cljc/orcpub/dnd/e5/views_2.cljc index e1e9a7d2d..6a82a3287 100644 --- a/src/cljc/orcpub/dnd/e5/views_2.cljc +++ b/src/cljc/orcpub/dnd/e5/views_2.cljc @@ -1,7 +1,7 @@ (ns orcpub.dnd.e5.views-2 (:require [orcpub.route-map :as routes] [clojure.string :as s] - #?(:clj [orcpub.branding :as branding]))) + [orcpub.branding :as branding])) (defn style [style] #?(:cljs style) @@ -40,8 +40,7 @@ (defn legal-footer [] [:div.m-l-15.m-b-10.m-t-10.t-a-l - [:span #?(:clj (str "\u00a9 " branding/copyright-year " " branding/copyright-holder) - :cljs "\u00a9 2025 OrcPub")] + [:span (str "\u00a9 " branding/copyright-year " " branding/copyright-holder)] [:a.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"] [:a.m-l-5 {:href "/privacy-policy" :target :_blank} "Privacy Policy"]]) @@ -65,7 +64,7 @@ {:style (style {:display :flex :justify-content :space-around})} [:img.w-30-p - {:src #?(:clj branding/logo-path :cljs "/image/orcpub-logo.svg")}]] + {:src branding/logo-path}]] [:div {:style (style {:text-align :center :text-shadow "1px 2px 1px black" diff --git a/src/cljs/orcpub/integrations.cljs b/src/cljs/orcpub/integrations.cljs index 7e220314b..d4c408f80 100644 --- a/src/cljs/orcpub/integrations.cljs +++ b/src/cljs/orcpub/integrations.cljs @@ -25,6 +25,14 @@ Fork overrides: wire analytics user identification, etc." [_context]) +;; ─── Analytics Custom Variables ───────────────────────────────── +;; Called from render functions that need to tag analytics events +;; with page-specific data. + +(defn track-character-list! + "Tag the character list view with analytics data. No-op by default." + [_character-count _user-tier]) + ;; ─── Content Slot ────────────────────────────────────────────── ;; Hook for rendering supplementary content in the page body. ;; Fork overrides: return hiccup for banners, promotions, etc. From 79e736b2e69694afa88a607e9f4b18efaed5c144 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 15:27:09 +0000 Subject: [PATCH 12/50] refactor: wire shared files to integrations hooks + field-limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror DMV integration hook API on public branch: - supporter-link: header supporter button (shows when URL configured) - support-banner: no-op stub (fork overrides with announcements) - content-slot: now accepts user-tier arg, returns nil by default - pdf-options-slot: no-op stub below PDF sheet options - share-links / share-link-www: email share with dynamic protocol - track-character-list!: no-op stub Add field-limits to branding config bridge ({:notes 50000 :text 255 :number 7}). Add current-year and raw-html generic helpers to views.cljs. Social links section gets usage examples in comments. Shared files (views.cljs, character_builder.cljs) now call integrations hooks identically to DMV — fork-specific rendering decisions live in the override file, not inline in shared code. --- src/clj/orcpub/branding.clj | 17 ++++- src/cljs/orcpub/branding.cljs | 4 ++ src/cljs/orcpub/character_builder.cljs | 79 ++++++++++++----------- src/cljs/orcpub/dnd/e5/views.cljs | 88 +++++++++++++------------- src/cljs/orcpub/integrations.cljs | 73 ++++++++++++++++++++- 5 files changed, 175 insertions(+), 86 deletions(-) diff --git a/src/clj/orcpub/branding.clj b/src/clj/orcpub/branding.clj index 661b7d42f..8e854e063 100644 --- a/src/clj/orcpub/branding.clj +++ b/src/clj/orcpub/branding.clj @@ -65,7 +65,10 @@ (or (env :app-help-url) "")) ;; ─── Social Links ────────────────────────────────────────────────── -;; Set any of these to "" to hide the link in the UI. +;; Each link appears in the header/footer when non-empty. +;; Set the corresponding env var to a URL to enable, or leave unset to hide. +;; e.g. in .env: APP_SOCIAL_PATREON=https://www.patreon.com/YourProject +;; APP_SOCIAL_DISCORD=https://discord.gg/your-invite (def social-links "Map of social platform links. Empty string = hidden." @@ -75,6 +78,15 @@ :reddit (or (env :app-social-reddit) "") :discord (or (env :app-social-discord) "")}) +;; ─── Field Limits ──────────────────────────────────────────────── +;; Input field max-length constraints for form validation. + +(def field-limits + "Max-length constraints for form input fields." + {:notes (or (some-> (env :app-field-limit-notes) Integer/parseInt) 50000) + :text (or (some-> (env :app-field-limit-text) Integer/parseInt) 255) + :number (or (some-> (env :app-field-limit-number) Integer/parseInt) 7)}) + ;; ─── Client-Side Config Bridge ─────────────────────────────────── ;; index.clj injects this as window.__BRANDING__ JSON in <head>. ;; branding.cljs reads it at runtime for CLJS components. @@ -88,4 +100,5 @@ :copyright-year copyright-year :support-email support-email :help-url help-url - :social-links social-links}) + :social-links social-links + :field-limits field-limits}) diff --git a/src/cljs/orcpub/branding.cljs b/src/cljs/orcpub/branding.cljs index cf5d6f176..b1e940cee 100644 --- a/src/cljs/orcpub/branding.cljs +++ b/src/cljs/orcpub/branding.cljs @@ -44,3 +44,7 @@ (def social-links "Map of social platform links. Empty string = hidden." (:social-links config {})) + +(def field-limits + "Max-length constraints for form input fields." + (:field-limits config {:notes 50000 :text 255 :number 7})) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 05f97c70a..6cee065ad 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -46,6 +46,7 @@ [clojure.core.match :refer [match]] [reagent.core :as r] + [orcpub.integrations :as integrations] [re-frame.core :refer [subscribe dispatch dispatch-sync]])) ;console-print (def print-disabled? true) @@ -2108,44 +2109,46 @@ (when (not character-changed?) (js/window.scrollTo 0,0)) ;//Force a scroll to top of page only if we are not editing. [views5e/content-page "Character Builder" - (remove - nil? - [(when character-id [views5e/share-link character-id]) - {:title "Random" - :icon "random" - :on-click (confirm-handler - character-changed? - {:confirm-button-text "GENERATE RANDOM CHARACTER" - :question "You have unsaved changes, are you sure you want to discard them and generate a random character?" - :pre set-loading - :event [:random-character character built-template locked-components]})} - {:title "New" - :icon "plus" - :on-click (confirm-handler - character-changed? - {:confirm-button-text "CREATE NEW CHARACTER" - :question "You have unsaved changes, are you sure you want to discard them and create a new character?" - :event [:reset-character]})} - {:title "Clone" - :icon "clone" - :on-click (confirm-handler - character-changed? - {:confirm-button-text "CREATE CLONE" - :question "You have unsaved changes, are you sure you want to discard them and clone this character? The new character will have the unsaved changes, the original will not." - :event [::char5e/clone-character]})} - {:title "Print" - :icon "print" - :on-click (views5e/make-print-handler (:db/id character) built-char)} - {:title (if (:db/id character) - "Update Existing Character" - "Save New Character") - :icon "save" - :style (when character-changed? unsaved-button-style) - :on-click #(save-character built-char)} - (when (:db/id character) - {:title "View" - :icon "eye" - :on-click (load-character-page (:db/id character))})]) + (into + (if character-id + (vec (integrations/share-links character-id @(subscribe [::char5e/character-name character-id]))) + []) + (remove nil? + [{:title "Random" + :icon "random" + :on-click (confirm-handler + character-changed? + {:confirm-button-text "GENERATE RANDOM CHARACTER" + :question "You have unsaved changes, are you sure you want to discard them and generate a random character?" + :pre set-loading + :event [:random-character character built-template locked-components]})} + {:title "New" + :icon "plus" + :on-click (confirm-handler + character-changed? + {:confirm-button-text "CREATE NEW CHARACTER" + :question "You have unsaved changes, are you sure you want to discard them and create a new character?" + :event [:reset-character]})} + {:title "Clone" + :icon "clone" + :on-click (confirm-handler + character-changed? + {:confirm-button-text "CREATE CLONE" + :question "You have unsaved changes, are you sure you want to discard them and clone this character? The new character will have the unsaved changes, the original will not." + :event [::char5e/clone-character]})} + {:title "Print" + :icon "print" + :on-click (views5e/make-print-handler (:db/id character) built-char)} + {:title (if (:db/id character) + "Update Existing Character" + "Save New Character") + :icon "save" + :style (when character-changed? unsaved-button-style) + :on-click #(save-character built-char)} + (when (:db/id character) + {:title "View" + :icon "eye" + :on-click (load-character-page (:db/id character))})])) [:div [:div.container [:div.content diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index d13db79c5..08af85054 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -394,12 +394,7 @@ {:class (if mobile? "justify-cont-s-b" "justify-cont-s-b")} [:div {:style {:min-width "53px"}} - (when-let [url (not-empty (:patreon branding/social-links))] - [:a {:href url :target :_blank} - [:img.h-32.m-l-10.m-b-5.pointer.opacity-7.hover-opacity-full - {:src (if mobile? - "https://c5.patreon.com/external/logo/downloads_logomark_color_on_navy.png" - "https://c5.patreon.com/external/logo/become_a_patron_button.png")}]]) + [integrations/supporter-link @(subscribe [:user-tier]) mobile? svg-icon] (when (not mobile?) [:div.main-text-color.p-10 (when-let [url (not-empty (:facebook branding/social-links))] @@ -1366,6 +1361,14 @@ (def srd-link [:a.orange {:href "/SRD-OGL_V5.1.pdf" :target "_blank"} "the 5e SRD"]) +(defn current-year [] + (.getFullYear (js/Date.))) + +(defn raw-html + "Render raw HTML string inside Reagent." + [html] + [:div {:dangerouslySetInnerHTML #js {:__html html}}]) + (defn orcacle [] (let [search-text @(subscribe [:search-text])] [:div.flex.flex-column.h-100-p.white @@ -1445,21 +1448,24 @@ hdr]]] [:div.flex.justify-cont-c.main-text-color [:div.content hdr]] - ; Banner for announcements - #_[:div.m-l-20.m-r-20.f-w-b.f-s-18.container.m-b-10.main-text-color - (if (and (not srd-message-closed?) - (not hide-header-message?)) - [:div - (if (not frame?) - [:div.content.bg-lighter.p-10.flex - [:div.flex-grow-1 - [:div "Site is based on SRD rules. " srd-link "."]] - [:i.fa.fa-times.p-10.pointer - {:on-click #(dispatch [:close-srd-message])}]])])] + ;; Support banner (integrations-gated) + [:div.m-l-20.m-r-20.f-w-b.f-s-18.container.m-b-10.main-text-color + [integrations/support-banner + {:srd-message-closed? srd-message-closed? + :hide-header-message? hide-header-message? + :frame? frame? + :user-tier @(subscribe [:user-tier]) + :on-dismiss #(dispatch [:close-srd-message])}]] + + ;; Content slot (integrations-gated) + [integrations/content-slot @(subscribe [:user-tier])] + [:div#app-main.container [:div.content.w-100-p content]] [:div.main-text-color.flex.justify-cont-c [:div.content.f-w-n.f-s-12 + ;; Content slot (integrations-gated) + [integrations/content-slot @(subscribe [:user-tier])] [:div.flex.justify-cont-s-b.align-items-c.flex-wrap.p-10 [:div [:div.m-b-5 "Icons made by Lorc, Caduceus, and Delapouite. Available on " [:a.orange {:href "http://game-icons.net"} "http://game-icons.net"]] @@ -1470,8 +1476,8 @@ [:a.orange {:href "/privacy-policy" :target :_blank} "Privacy Policy"] [:a.orange.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"]] [:div.legal-footer - [:p "© " branding/copyright-year " " branding/copyright-holder] - [:p "This site is based on " srd-link " - Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © " (.getFullYear (js/Date.)) " Wizards. All Rights Reserved."] + [:p "© " (current-year) " " branding/copyright-holder] + [:p "This site is based on " srd-link " - Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © " (current-year) " Wizards. All Rights Reserved."] [:p "This site is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] [:p "Version " (v/version) " (" (v/date) ") " (v/description) " edition"]]] [debug-data]]]])]))}))) @@ -3476,15 +3482,6 @@ (when @show-selections? [character-selections id])]]])))) -(defn share-link [id] - [:a.m-r-5.f-s-14 - {:href (str "mailto:?subject=My%20" (js/encodeURIComponent branding/app-name) "%20Character%20" - @(subscribe [::char/character-name id]) - "&body=https://" - js/window.location.hostname - (routes/path-for routes/dnd-e5-char-page-route :id id))} - [:i.fa.fa-envelope.m-r-5] - "share"]) (def character-display-style {:padding "20px 5px" @@ -3597,6 +3594,7 @@ {:title "Petersen Games - Cthulhu Mythos Sagas sheet" :value 4}] :value print-character-sheet-style? :on-change (make-arg-event-handler ::char/set-print-character-sheet-style? js/parseInt)}]]] + [integrations/pdf-options-slot @(subscribe [:user-tier])] [:div.flex [:div {:on-click (make-event-handler ::char/toggle-large-abilities-print)} @@ -3673,21 +3671,23 @@ "Character Page") (remove nil? - [[share-link id] - [:div.m-l-5.hover-shadow.pointer - {:on-click #(swap! expanded? not)} - [:img.h-32 {:src "/image/world-anvil.jpeg"}]] - (when (and username - owner - (= owner username)) - {:title "Edit" - :icon "pencil" - :on-click (make-event-handler :edit-character character)}) - {:title "Print" - :icon "print" - :on-click (make-print-handler id built-character)} - (when (and username owner (not= owner username)) - [add-to-party-component id])]) + (into + (vec (integrations/share-links id @(subscribe [::char/character-name id]))) + (remove nil? + [[:div.m-l-5.hover-shadow.pointer + {:on-click #(swap! expanded? not)} + [:img.h-32 {:src "/image/world-anvil.jpeg"}]] + (when (and username + owner + (= owner username)) + {:title "Edit" + :icon "pencil" + :on-click (make-event-handler :edit-character character)}) + {:title "Print" + :icon "print" + :on-click (make-print-handler id built-character)} + (when (and username owner (not= owner username)) + [add-to-party-component id])]))) [:div.p-10.main-text-color (when @expanded? (let [url js/window.location.href] @@ -7914,7 +7914,7 @@ [:div {:style character-display-style} [:div.flex.justify-cont-end.uppercase.align-items-c - [share-link id] + [integrations/share-link-www id] (when (= username owner) [:button.form-button {:on-click (make-event-handler :edit-character character)} diff --git a/src/cljs/orcpub/integrations.cljs b/src/cljs/orcpub/integrations.cljs index d4c408f80..77fdc2a23 100644 --- a/src/cljs/orcpub/integrations.cljs +++ b/src/cljs/orcpub/integrations.cljs @@ -4,7 +4,9 @@ Companion to integrations.clj (server-side head tags). Server-side loads third-party scripts in <head>; - this namespace provides the in-app lifecycle hooks.") + this namespace provides the in-app lifecycle hooks." + (:require [orcpub.branding :as branding] + [orcpub.route-map :as routes])) ;; ─── Page View Tracking ───────────────────────────────────────── ;; Called from the :route event handler (events.cljs), NOT from render @@ -35,10 +37,77 @@ ;; ─── Content Slot ────────────────────────────────────────────── ;; Hook for rendering supplementary content in the page body. +;; Self-gated: accepts user-tier, returns nil by default. ;; Fork overrides: return hiccup for banners, promotions, etc. (defn content-slot "Supplementary content component. Returns nil (renders nothing) by default. Fork overrides: return hiccup to render content in designated slots." - [] + [_user-tier] nil) + +;; ─── Supporter Link ────────────────────────────────────────── +;; Header supporter area. Shows a supporter button when a URL is configured. +;; Fork overrides: add tier badges, enhanced button styles, etc. + +(defn supporter-link + "Header supporter link. Shows Patreon/supporter button when URL is configured. + icon-fn: (fn [icon-name size css] hiccup) — render function, unused in default." + [_user-tier mobile? _icon-fn] + (when-let [url (not-empty (:patreon branding/social-links))] + [:a {:href url :target :_blank} + [:img.h-32.m-l-10.m-b-5.pointer.opacity-7.hover-opacity-full + {:src (if mobile? + "https://c5.patreon.com/external/logo/downloads_logomark_color_on_navy.png" + "https://c5.patreon.com/external/logo/become_a_patron_button.png")}]])) + +;; ─── Support Banner ────────────────────────────────────────── +;; Dismissable banner for site announcements or support messages. +;; Fork overrides: return hiccup for donation CTAs, announcements, etc. + +(defn support-banner + "Site announcement/support banner. Returns nil by default. + Opts: {:srd-message-closed? bool :hide-header-message? bool + :frame? bool :user-tier keyword :on-dismiss fn}" + [_opts] + nil) + +;; ─── PDF Upsell ────────────────────────────────────────────── +;; Hook for additional content below PDF sheet options. +;; Fork overrides: return hiccup for upsell blocks, premium feature promos. + +(defn pdf-options-slot + "Additional content below PDF options. Returns nil by default." + [_user-tier] + nil) + +;; ─── Share Links ───────────────────────────────────────────── +;; Character sharing links. Default provides a single email share. +;; Fork overrides: add direct links, frame support, etc. + +(defn share-links + "Returns a seq of share-link hiccup elements for a character. + Default: single email share with dynamic protocol." + [id character-name] + [[:a.m-r-5.f-s-14 + {:href (str "mailto:?subject=My%20" (js/encodeURIComponent branding/app-name) "%20Character%20" + character-name + "&body=" js/window.location.protocol "//" + js/window.location.hostname + (when-let [p js/window.location.port] (when (seq p) (str ":" p))) + (routes/path-for routes/dnd-e5-char-page-route :id id))} + [:i.fa.fa-envelope.m-r-5] + "share"]]) + +(defn share-link-www + "Direct www share link. Default: same as email share. + Fork overrides: add frame support, direct URL, etc." + [id] + [:a.m-r-5.f-s-14 + {:href (str js/window.location.protocol "//" + js/window.location.hostname + (when-let [p js/window.location.port] (when (seq p) (str ":" p))) + (routes/path-for routes/dnd-e5-char-page-route :id id)) + :target "_blank"} + [:i.fa.fa-link.m-r-5] + "www"]) From 39ff156a761c8656ca5c5c1177cda3196962b41b Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 15:38:30 +0000 Subject: [PATCH 13/50] fix: rename PDF Upsell section header to PDF Options Slot --- src/cljs/orcpub/integrations.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cljs/orcpub/integrations.cljs b/src/cljs/orcpub/integrations.cljs index 77fdc2a23..3ffe01a77 100644 --- a/src/cljs/orcpub/integrations.cljs +++ b/src/cljs/orcpub/integrations.cljs @@ -72,9 +72,9 @@ [_opts] nil) -;; ─── PDF Upsell ────────────────────────────────────────────── +;; ─── PDF Options Slot ──────────────────────────────────────── ;; Hook for additional content below PDF sheet options. -;; Fork overrides: return hiccup for upsell blocks, premium feature promos. +;; Fork overrides: return hiccup for premium feature promos, etc. (defn pdf-options-slot "Additional content below PDF options. Returns nil by default." From fce75de8c2e3d2ccdd4ebce8bf4706ebabf4b23b Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 15:41:30 +0000 Subject: [PATCH 14/50] docs: add branding and integrations configuration guide --- docs/BRANDING-AND-INTEGRATIONS.md | 124 ++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/BRANDING-AND-INTEGRATIONS.md diff --git a/docs/BRANDING-AND-INTEGRATIONS.md b/docs/BRANDING-AND-INTEGRATIONS.md new file mode 100644 index 000000000..78e51769a --- /dev/null +++ b/docs/BRANDING-AND-INTEGRATIONS.md @@ -0,0 +1,124 @@ +# Branding & Integrations + +How to customize the app's identity and add third-party integrations. + +## Branding + +All branding values are configured via environment variables in `.env`. Defaults are set in `src/clj/orcpub/branding.clj`. + +Server-side values reach the browser through a config bridge: `branding.clj` builds a map, `index.clj` injects it as `window.__BRANDING__` JSON in `<head>`, and `branding.cljs` reads it at runtime. + +### App Identity + +| Env Var | Default | Where it shows up | +|---------|---------|-------------------| +| `APP_NAME` | OrcPub | Page titles, emails, privacy policy, OG tags | +| `APP_LOGO_PATH` | /image/orcpub-logo.svg | Header, splash page, privacy page | +| `APP_OG_IMAGE` | /image/orcpub-logo.png | Social sharing preview | +| `APP_TAGLINE` | D&D 5e character builder... | OG meta tags | +| `APP_PAGE_TITLE` | OrcPub: D&D 5e... | Browser tab title | + +### Copyright & Contact + +| Env Var | Default | Where it shows up | +|---------|---------|-------------------| +| `APP_COPYRIGHT_HOLDER` | OrcPub | Footer | +| `APP_COPYRIGHT_YEAR` | 2025 | Footer | +| `APP_SUPPORT_EMAIL` | *(empty = hidden)* | Privacy page, error messages | +| `APP_HELP_URL` | *(empty = hidden)* | Footer help link | + +### Email + +| Env Var | Default | Where it shows up | +|---------|---------|-------------------| +| `APP_EMAIL_SENDER_NAME` | OrcPub Team | "From" display name | +| `EMAIL_FROM_ADDRESS` | no-reply@orcpub.com | "From" address | + +### Social Links + +Shown in the app header/footer when non-empty. Leave unset to hide. + +| Env Var | Example | +|---------|---------| +| `APP_SOCIAL_PATREON` | `https://www.patreon.com/YourProject` | +| `APP_SOCIAL_FACEBOOK` | `https://www.facebook.com/groups/yourgroup/` | +| `APP_SOCIAL_TWITTER` | `https://twitter.com/yourhandle` | +| `APP_SOCIAL_REDDIT` | `https://reddit.com/r/yoursubreddit` | +| `APP_SOCIAL_DISCORD` | `https://discord.gg/your-invite` | + +When `APP_SOCIAL_PATREON` is set, a supporter button appears in the header. + +### Field Limits + +Input validation constraints for form fields. + +| Env Var | Default | Used for | +|---------|---------|----------| +| `APP_FIELD_LIMIT_NOTES` | 50000 | Character notes, backstory | +| `APP_FIELD_LIMIT_TEXT` | 255 | Name fields, short text | +| `APP_FIELD_LIMIT_NUMBER` | 7 | Numeric inputs | + +--- + +## Integrations + +Third-party services (analytics, ads) are managed through two files: + +- **`integrations.clj`** (server-side) — injects `<script>` tags in `<head>` +- **`integrations.cljs`** (client-side) — provides lifecycle hooks and UI components + +### Analytics & Ads + +| Env Var | Default | What it enables | +|---------|---------|-----------------| +| `MATOMO_URL` | *(empty = disabled)* | Matomo analytics tracking | +| `MATOMO_SITE_ID` | *(empty = disabled)* | Matomo site ID | +| `ADSENSE_CLIENT` | *(empty = disabled)* | Google AdSense | + +### Integration Hooks + +The app calls these functions at specific points. By default they're no-ops — override them in `integrations.cljs` to add custom behavior. + +**Lifecycle hooks** (called from events/views): + +| Function | When it's called | +|----------|-----------------| +| `track-page-view!` | Every route change | +| `on-app-mount!` | App root component mount | +| `track-character-list!` | Character list render | + +**UI hooks** (return hiccup or nil): + +| Function | Where it renders | +|----------|-----------------| +| `content-slot` | Content page body (2 slots) | +| `supporter-link` | App header | +| `support-banner` | Content page top | +| `pdf-options-slot` | Below PDF sheet options | +| `share-links` | Character page + builder | +| `share-link-www` | Character list items | + +--- + +## Adding a New Integration Hook + +1. Add the stub function in `integrations.cljs` (empty body or `nil` return) +2. Wire the call site in the appropriate shared file (views.cljs, etc.) +3. Implement the real behavior in the stub body + +--- + +## Files That Read Config + +| File | What it reads | +|------|--------------| +| `branding.clj` | All `APP_*` env vars | +| `integrations.clj` | `MATOMO_URL`, `MATOMO_SITE_ID`, `ADSENSE_CLIENT` | +| `index.clj` | Calls `branding/client-config` + `integrations/head-tags` | +| `privacy.clj` | `branding/*` for app name + `integrations/head-tags` for scripts | +| `email.clj` | `branding/email-from-address`, `branding/email-sender-name` | +| `branding.cljs` | Reads `window.__BRANDING__` (injected by index.clj) | +| `integrations.cljs` | Reads `branding/*` via branding.cljs | +| `views.cljs` | `branding/*` + `integrations/*` hooks | +| `events.cljs` | `branding/support-email` | +| `character_builder.cljs` | `integrations/share-links` | From 92528c868ac2aeee41e20853e3fc13542e859f6a Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 15:50:06 +0000 Subject: [PATCH 15/50] fix: update integrations.cljs ns docstring to reflect minimal defaults --- src/cljs/orcpub/integrations.cljs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cljs/orcpub/integrations.cljs b/src/cljs/orcpub/integrations.cljs index 3ffe01a77..4b15d7cff 100644 --- a/src/cljs/orcpub/integrations.cljs +++ b/src/cljs/orcpub/integrations.cljs @@ -1,10 +1,14 @@ (ns orcpub.integrations - "Client-side integration hooks. No-op by default. - Fork overrides: replace these functions with real implementations. + "Client-side integration hooks with minimal defaults. + Fork overrides: replace with full implementations. + + Lifecycle hooks (track-page-view!, on-app-mount!, etc.) are no-ops. + UI hooks provide basic defaults (e.g. supporter-link shows a Patreon + button when configured, share-links provides a single email link). Companion to integrations.clj (server-side head tags). Server-side loads third-party scripts in <head>; - this namespace provides the in-app lifecycle hooks." + this namespace provides the in-app component hooks." (:require [orcpub.branding :as branding] [orcpub.route-map :as routes])) From fd86faf4386d8358fe75873bc3f273a7ffd7fac5 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 16:12:37 +0000 Subject: [PATCH 16/50] =?UTF-8?q?fix:=20hardening=20pass=20=E2=80=94=20ema?= =?UTF-8?q?il,=20privacy,=20social=20icons,=20config=20bridge=20stubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit branding.clj: add app-url def, :bluesky social link, dynamic copyright-year email.clj: add error handling to send-email-change-verification, fix "Patron" greeting to use name/neutral, fix grammar integrations.clj: add client-config stub (empty map) index.clj: inject window.__INTEGRATIONS__ alongside __BRANDING__ privacy.clj: replace hardcoded "orcpub.com" with branding/app-name views.cljs: add bluesky inline SVG icon, add discord/bluesky to social links --- src/clj/orcpub/branding.clj | 12 ++++++-- src/clj/orcpub/email.clj | 50 +++++++++++++++++++++++-------- src/clj/orcpub/index.clj | 3 +- src/clj/orcpub/integrations.clj | 17 ++++++----- src/clj/orcpub/privacy.clj | 2 +- src/cljs/orcpub/dnd/e5/views.cljs | 22 ++++++++++++-- 6 files changed, 79 insertions(+), 27 deletions(-) diff --git a/src/clj/orcpub/branding.clj b/src/clj/orcpub/branding.clj index 8e854e063..76b2016e2 100644 --- a/src/clj/orcpub/branding.clj +++ b/src/clj/orcpub/branding.clj @@ -5,7 +5,8 @@ Server-side (.clj) is the source of truth. Client-side branding is delivered via the config bridge: index.clj injects client-config as window.__BRANDING__ JSON in <head>, and branding.cljs reads it." - (:require [environ.core :refer [env]])) + (:require [environ.core :refer [env]]) + (:import [java.time Year])) ;; ─── App Identity ────────────────────────────────────────────────── @@ -18,6 +19,10 @@ (or (env :app-tagline) "D&D 5e character builder/generator and digital character sheet far beyond any other in the multiverse.")) +(def app-url + "Primary application URL for legal pages and external references. Empty = hidden." + (or (env :app-url) "")) + (def default-page-title "Default <title> and og:title when no page-specific title is set." (or (env :app-page-title) @@ -41,8 +46,8 @@ (or (env :app-copyright-holder) "OrcPub")) (def copyright-year - "Copyright year string." - (or (env :app-copyright-year) "2025")) + "Copyright year string. Defaults to the current year." + (or (env :app-copyright-year) (str (.getValue (Year/now))))) ;; ─── Email ───────────────────────────────────────────────────────── @@ -74,6 +79,7 @@ "Map of social platform links. Empty string = hidden." {:patreon (or (env :app-social-patreon) "") :facebook (or (env :app-social-facebook) "") + :bluesky (or (env :app-social-bluesky) "") :twitter (or (env :app-social-twitter) "") :reddit (or (env :app-social-reddit) "") :discord (or (env :app-social-discord) "")}) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 6e8ce1765..926c66e28 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -15,10 +15,11 @@ (defn verification-email-html [first-and-last-name username verification-url] [:div - (str "Dear " branding/app-name " Patron,") + (str "Welcome to " branding/app-name + (when (seq first-and-last-name) (str ", " first-and-last-name)) "!") [:br] [:br] - (str "Your " branding/app-name " account is almost ready, we just need you to verify your email address going the following URL to confirm that you are authorized to use this email address:") + (str "Your " branding/app-name " account is almost ready, we just need you to verify your email address by visiting the following URL to confirm that you are authorized to use this email address:") [:br] [:br] [:a {:href verification-url} verification-url] @@ -37,7 +38,7 @@ "Email body for existing users changing their email (distinct from registration)." [username verification-url] [:div - (str "Dear " branding/app-name " Patron,") + (str "Dear " branding/app-name " User,") [:br] [:br] "You requested to change the email address on your account (" username "). " @@ -114,19 +115,44 @@ e))))) (defn send-email-change-verification - "Send a verification email for an email-change request (not registration)." + "Send a verification email for an email-change request (not registration). + + Args: + base-url - Base URL for the application (for verification link) + user-map - Map containing :email and :username + verification-key - Unique key for email change verification + + Returns: + Postal send-message result + + Throws: + ExceptionInfo with :email-change-verification-failed error code if email cannot be sent" [base-url {:keys [email username]} verification-key] - (postal/send-message (email-cfg) - {:from (str branding/email-sender-name " <" (emailfrom) ">") - :to email - :subject (str branding/app-name " Email Change Verification") - :body (email-change-verification-email - username - (str base-url (routes/path-for routes/verify-route) "?key=" verification-key))})) + (try + (let [result (postal/send-message (email-cfg) + {:from (str branding/email-sender-name " <" (emailfrom) ">") + :to email + :subject (str branding/app-name " Email Change Verification") + :body (email-change-verification-email + username + (str base-url (routes/path-for routes/verify-route) "?key=" verification-key))})] + (when (not= :SUCCESS (:error result)) + (throw (ex-info "Failed to send email change verification" + {:error :email-send-failed + :email email + :postal-response result}))) + result) + (catch Exception e + (println "ERROR: Failed to send email change verification to" email ":" (.getMessage e)) + (throw (ex-info "Unable to send email change verification. Please check your email configuration or try again later." + {:error :email-change-verification-failed + :email email + :username username} + e))))) (defn reset-password-email-html [first-and-last-name reset-url] [:div - (str "Dear " branding/app-name " Patron") + (str "Dear " (if (seq first-and-last-name) first-and-last-name (str branding/app-name " User")) ",") [:br] [:br] "We received a request to reset your password, to do so please go to the following URL to complete the reset." diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index 015cdd54c..1a936cb3a 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -138,7 +138,8 @@ html { [:title title] (integrations/head-tags nonce) (script-tag {:nonce nonce} - (str "window.__BRANDING__=" (cheshire/generate-string (branding/client-config)) ";"))] + (str "window.__BRANDING__=" (cheshire/generate-string (branding/client-config)) ";" + "window.__INTEGRATIONS__=" (cheshire/generate-string (integrations/client-config)) ";"))] [:body {:style "margin:0;line-height:1"} [:div#app (if splash? diff --git a/src/clj/orcpub/integrations.clj b/src/clj/orcpub/integrations.clj index 9b56efdaf..4919ffd10 100644 --- a/src/clj/orcpub/integrations.clj +++ b/src/clj/orcpub/integrations.clj @@ -18,16 +18,17 @@ ;; 3. Add it to head-tags's concat list below. ;; ─── Client-Side Config Bridge ────────────────────────────────── -;; Server-side integrations load scripts in <head>. -;; Client-side hooks live in integrations.cljs — forks override -;; those no-op stubs with real implementations. -;; -;; To pass server-side config (env vars) to CLJS: -;; 1. Add a client-config function here -;; 2. Inject it in index.clj as a JS global via cheshire -;; 3. Read it in CLJS at namespace load time +;; Passes server-side integration config to CLJS components. +;; index.clj injects this as window.__INTEGRATIONS__ JSON in <head>. +;; integrations.cljs reads it at namespace load time. ;; See branding.clj/client-config for a working example. +(defn client-config + "Map of integration config for CLJS injection. Empty by default. + Fork overrides: return env-var-gated config values for CLJS components." + [] + {}) + (def csp-domains "Extra CSP domains required by enabled integrations. Returns {:connect-src [\"https://...\"] :frame-src [\"https://...\"]}. diff --git a/src/clj/orcpub/privacy.clj b/src/clj/orcpub/privacy.clj index 60933a731..7654ae66c 100644 --- a/src/clj/orcpub/privacy.clj +++ b/src/clj/orcpub/privacy.clj @@ -314,7 +314,7 @@ {:title "Where we use cookies" :font-size 32 :paragraphs - [(str "We use cookies on orcpub.com, in our mobile applications, and in our products and services (like ads, emails and applications). We also use them on the websites of partners who use " branding/app-name "'s Save button, " branding/app-name " widgets, or ad tools like conversion tracking.")]} + [(str "We use cookies on " branding/app-name ", in our mobile applications, and in our products and services (like ads, emails and applications). We also use them on the websites of partners who use " branding/app-name "'s Save button, " branding/app-name " widgets, or ad tools like conversion tracking.")]} {:title "Your options" :font-size 32 :paragraphs diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 08af85054..89a47ac48 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -296,13 +296,27 @@ {:color :white :font-size "20px"}) -(defn social-icon [icon link] +(defn social-icon + "Render a Font Awesome brand icon as a social link." + [icon link] [:a.p-5.opacity-5.hover-opacity-full.main-text-color {:style social-icon-style :href link :target :_blank} [:i.fab {:class (str "fa-" icon)}]]) +(defn bluesky-icon + "Bluesky butterfly icon (inline SVG — FA 5.13.1 has no fa-bluesky)." + [link] + [:a.p-5.opacity-5.hover-opacity-full.main-text-color + {:style social-icon-style + :href link :target :_blank} + [:svg {:xmlns "http://www.w3.org/2000/svg" + :viewBox "0 0 568 501" + :width "20" :height "18" + :style {:vertical-align "middle" :fill "currentColor"}} + [:path {:d "M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.07-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.135-1.612 123.121 33.664Z"}]]]) + (def search-input-style {:height "60px" :margin-top "0px" @@ -399,10 +413,14 @@ [:div.main-text-color.p-10 (when-let [url (not-empty (:facebook branding/social-links))] (social-icon "facebook-f" url)) + (when-let [url (not-empty (:bluesky branding/social-links))] + (bluesky-icon url)) (when-let [url (not-empty (:twitter branding/social-links))] (social-icon "twitter" url)) (when-let [url (not-empty (:reddit branding/social-links))] - (social-icon "reddit-alien" url))])] + (social-icon "reddit-alien" url)) + (when-let [url (not-empty (:discord branding/social-links))] + (social-icon "discord" url))])] [:div.flex.m-b-5.m-t-5.justify-cont-s-b.app-header-menu [header-tab "characters" From 69eafaad23d82e6208b98f47c08cee7e22b8ed8c Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 17:51:51 +0000 Subject: [PATCH 17/50] refactor: move fork-customization files into fork/ subdirectory --- src/clj/orcpub/email.clj | 2 +- src/clj/orcpub/{ => fork}/branding.clj | 2 +- src/clj/orcpub/{ => fork}/integrations.clj | 2 +- src/clj/orcpub/{ => fork}/user_data.clj | 2 +- src/clj/orcpub/index.clj | 4 ++-- src/clj/orcpub/pedestal.clj | 2 +- src/clj/orcpub/privacy.clj | 2 +- src/clj/orcpub/routes.clj | 4 ++-- src/cljc/orcpub/dnd/e5/views_2.cljc | 2 +- src/cljs/orcpub/character_builder.cljs | 4 ++-- src/cljs/orcpub/dnd/e5/events.cljs | 2 +- src/cljs/orcpub/dnd/e5/views.cljs | 6 +++--- src/cljs/orcpub/{ => fork}/branding.cljs | 2 +- src/cljs/orcpub/{ => fork}/integrations.cljs | 4 ++-- src/cljs/orcpub/{ => fork}/user_tier.cljs | 2 +- 15 files changed, 21 insertions(+), 21 deletions(-) rename src/clj/orcpub/{ => fork}/branding.clj (99%) rename src/clj/orcpub/{ => fork}/integrations.clj (98%) rename src/clj/orcpub/{ => fork}/user_data.clj (97%) rename src/cljs/orcpub/{ => fork}/branding.cljs (98%) rename src/cljs/orcpub/{ => fork}/integrations.cljs (98%) rename src/cljs/orcpub/{ => fork}/user_tier.cljs (96%) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 926c66e28..c42f14593 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -10,7 +10,7 @@ [clojure.pprint :as pprint] [clojure.string :as s] [orcpub.route-map :as routes] - [orcpub.branding :as branding] + [orcpub.fork.branding :as branding] [cuerdas.core :as str])) (defn verification-email-html [first-and-last-name username verification-url] diff --git a/src/clj/orcpub/branding.clj b/src/clj/orcpub/fork/branding.clj similarity index 99% rename from src/clj/orcpub/branding.clj rename to src/clj/orcpub/fork/branding.clj index 76b2016e2..ffc2cecfc 100644 --- a/src/clj/orcpub/branding.clj +++ b/src/clj/orcpub/fork/branding.clj @@ -1,4 +1,4 @@ -(ns orcpub.branding +(ns orcpub.fork.branding "Centralized branding configuration for fork-neutral deployment. All values have sensible defaults; forks override via env vars. diff --git a/src/clj/orcpub/integrations.clj b/src/clj/orcpub/fork/integrations.clj similarity index 98% rename from src/clj/orcpub/integrations.clj rename to src/clj/orcpub/fork/integrations.clj index 4919ffd10..2ac4269b0 100644 --- a/src/clj/orcpub/integrations.clj +++ b/src/clj/orcpub/fork/integrations.clj @@ -1,4 +1,4 @@ -(ns orcpub.integrations +(ns orcpub.fork.integrations "Optional third-party <head> integrations. Configure via environment variables; disabled when unset. Fork overrides: uncomment examples and add real service config." diff --git a/src/clj/orcpub/user_data.clj b/src/clj/orcpub/fork/user_data.clj similarity index 97% rename from src/clj/orcpub/user_data.clj rename to src/clj/orcpub/fork/user_data.clj index 69ce6e78e..542660725 100644 --- a/src/clj/orcpub/user_data.clj +++ b/src/clj/orcpub/fork/user_data.clj @@ -1,4 +1,4 @@ -(ns orcpub.user-data +(ns orcpub.fork.user-data "User data enrichment hooks. Pass-through by default. Fork overrides: replace this file to add custom fields to the user API response and registration defaults. diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index 1a936cb3a..146a61fac 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -3,8 +3,8 @@ [orcpub.oauth :as oauth] [orcpub.dnd.e5.views-2 :as views-2] [orcpub.favicon :as fi] - [orcpub.integrations :as integrations] - [orcpub.branding :as branding] + [orcpub.fork.integrations :as integrations] + [orcpub.fork.branding :as branding] [cheshire.core :as cheshire] [environ.core :refer [env]])) diff --git a/src/clj/orcpub/pedestal.clj b/src/clj/orcpub/pedestal.clj index 0ea097c5b..7efce5346 100644 --- a/src/clj/orcpub/pedestal.clj +++ b/src/clj/orcpub/pedestal.clj @@ -8,7 +8,7 @@ [java-time.api :as t] [orcpub.csp :as csp] [orcpub.config :as config] - [orcpub.integrations :as integrations]) + [orcpub.fork.integrations :as integrations]) (:import [java.io File] [java.time.format DateTimeFormatter])) diff --git a/src/clj/orcpub/privacy.clj b/src/clj/orcpub/privacy.clj index 7654ae66c..3ffe26372 100644 --- a/src/clj/orcpub/privacy.clj +++ b/src/clj/orcpub/privacy.clj @@ -2,7 +2,7 @@ (:require [hiccup.page :as page] [clojure.string :as s] [environ.core :as environ] - [orcpub.branding :as branding])) + [orcpub.fork.branding :as branding])) (defn section [{:keys [title font-size paragraphs subsections]}] [:div diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 1cd421e35..4d90ee1c7 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -33,8 +33,8 @@ [orcpub.errors :as errors] [orcpub.privacy :as privacy] [orcpub.email :as email] - [orcpub.branding :as branding] - [orcpub.user-data :as user-data] + [orcpub.fork.branding :as branding] + [orcpub.fork.user-data :as user-data] [orcpub.index :refer [index-page]] [orcpub.pdf :as pdf] [orcpub.registration :as registration] diff --git a/src/cljc/orcpub/dnd/e5/views_2.cljc b/src/cljc/orcpub/dnd/e5/views_2.cljc index 6a82a3287..e7d4b3453 100644 --- a/src/cljc/orcpub/dnd/e5/views_2.cljc +++ b/src/cljc/orcpub/dnd/e5/views_2.cljc @@ -1,7 +1,7 @@ (ns orcpub.dnd.e5.views-2 (:require [orcpub.route-map :as routes] [clojure.string :as s] - [orcpub.branding :as branding])) + [orcpub.fork.branding :as branding])) (defn style [style] #?(:cljs style) diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 6cee065ad..6253d4e3a 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -36,7 +36,7 @@ [orcpub.dnd.e5.db :as db] [orcpub.dnd.e5.views :as views5e] [orcpub.dnd.e5.subs :as subs5e] - [orcpub.branding :as branding] + [orcpub.fork.branding :as branding] [orcpub.route-map :as routes] [orcpub.pdf-spec :as pdf-spec] [orcpub.user-agent :as user-agent] @@ -46,7 +46,7 @@ [clojure.core.match :refer [match]] [reagent.core :as r] - [orcpub.integrations :as integrations] + [orcpub.fork.integrations :as integrations] [re-frame.core :refer [subscribe dispatch dispatch-sync]])) ;console-print (def print-disabled? true) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 2eaef38b8..81284f3e2 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -80,7 +80,7 @@ [clojure.set :as sets] [cljsjs.filesaverjs] [clojure.pprint :as pprint] - [orcpub.integrations :as integrations]) + [orcpub.fork.integrations :as integrations]) (:require-macros [cljs.core.async.macros :refer [go]])) ;; ============================================================================= diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 89a47ac48..ee01511af 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -42,9 +42,9 @@ [orcpub.dnd.e5.options :as opt] [orcpub.dnd.e5.events :as events] [orcpub.ver :as v] - [orcpub.branding :as branding] - [orcpub.user-tier] - [orcpub.integrations :as integrations] + [orcpub.fork.branding :as branding] + [orcpub.fork.user-tier] + [orcpub.fork.integrations :as integrations] [clojure.string :as s] [cljs.reader :as reader] [orcpub.user-agent :as user-agent] diff --git a/src/cljs/orcpub/branding.cljs b/src/cljs/orcpub/fork/branding.cljs similarity index 98% rename from src/cljs/orcpub/branding.cljs rename to src/cljs/orcpub/fork/branding.cljs index b1e940cee..dee644b31 100644 --- a/src/cljs/orcpub/branding.cljs +++ b/src/cljs/orcpub/fork/branding.cljs @@ -1,4 +1,4 @@ -(ns orcpub.branding +(ns orcpub.fork.branding "Client-side branding config. Reads server-injected window.__BRANDING__. Fallback values are used in dev/REPL where no server injection exists. diff --git a/src/cljs/orcpub/integrations.cljs b/src/cljs/orcpub/fork/integrations.cljs similarity index 98% rename from src/cljs/orcpub/integrations.cljs rename to src/cljs/orcpub/fork/integrations.cljs index 4b15d7cff..0ace09912 100644 --- a/src/cljs/orcpub/integrations.cljs +++ b/src/cljs/orcpub/fork/integrations.cljs @@ -1,4 +1,4 @@ -(ns orcpub.integrations +(ns orcpub.fork.integrations "Client-side integration hooks with minimal defaults. Fork overrides: replace with full implementations. @@ -9,7 +9,7 @@ Companion to integrations.clj (server-side head tags). Server-side loads third-party scripts in <head>; this namespace provides the in-app component hooks." - (:require [orcpub.branding :as branding] + (:require [orcpub.fork.branding :as branding] [orcpub.route-map :as routes])) ;; ─── Page View Tracking ───────────────────────────────────────── diff --git a/src/cljs/orcpub/user_tier.cljs b/src/cljs/orcpub/fork/user_tier.cljs similarity index 96% rename from src/cljs/orcpub/user_tier.cljs rename to src/cljs/orcpub/fork/user_tier.cljs index 60b784e0d..d54787b05 100644 --- a/src/cljs/orcpub/user_tier.cljs +++ b/src/cljs/orcpub/fork/user_tier.cljs @@ -1,4 +1,4 @@ -(ns orcpub.user-tier +(ns orcpub.fork.user-tier "User tier abstraction for feature gating. No tier system on the public repo — all users are :free. Fork overrides: replace this file with real tier logic. From 62381b8578d481119a1ff2f908f9d2393bfc0a7f Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 18:17:00 +0000 Subject: [PATCH 18/50] =?UTF-8?q?feat:=20email=20preferences=20=E2=80=94?= =?UTF-8?q?=20My=20Account=20toggle=20+=20JWT=20unsubscribe=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add send-updates? to user-body API response - Add /unsubscribe GET endpoint with JWT-signed token verification - Add /unsubscribe-success SPA page (follows verify-success pattern) - Add PUT /user for update-user-preferences (toggle send-updates?) - Add :toggle-send-updates event, :send-updates? subscription - Add email updates checkbox to My Account page - Backport social-links-footer to email.clj (self-gating on empty config) - Add unsubscribe-url helper to email.clj - Add 4 new test suites (token roundtrip, handler, preferences, user-body) - 210 tests, 963 assertions, 0 failures / 0 CLJS warnings --- src/clj/orcpub/email.clj | 87 ++++++++++++++--------- src/clj/orcpub/routes.clj | 48 +++++++++++++ src/cljc/orcpub/route_map.cljc | 4 ++ src/cljs/orcpub/dnd/e5/events.cljs | 25 ++++++- src/cljs/orcpub/dnd/e5/subs.cljs | 5 ++ src/cljs/orcpub/dnd/e5/views.cljs | 30 +++++++- test/clj/orcpub/routes_test.clj | 106 +++++++++++++++++++++++++++++ web/cljs/orcpub/core.cljs | 3 +- 8 files changed, 273 insertions(+), 35 deletions(-) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index c42f14593..c0da8cf20 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -13,22 +13,37 @@ [orcpub.fork.branding :as branding] [cuerdas.core :as str])) +(defn- social-links-footer + "Render email footer social links from branding config. Empty links hidden." + [] + (let [{:keys [patreon twitter facebook]} branding/social-links] + (remove nil? + [(when (seq patreon) [:br]) + (when (seq patreon) (str patreon " <-- Like what we are doing? Support us here.")) + (when (seq twitter) [:br]) + (when (seq twitter) twitter) + (when (seq facebook) [:br]) + (when (seq facebook) facebook) + [:br]]))) + (defn verification-email-html [first-and-last-name username verification-url] - [:div - (str "Welcome to " branding/app-name - (when (seq first-and-last-name) (str ", " first-and-last-name)) "!") - [:br] - [:br] - (str "Your " branding/app-name " account is almost ready, we just need you to verify your email address by visiting the following URL to confirm that you are authorized to use this email address:") - [:br] - [:br] - [:a {:href verification-url} verification-url] - [:br] - [:br] - "Sincerely," - [:br] - [:br] - (str "The " branding/email-sender-name)]) + (into + [:div + (str "Welcome to " branding/app-name + (when (seq first-and-last-name) (str ", " first-and-last-name)) "!") + [:br] + [:br] + (str "Your " branding/app-name " account is almost ready, we just need you to verify your email address by visiting the following URL to confirm that you are authorized to use this email address:") + [:br] + [:br] + [:a {:href verification-url} verification-url] + [:br] + [:br] + "Sincerely," + [:br] + [:br] + (str "The " branding/email-sender-name)] + (social-links-footer))) (defn verification-email [first-and-last-name username verification-url] [{:type "text/html" @@ -151,23 +166,25 @@ e))))) (defn reset-password-email-html [first-and-last-name reset-url] - [:div - (str "Dear " (if (seq first-and-last-name) first-and-last-name (str branding/app-name " User")) ",") - [:br] - [:br] - "We received a request to reset your password, to do so please go to the following URL to complete the reset." - [:br] - [:br] - [:a {:href reset-url} reset-url] - [:br] - [:br] - "If you did NOT request a reset, please do NOT click on the link." - [:br] - [:br] - "Sincerely," - [:br] - [:br] - (str "The " branding/email-sender-name)]) + (into + [:div + (str "Dear " (if (seq first-and-last-name) first-and-last-name (str branding/app-name " User")) ",") + [:br] + [:br] + "We received a request to reset your password, to do so please go to the following URL to complete the reset." + [:br] + [:br] + [:a {:href reset-url} reset-url] + [:br] + [:br] + "If you did NOT request a reset, please do NOT click on the link." + [:br] + [:br] + "Sincerely," + [:br] + [:br] + (str "The " branding/email-sender-name)] + (social-links-footer))) (defn reset-password-email [first-and-last-name reset-url] [{:type "text/html" @@ -209,6 +226,12 @@ :username username} e))))) +(defn unsubscribe-url + "Build a full unsubscribe URL from a base-url and a pre-signed JWT token. + Token is generated by routes/unsubscribe-token to avoid circular deps." + [base-url token] + (str base-url (routes/path-for routes/unsubscribe-route) "?token=" token)) + (defn send-error-email "Sends error notification email to configured admin email. diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 4d90ee1c7..818c668e3 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -243,6 +243,7 @@ (cond-> (user-data/enrich-response {:username (:orcpub.user/username user) :email (:orcpub.user/email user) + :send-updates? (boolean (:orcpub.user/send-updates? user)) :following (following-usernames db (map :db/id (:orcpub.user/following user)))} user) (:orcpub.user/pending-email user) @@ -441,6 +442,49 @@ conn {:db/id id})))) +;; ─── Email Preferences ───────────────────────────────────────────── + +(defn unsubscribe-token + "Create a JWT-signed unsubscribe token for embedding in email links. + Stateless — no DB storage needed. Verified by checking JWT signature." + [email] + (jwt/sign {:email (s/lower-case email) :action "unsubscribe"} + (environ/env :signature))) + +(defn unsubscribe + "GET handler for /unsubscribe?token=<jwt>. + Verifies JWT signature, sets send-updates? to false, redirects to success page. + Idempotent — unsubscribing twice is harmless." + [{:keys [query-params db conn]}] + (let [token (:token query-params)] + (if (s/blank? token) + {:status 400 :body "Missing token"} + (try + (let [{:keys [email action]} (jwt/unsign token (environ/env :signature))] + (if (not= "unsubscribe" action) + {:status 400 :body "Invalid token"} + (let [{:keys [:db/id]} (user-for-email (d/db conn) email)] + (if id + (do @(d/transact conn [{:db/id id :orcpub.user/send-updates? false}]) + (redirect route-map/unsubscribe-success-route)) + {:status 400 :body "Unknown email"})))) + (catch Exception _ + {:status 400 :body "Invalid or tampered token"}))))) + +(defn update-user-preferences + "PUT handler for /user — update user preferences (currently send-updates?). + Requires authentication. Only updates fields present in transit-params." + [{:keys [transit-params db conn identity]}] + (let [username (:user identity) + {:keys [:db/id]} (find-user-by-username db username)] + (if id + (do (when (contains? transit-params :send-updates?) + @(d/transact conn [{:db/id id + :orcpub.user/send-updates? (boolean (:send-updates? transit-params))}])) + {:status 200 + :body {:send-updates? (boolean (:send-updates? transit-params))}}) + {:status 400 :body {:error "User not found"}}))) + (defn do-send-password-reset [user-id email conn request] (let [key (str (java.util.UUID/randomUUID))] (try @@ -1274,6 +1318,7 @@ [route-map/password-reset-used-route] [route-map/verify-failed-route] [route-map/verify-success-route] + [route-map/unsubscribe-success-route] [route-map/dnd-e5-orcacle-page-route]]) (defn character-page [{:keys [db conn identity headers scheme uri] {:keys [id]} :path-params :as request}] @@ -1368,6 +1413,7 @@ {:post `register}] [(route-map/path-for route-map/user-route) ^:interceptors [check-auth] {:get `get-user + :put `update-user-preferences :delete `delete-user}] [(route-map/path-for route-map/user-email-route) ^:interceptors [check-auth] {:put `request-email-change}] @@ -1427,6 +1473,8 @@ {:get `verify}] [(route-map/path-for route-map/re-verify-route) {:get `re-verify}] + [(route-map/path-for route-map/unsubscribe-route) + {:get `unsubscribe}] [(route-map/path-for route-map/reset-password-route) ^:interceptors [ring/cookies check-auth] {:post `reset-password}] [(route-map/path-for route-map/reset-password-page-route) ^:interceptors [ring/cookies] diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 4e7eade74..fdf29b11e 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -112,6 +112,8 @@ (def password-reset-success-route :password-reset-success) (def password-reset-expired-route :password-reset-expired) (def password-reset-used-route :password-reset-used) +(def unsubscribe-route :unsubscribe) +(def unsubscribe-success-route :unsubscribe-success) (def terms-of-use-route :terms-of-use) (def privacy-policy-route :privacy-policy) (def community-guidelines-route :community-guidelines) @@ -138,6 +140,8 @@ "password-reset-success" password-reset-success-route "password-reset-expired" password-reset-expired-route "password-reset-used" password-reset-used-route + "unsubscribe" unsubscribe-route + "unsubscribe-success" unsubscribe-success-route "terms-of-use" terms-of-use-route "privacy-policy" privacy-policy-route "community-guidelines" community-guidelines-route diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 81284f3e2..68885be31 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -1175,6 +1175,28 @@ (fn [db _] (dissoc db :email-change-sent? :email-change-error))) +;; ─── Email Preferences ───────────────────────────────────────────── + +(reg-event-fx + :toggle-send-updates + (fn [{:keys [db]} [_ new-value]] + {:http {:method :put + :headers (authorization-headers db) + :url (backend-url (routes/path-for routes/user-route)) + :transit-params {:send-updates? (boolean new-value)} + :on-success [:toggle-send-updates-success new-value] + :on-failure [:toggle-send-updates-failure]}})) + +(reg-event-db + :toggle-send-updates-success + (fn [db [_ new-value]] + (assoc-in db [:user-data :user-data :send-updates?] (boolean new-value)))) + +(reg-event-db + :toggle-send-updates-failure + (fn [db _] + db)) + (reg-event-fx :unfollow-user (fn [{:keys [db]} [_ username]] @@ -1808,7 +1830,8 @@ routes/send-password-reset-page-route routes/password-reset-success-route routes/password-reset-expired-route - routes/password-reset-used-route}) + routes/password-reset-used-route + routes/unsubscribe-success-route}) (reg-event-fx :login diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index ad15636e0..c488533f0 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -264,6 +264,11 @@ (fn [db _] (-> db :user-data :user-data :pending-email))) +(reg-sub + :send-updates? + (fn [db _] + (boolean (-> db :user-data :user-data :send-updates?)))) + (reg-sub :email-change-sent? (fn [db _] diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index ee01511af..725fc0aa6 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -729,6 +729,19 @@ [:div.m-t-20 "You can now log in"] [login-link]])) +(defn unsubscribe-success [] + (registration-page + [:div {:style {:text-align :center}} + [:div {:style {:color orange + :font-weight :bold + :font-size "36px" + :text-transform :uppercase + :text-shadow "1px 2px 1px rgba(0,0,0,0.37)" + :margin-top "100px"}} + "Unsubscribed"] + [:div.m-t-20 "You have been successfully unsubscribed from email updates."] + [:div.m-t-10 "You can re-enable updates at any time from your account settings."]])) + (defn email-sent [text] (registration-page [:div {:style {:text-align :center}} @@ -7731,7 +7744,22 @@ (reset! new-email "") (reset! confirm-email "") (dispatch [:change-email-clear]))} - "Change"]])]]]))) + "Change"]])] + ;; ─── Email Updates Toggle ───────────────────────────────── + [:div.p-5 + [:span.f-w-b "Email Updates: "] + (let [send-updates? @(subscribe [:send-updates?])] + [:span + [:i.fa.fa-check.f-s-14.pointer.m-r-5 + {:class (if send-updates? "orange" "white") + :style {:border-color "#f0a100" + :border-style :solid + :border-width "1px" + :border-bottom-width "3px"} + :on-click #(dispatch [:toggle-send-updates (not send-updates?)])}] + (if send-updates? + (str "Receiving updates from " branding/app-name) + "Not receiving updates")])]]]))) (defn newb-character-builder-page [] diff --git a/test/clj/orcpub/routes_test.clj b/test/clj/orcpub/routes_test.clj index a49771782..843ae5216 100644 --- a/test/clj/orcpub/routes_test.clj +++ b/test/clj/orcpub/routes_test.clj @@ -5,6 +5,8 @@ [datomic.api :as d] [datomock.core :as dm] [io.pedestal.http :as http] + [buddy.sign.jwt :as jwt] + [environ.core :as environ] [orcpub.routes :as routes] [orcpub.dnd.e5.magic-items :as mi] [orcpub.dnd.e5.character :as char5e] @@ -249,3 +251,107 @@ :v 34 :zz {:v 78 :zzz [{:s "String"}]}}]}})))) + +;; ─── Unsubscribe Token Tests ──────────────────────────────────────── + +(deftest test-unsubscribe-token-roundtrip + (testing "Token encodes email and action, verifiable with signature" + (let [token (routes/unsubscribe-token "Test@Example.com") + claims (jwt/unsign token (environ/env :signature))] + (is (= "test@example.com" (:email claims)) + "Email should be lowercased") + (is (= "unsubscribe" (:action claims)))))) + +(deftest test-unsubscribe-handler + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + @(d/transact mocked-conn schema/all-schemas) + @(d/transact mocked-conn [{:orcpub.user/username "testy" + :orcpub.user/email "test@test.com" + :orcpub.user/send-updates? true}]) + + (testing "Valid token unsubscribes user" + (let [token (routes/unsubscribe-token "test@test.com") + resp (routes/unsubscribe {:query-params {:token token} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 302 (:status resp)) + "Should redirect to success page") + (let [user (routes/user-for-email (d/db mocked-conn) "test@test.com")] + (is (false? (:orcpub.user/send-updates? user)) + "send-updates? should be false after unsubscribe")))) + + (testing "Idempotent — unsubscribing twice succeeds" + (let [token (routes/unsubscribe-token "test@test.com") + resp (routes/unsubscribe {:query-params {:token token} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 302 (:status resp))))) + + (testing "Tampered token returns 400" + (let [resp (routes/unsubscribe {:query-params {:token "tampered.token.here"} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 400 (:status resp))))) + + (testing "Missing token returns 400" + (let [resp (routes/unsubscribe {:query-params {} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 400 (:status resp))))) + + (testing "Unknown email returns 400" + (let [token (routes/unsubscribe-token "nobody@test.com") + resp (routes/unsubscribe {:query-params {:token token} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 400 (:status resp)))))))) + +(deftest test-update-user-preferences + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + @(d/transact mocked-conn schema/all-schemas) + @(d/transact mocked-conn [{:orcpub.user/username "testy" + :orcpub.user/email "test@test.com" + :orcpub.user/send-updates? false}]) + + (testing "Toggle send-updates to true" + (let [resp (routes/update-user-preferences + {:transit-params {:send-updates? true} + :db (d/db mocked-conn) + :conn mocked-conn + :identity {:user "testy"}})] + (is (= 200 (:status resp))) + (let [user (routes/user-for-email (d/db mocked-conn) "test@test.com")] + (is (true? (:orcpub.user/send-updates? user)))))) + + (testing "Toggle send-updates back to false" + (let [resp (routes/update-user-preferences + {:transit-params {:send-updates? false} + :db (d/db mocked-conn) + :conn mocked-conn + :identity {:user "testy"}})] + (is (= 200 (:status resp))) + (let [user (routes/user-for-email (d/db mocked-conn) "test@test.com")] + (is (false? (:orcpub.user/send-updates? user)))))) + + (testing "Unknown user returns 400" + (let [resp (routes/update-user-preferences + {:transit-params {:send-updates? true} + :db (d/db mocked-conn) + :conn mocked-conn + :identity {:user "nonexistent"}})] + (is (= 400 (:status resp)))))))) + +(deftest test-user-body-includes-send-updates + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + @(d/transact mocked-conn schema/all-schemas) + @(d/transact mocked-conn [{:orcpub.user/username "testy" + :orcpub.user/email "test@test.com" + :orcpub.user/send-updates? true}]) + (let [db (d/db mocked-conn) + user (routes/user-for-email db "test@test.com") + body (routes/user-body db user)] + (is (true? (:send-updates? body)) + "user-body should include send-updates? field"))))) diff --git a/web/cljs/orcpub/core.cljs b/web/cljs/orcpub/core.cljs index f50c6acaa..080fbae0c 100644 --- a/web/cljs/orcpub/core.cljs +++ b/web/cljs/orcpub/core.cljs @@ -72,7 +72,8 @@ routes/reset-password-page-route views/password-reset-page routes/password-reset-success-route views/password-reset-success routes/password-reset-expired-route views/password-reset-expired-page - routes/password-reset-used-route views/password-reset-used-page}) + routes/password-reset-used-route views/password-reset-used-page + routes/unsubscribe-success-route views/unsubscribe-success}) (defn handle-url-change [_] (let [route (when js/window.location From 426c0bdb47f15b14722c8efbad2b2ae4296715bd Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 18:51:48 +0000 Subject: [PATCH 19/50] feat: auto-detect Figwheel WebSocket URL for Codespaces Figwheel's default ws://localhost:3449 fails in remote environments where the browser connects through a forwarded hostname. start.sh now auto-detects GitHub Codespaces and passes --fw-opts to override the connect URL with the correct wss:// endpoint. FIGWHEEL_CONNECT_URL env var available for other remote setups (Gitpod, tunnels). - scripts/start.sh: Codespaces detection + --fw-opts EDN override - scripts/common.sh: FIGWHEEL_CONNECT_URL env var - .devcontainer/devcontainer.json: port 3449 public (WebSocket needs it) - .env.example: document FIGWHEEL_CONNECT_URL --- .devcontainer/devcontainer.json | 3 ++- .env.example | 6 ++++++ scripts/common.sh | 5 +++++ scripts/start.sh | 38 ++++++++++++++++++++++++++++++--- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d1194a929..c486f6670 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -17,7 +17,8 @@ }, "3449": { "label": "Figwheel", - "onAutoForward": "silent" + "onAutoForward": "silent", + "visibility": "public" }, "4334": { "label": "Datomic", diff --git a/.env.example b/.env.example index 61941e5c5..e8903834d 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,12 @@ DEV_MODE=true # Defaults to project logs/ if unset LOG_DIR= +# --- Remote Dev (optional) --- +# Figwheel WebSocket URL override for remote environments (Gitpod, tunnels, etc.) +# Auto-detected for GitHub Codespaces — only set this for other remote setups. +# Example: wss://my-remote-host:3449/figwheel-connect +# FIGWHEEL_CONNECT_URL= + # --- Email (SMTP) --- # Leave EMAIL_SERVER_URL empty to disable email functionality EMAIL_SERVER_URL= diff --git a/scripts/common.sh b/scripts/common.sh index d96f3f8b6..f7ace7e1b 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -43,6 +43,11 @@ NREPL_PORT="${NREPL_PORT:-7888}" FIGWHEEL_PORT="${FIGWHEEL_PORT:-3449}" GARDEN_PORT="${GARDEN_PORT:-3000}" +# Figwheel WebSocket connect URL override (for remote dev environments). +# Auto-detected for GitHub Codespaces; set explicitly for other remote setups. +# Leave empty for local development (uses Figwheel default: ws://localhost:PORT). +FIGWHEEL_CONNECT_URL="${FIGWHEEL_CONNECT_URL:-}" + # Derived paths DATOMIC_DIR="$REPO_ROOT/lib/com/datomic/datomic-${DATOMIC_TYPE}/${DATOMIC_VERSION}" DATOMIC_CONFIG="$DATOMIC_DIR/config/working-transactor.properties" diff --git a/scripts/start.sh b/scripts/start.sh index 4287afa0e..f6045a7f3 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -405,6 +405,7 @@ start_server() { start_figwheel() { local idempotent="${1:-false}" local port_result=0 + local build_name="dev" # Check port (0=available, 1=abort, 2=skip) check_port_available "$FIGWHEEL_PORT" "figwheel" "$idempotent" || port_result=$? @@ -418,11 +419,39 @@ start_figwheel() { # Clean up stale PID file cleanup_stale_pid "figwheel" + # ── Remote dev environment detection ────────────────────────────── + # Figwheel's default connect URL (ws://localhost:PORT) only works when + # the browser is on the same machine. In remote environments (Codespaces, + # Gitpod, etc.) the browser connects through a forwarded hostname. + # We auto-detect Codespaces and pass --fw-opts to override the connect URL. + # Set FIGWHEEL_CONNECT_URL in .env to override for other remote setups. + local connect_url="${FIGWHEEL_CONNECT_URL:-}" + local fw_opts="" + + if [[ -z "$connect_url" && "${CODESPACES:-}" == "true" ]]; then + local cs_domain="${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-app.github.dev}" + local cs_name="${CODESPACE_NAME:?CODESPACE_NAME required in Codespaces}" + connect_url="wss://${cs_name}-${FIGWHEEL_PORT}.${cs_domain}/figwheel-connect" + log_info "Codespaces detected" + fi + + if [[ -n "$connect_url" ]]; then + # Build EDN override for figwheel's --fw-opts flag. + # Merges with dev.cljs.edn metadata — no generated file needed. + printf -v fw_opts '{:connect-url "%s" :ring-server-options {:port %s :host "0.0.0.0"} :open-url false}' \ + "$connect_url" "$FIGWHEEL_PORT" + log_info "Remote dev connect URL: $connect_url" + fi + log_info "Starting Figwheel (ClojureScript hot-reload)..." cd "$REPO_ROOT" - # Use fig:watch alias (headless build + watch, no REPL — works with nohup) - # For interactive REPL use: lein fig:dev (needs a terminal) - nohup lein fig:watch > "$LOG_DIR/figwheel.log" 2>&1 & + # Use fig:watch alias for local dev, or pass --fw-opts for remote environments. + # --fw-opts merges EDN overrides with the build config in dev.cljs.edn. + if [[ -n "$fw_opts" ]]; then + nohup lein run -m figwheel.main -- --fw-opts "$fw_opts" --build dev > "$LOG_DIR/figwheel.log" 2>&1 & + else + nohup lein fig:watch > "$LOG_DIR/figwheel.log" 2>&1 & + fi local figwheel_pid=$! echo "$figwheel_pid" > "$LOG_DIR/figwheel.pid" log_info "Figwheel started (PID $figwheel_pid)" @@ -440,6 +469,9 @@ start_figwheel() { log_info "Waiting for Figwheel to be ready (port $FIGWHEEL_PORT)..." if wait_for_port_or_die "$FIGWHEEL_PORT" "$figwheel_pid" "$PORT_WAIT"; then log_info "Figwheel is ready" + if [[ -n "$connect_url" ]]; then + log_info "Hot-reload WebSocket: $connect_url" + fi elif kill -0 "$figwheel_pid" 2>/dev/null; then # Process alive but port not ready — likely first-run compile/dep download log_warn "Figwheel still starting (PID $figwheel_pid alive, port $FIGWHEEL_PORT not yet open)" From bd6d562c7138d77e6d2e8069112b313bc5ee2403 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 18:57:02 +0000 Subject: [PATCH 20/50] docs: add remote dev documentation to start.sh help and menu - start.sh show_help(): FIGWHEEL_CONNECT_URL + FIGWHEEL_PORT in env vars list, Remote Dev section in notes - start.sh run_checks(): report remote dev detection status for figwheel and all targets (auto-detect / configured / local) - menu show_help(): Remote Dev section with FIGWHEEL_CONNECT_URL ref --- menu | 5 +++++ scripts/start.sh | 41 ++++++++++++++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/menu b/menu index 897a07697..f480391e6 100755 --- a/menu +++ b/menu @@ -64,6 +64,11 @@ Stop options (passed through to stop.sh): --yes, -y Skip confirmation --force, -f Use SIGKILL if needed +Remote Dev (Figwheel): + Figwheel hot-reload auto-detects GitHub Codespaces and configures + the WebSocket URL. For other remote setups (Gitpod, tunnels), set + FIGWHEEL_CONNECT_URL in .env. Check status: ./menu start figwheel -c + Tmux tips: Attach: tmux attach -t orcpub Detach: Ctrl+B, then D diff --git a/scripts/start.sh b/scripts/start.sh index f6045a7f3..8ddb5f912 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -242,13 +242,26 @@ run_checks() { esac case "$target" in - figwheel) + all|figwheel) echo -n "Figwheel port ($FIGWHEEL_PORT): " if port_in_use "$FIGWHEEL_PORT"; then echo -e "${YELLOW}IN USE${NC}" else echo -e "${GREEN}AVAILABLE${NC}" fi + + # Report remote dev detection status + echo -n "Remote dev: " + if [[ -n "${FIGWHEEL_CONNECT_URL:-}" ]]; then + echo -e "${GREEN}CONFIGURED${NC} (FIGWHEEL_CONNECT_URL)" + echo " URL: $FIGWHEEL_CONNECT_URL" + elif [[ "${CODESPACES:-}" == "true" ]]; then + local cs_domain="${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-app.github.dev}" + echo -e "${GREEN}AUTO-DETECT${NC} (Codespaces)" + echo " URL: wss://${CODESPACE_NAME:-???}-${FIGWHEEL_PORT}.${cs_domain}/figwheel-connect" + else + echo -e "${CYAN}LOCAL${NC} (ws://localhost:$FIGWHEEL_PORT)" + fi ;; esac @@ -654,14 +667,16 @@ Exit Codes: 3 Runtime failure (port conflict, startup timeout, process crash) Environment Variables (via .env or shell): - DATOMIC_VERSION Datomic version (default: 1.0.7482) - DATOMIC_TYPE Datomic type: pro or dev (default: pro) - JAVA_MIN_VERSION Minimum Java version required (default: 11) - LOG_DIR Directory for log files (default: ./logs) - DATOMIC_PORT Datomic port (default: 4334) - SERVER_PORT Server port (default: 8890) - PORT_WAIT Timeout for port readiness (default: 30) - KILL_WAIT Timeout for graceful shutdown (default: 5) + DATOMIC_VERSION Datomic version (default: 1.0.7482) + DATOMIC_TYPE Datomic type: pro or dev (default: pro) + JAVA_MIN_VERSION Minimum Java version required (default: 11) + LOG_DIR Directory for log files (default: ./logs) + DATOMIC_PORT Datomic port (default: 4334) + SERVER_PORT Server port (default: 8890) + FIGWHEEL_PORT Figwheel port (default: 3449) + FIGWHEEL_CONNECT_URL Override Figwheel WebSocket URL for remote dev + PORT_WAIT Timeout for port readiness (default: 30) + KILL_WAIT Timeout for graceful shutdown (default: 5) Configuration: Config is loaded from: \$REPO_ROOT/.env @@ -692,6 +707,14 @@ Notes: - Or use ./start.sh alone for Datomic + server in one terminal - Or use ./start.sh --tmux to run all in a tmux session - In non-interactive mode (CI/cron), port conflicts fail immediately + +Remote Dev (Codespaces / Gitpod / tunnels): + Figwheel's default ws://localhost:3449 doesn't work when the browser + connects through a remote hostname. start.sh handles this automatically: + - GitHub Codespaces: auto-detected (wss:// URL built from env vars) + - Other remote setups: set FIGWHEEL_CONNECT_URL in .env + - Local dev: no action needed (Figwheel default just works) + Port 3449 must be public in Codespaces for the WebSocket to connect. EOF } From 49fee1109a3a667aa5b4d45b0e3f7224c6aec7c6 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 19:05:35 +0000 Subject: [PATCH 21/50] fix: guard API subscriptions against unauthenticated HTTP calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit characters, parties, and user subscriptions (reg-sub-raw) were firing HTTP requests even when no auth token existed in app-db, producing spurious 401s in the browser console on every page load. Added token check ([:user-data :token]) before the go block in each subscription — no token means no request, just return []. Also fixed :user sub which checked the wrong path ([:user :token] instead of [:user-data :token]) — worked by accident, now correct. Matches the existing guard pattern in equipment_subs.cljs. --- src/cljs/orcpub/dnd/e5/subs.cljs | 36 ++++---- test/cljs/orcpub/dnd/e5/subs_test.cljs | 113 +++++++++++++++++++++++++ test/cljs/orcpub/test_runner.cljs | 6 +- 3 files changed, 136 insertions(+), 19 deletions(-) create mode 100644 test/cljs/orcpub/dnd/e5/subs_test.cljs diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index c488533f0..938719082 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -386,35 +386,37 @@ (reg-sub-raw ::char5e/characters (fn [app-db [_ login-optional?]] - (go (dispatch [:set-loading true]) - (let [response (<! (http/get (url-for-route routes/dnd-e5-char-summary-list-route) - {:headers (auth-headers @app-db)}))] - (dispatch [:set-loading false]) - (handle-api-response response - #(dispatch [::char5e/set-characters (:body response)]) - :on-401 #(when-not login-optional? (dispatch [:route-to-login])) - :context "fetch characters"))) + (when (:token (:user-data @app-db)) + (go (dispatch [:set-loading true]) + (let [response (<! (http/get (url-for-route routes/dnd-e5-char-summary-list-route) + {:headers (auth-headers @app-db)}))] + (dispatch [:set-loading false]) + (handle-api-response response + #(dispatch [::char5e/set-characters (:body response)]) + :on-401 #(when-not login-optional? (dispatch [:route-to-login])) + :context "fetch characters")))) (ra/make-reaction (fn [] (get @app-db ::char5e/characters []))))) (reg-sub-raw ::party5e/parties (fn [app-db [_ login-optional?]] - (go (dispatch [:set-loading true]) - (let [response (<! (http/get (url-for-route routes/dnd-e5-char-parties-route) - {:headers (auth-headers @app-db)}))] - (dispatch [:set-loading false]) - (handle-api-response response - #(dispatch [::party5e/set-parties (:body response)]) - :on-401 #(when-not login-optional? (dispatch [:route-to-login])) - :context "fetch parties"))) + (when (:token (:user-data @app-db)) + (go (dispatch [:set-loading true]) + (let [response (<! (http/get (url-for-route routes/dnd-e5-char-parties-route) + {:headers (auth-headers @app-db)}))] + (dispatch [:set-loading false]) + (handle-api-response response + #(dispatch [::party5e/set-parties (:body response)]) + :on-401 #(when-not login-optional? (dispatch [:route-to-login])) + :context "fetch parties")))) (ra/make-reaction (fn [] (get @app-db ::char5e/parties []))))) (reg-sub-raw :user (fn [app-db [_ required?]] - (when (and (:user @app-db) (:token (:user @app-db))) ;;check if logged in, prevent unncessary calls + (when (:token (:user-data @app-db)) ;; guard: skip HTTP when not logged in (go (let [hdrs (auth-headers @app-db) response (<! (http/get (url-for-route routes/user-route) {:headers hdrs}))] (handle-api-response response diff --git a/test/cljs/orcpub/dnd/e5/subs_test.cljs b/test/cljs/orcpub/dnd/e5/subs_test.cljs new file mode 100644 index 000000000..7f8df091f --- /dev/null +++ b/test/cljs/orcpub/dnd/e5/subs_test.cljs @@ -0,0 +1,113 @@ +(ns orcpub.dnd.e5.subs-test + "Tests for API-backed subscriptions (reg-sub-raw). + + These subscriptions gate HTTP calls on the presence of an auth token. + When no token exists in app-db, the subscription should return an empty + vector without making any network request (no :set-loading dispatch). + + PATTERN — testing a reg-sub-raw guard: + 1. Reset app-db to a known state (with or without token) + 2. Deref the subscription to trigger it + 3. Assert the return value and check for side effects (:loading)" + (:require [cljs.test :refer-macros [deftest testing is use-fixtures]] + [re-frame.core :as rf] + [re-frame.db :refer [app-db]] + [orcpub.dnd.e5.character :as char5e] + [orcpub.dnd.e5.party :as party5e] + ;; Side effect: registers subscriptions + [orcpub.dnd.e5.subs])) + +;; --------------------------------------------------------------------------- +;; Fixtures +;; --------------------------------------------------------------------------- + +(defn reset-db! + "Reset app-db before each test to prevent state leakage." + [] + (reset! app-db {}) + ;; Clear subscription cache so reg-sub-raw re-evaluates + (rf/clear-subscription-cache!)) + +(use-fixtures :each {:before reset-db!}) + +;; --------------------------------------------------------------------------- +;; ::char5e/characters — token guard +;; --------------------------------------------------------------------------- + +(deftest characters-no-token-returns-empty + (testing "without auth token, subscription returns [] without HTTP call" + (reset! app-db {}) + (let [result @(rf/subscribe [::char5e/characters])] + ;; Should return empty vector (default) + (is (= [] result)) + ;; :set-loading should NOT have been dispatched (go block skipped) + (is (nil? (:loading @app-db)) + "No loading state should be set without token")))) + +(deftest characters-no-token-login-optional + (testing "login-optional? param doesn't bypass the token guard" + (reset! app-db {}) + (let [result @(rf/subscribe [::char5e/characters true])] + (is (= [] result)) + (is (nil? (:loading @app-db)))))) + +(deftest characters-with-token-empty-is-nil + (testing "with token but no cached data, returns []" + (reset! app-db {:user-data {:token "test-token"}}) + ;; The go block will fire and try HTTP (which will fail in test env), + ;; but the reaction should still return [] since no data is cached yet + (let [result @(rf/subscribe [::char5e/characters])] + (is (= [] result))))) + +;; --------------------------------------------------------------------------- +;; ::party5e/parties — token guard +;; --------------------------------------------------------------------------- + +(deftest parties-no-token-returns-empty + (testing "without auth token, subscription returns [] without HTTP call" + (reset! app-db {}) + (let [result @(rf/subscribe [::party5e/parties])] + (is (= [] result)) + (is (nil? (:loading @app-db)) + "No loading state should be set without token")))) + +(deftest parties-no-token-login-optional + (testing "login-optional? param doesn't bypass the token guard" + (reset! app-db {}) + (let [result @(rf/subscribe [::party5e/parties true])] + (is (= [] result)) + (is (nil? (:loading @app-db)))))) + +(deftest parties-with-token-empty-is-nil + (testing "with token but no cached data, returns []" + (reset! app-db {:user-data {:token "test-token"}}) + (let [result @(rf/subscribe [::party5e/parties])] + (is (= [] result))))) + +;; --------------------------------------------------------------------------- +;; :user — token guard +;; +;; Previously checked [:user :token] which was the wrong path. +;; Fixed to check [:user-data :token] (same as auth-headers). +;; --------------------------------------------------------------------------- + +(deftest user-no-token-returns-empty + (testing "without auth token, subscription returns [] without HTTP call" + (reset! app-db {}) + (let [result @(rf/subscribe [:user])] + (is (= [] result))))) + +(deftest user-stale-user-no-token-still-guarded + (testing "user key present but no token → still skips HTTP" + ;; This was the accidental-guard case: [:user] existed but [:user :token] + ;; didn't, so the old guard happened to block. The new guard checks the + ;; canonical path [:user-data :token] which is authoritative. + (reset! app-db {:user {:name "stale-user"}}) + (let [result @(rf/subscribe [:user])] + (is (= [] result))))) + +(deftest user-with-token-returns-default + (testing "with token, subscription fires (returns default until HTTP resolves)" + (reset! app-db {:user-data {:token "test-token"}}) + (let [result @(rf/subscribe [:user])] + (is (= [] result))))) diff --git a/test/cljs/orcpub/test_runner.cljs b/test/cljs/orcpub/test_runner.cljs index e6ec3ec98..e1de6f0af 100644 --- a/test/cljs/orcpub/test_runner.cljs +++ b/test/cljs/orcpub/test_runner.cljs @@ -4,12 +4,14 @@ [orcpub.dnd.e5.event-utils-test] [orcpub.dnd.e5.compute-test] ;; CLJS-only re-frame integration tests - [orcpub.dnd.e5.events-test])) + [orcpub.dnd.e5.events-test] + [orcpub.dnd.e5.subs-test])) (defn -main [] (run-tests 'orcpub.dnd.e5.event-utils-test 'orcpub.dnd.e5.compute-test - 'orcpub.dnd.e5.events-test)) + 'orcpub.dnd.e5.events-test + 'orcpub.dnd.e5.subs-test)) ;; Auto-run when figwheel reloads (defn ^:after-load on-reload [] From 23745571a8a6e5b7e8084b976b3ad0c35109bcbf Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 19:32:19 +0000 Subject: [PATCH 22/50] fix: subscribe-outside-reactive-context warnings + folders auth guard - views.cljs content-page: read user-tier/username/email from app-db directly in componentDidMount (lifecycle is not a reactive context) - subs.cljs: add token guard to ::folder5e/folders (same pattern as characters/parties/user) - core.cljs: remove subscribe trace monkey-patch (served its purpose) - subs_test.cljs: add folders guard tests --- src/cljs/orcpub/dnd/e5/subs.cljs | 15 ++++++++------- src/cljs/orcpub/dnd/e5/views.cljs | 10 ++++++++++ test/cljs/orcpub/dnd/e5/subs_test.cljs | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index 938719082..ae6570f5a 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -449,13 +449,14 @@ (reg-sub-raw ::folder5e/folders (fn [app-db _] - (go (dispatch [:set-loading true]) - (let [response (<! (http/get (url-for-route routes/dnd-e5-char-folders-route) - {:headers (auth-headers @app-db)}))] - (dispatch [:set-loading false]) - (handle-api-response response - #(dispatch [::folder5e/set-folders (:body response)]) - :context "fetch folders"))) + (when (:token (:user-data @app-db)) + (go (dispatch [:set-loading true]) + (let [response (<! (http/get (url-for-route routes/dnd-e5-char-folders-route) + {:headers (auth-headers @app-db)}))] + (dispatch [:set-loading false]) + (handle-api-response response + #(dispatch [::folder5e/set-folders (:body response)]) + :context "fetch folders")))) (ra/make-reaction (fn [] (get @app-db ::folder5e/folders []))))) diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 725fc0aa6..e665f2a9c 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -10,6 +10,7 @@ [orcpub.dice :as dice] [orcpub.entity.strict :as se] [orcpub.dnd.e5.subs :as subs] + [re-frame.db :refer [app-db]] [orcpub.dnd.e5.equipment-subs] [orcpub.dnd.e5.character :as char] [orcpub.dnd.e5.backgrounds :as bg] @@ -1444,6 +1445,15 @@ (set! (.-display (.-style sticky-header)) "none")))))] (r/create-class {:component-did-mount (fn [comp] + ;; Read directly from app-db — lifecycle methods are + ;; not reactive contexts, so subscribe warns here. + (let [user-data (-> @app-db :user-data :user-data)] + (integrations/on-app-mount! + {:user-tier (if (:patron user-data) + (or (some-> user-data :patron-tier keyword) :patron) + :free) + :username (:username user-data) + :email (:email user-data)})) (when-not frame? (js/window.addEventListener "scroll" on-scroll)) (js/window.scrollTo 0,0)) diff --git a/test/cljs/orcpub/dnd/e5/subs_test.cljs b/test/cljs/orcpub/dnd/e5/subs_test.cljs index 7f8df091f..b850ddd60 100644 --- a/test/cljs/orcpub/dnd/e5/subs_test.cljs +++ b/test/cljs/orcpub/dnd/e5/subs_test.cljs @@ -14,6 +14,7 @@ [re-frame.db :refer [app-db]] [orcpub.dnd.e5.character :as char5e] [orcpub.dnd.e5.party :as party5e] + [orcpub.dnd.e5.folder :as folder5e] ;; Side effect: registers subscriptions [orcpub.dnd.e5.subs])) @@ -111,3 +112,21 @@ (reset! app-db {:user-data {:token "test-token"}}) (let [result @(rf/subscribe [:user])] (is (= [] result))))) + +;; --------------------------------------------------------------------------- +;; ::folder5e/folders — token guard +;; --------------------------------------------------------------------------- + +(deftest folders-no-token-returns-empty + (testing "without auth token, subscription returns [] without HTTP call" + (reset! app-db {}) + (let [result @(rf/subscribe [::folder5e/folders])] + (is (= [] result)) + (is (nil? (:loading @app-db)) + "No loading state should be set without token")))) + +(deftest folders-with-token-returns-default + (testing "with token but no cached data, returns []" + (reset! app-db {:user-data {:token "test-token"}}) + (let [result @(rf/subscribe [::folder5e/folders])] + (is (= [] result))))) From 2560f39cef89d31e96f69b6884bd8f8d13be2048 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 19:42:34 +0000 Subject: [PATCH 23/50] =?UTF-8?q?fix:=20error=20notification=20email=20?= =?UTF-8?q?=E2=80=94=20throttle,=20scrub,=20and=20fix=20paren=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites send-error-email with: - 5-minute throttle per error fingerprint (prevents email storms) - Request scrubbing (strips credentials, cookies, body params, Datomic objects) - Stack trace filtering (orcpub.* frames, falls back to deepest non-infra) - Full cause chain rendering - Pedestal interceptor metadata extraction - Fix missing closing paren that broke compilation --- src/clj/orcpub/email.clj | 174 +++++++++++++++++++++++++++++++++------ 1 file changed, 148 insertions(+), 26 deletions(-) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index c0da8cf20..56faeef81 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -232,34 +232,156 @@ [base-url token] (str base-url (routes/path-for routes/unsubscribe-route) "?token=" token)) -(defn send-error-email - "Sends error notification email to configured admin email. +;; --------------------------------------------------------------------------- +;; Error email — helpers +;; --------------------------------------------------------------------------- - This function is called when unhandled exceptions occur in the application. - It includes request context and exception details for debugging. +(def ^:private error-throttle + "Maps fingerprint string → last-sent-ms. Suppresses duplicate error emails." + (atom {})) - Args: - context - Request context map - exception - The exception that occurred +(def ^:private throttle-window-ms (* 5 60 1000)) - Returns: - Postal send-message result, or nil if no error email is configured - or if sending fails (failures are logged but not thrown)" +(defn- root-cause [^Throwable ex] + (loop [e ex] + (if-let [c (.getCause e)] (recur c) e))) + +(defn- orcpub-frame? [^StackTraceElement f] + (s/starts-with? (.getClassName f) "orcpub.")) + +(def ^:private infra-prefixes + ["org.eclipse.jetty." "io.pedestal." "clojure.lang." + "java.lang.Thread" "sun.reflect." "java.util.concurrent." + "clojure.core$"]) + +(defn- infra-frame? [^StackTraceElement f] + (let [cls (.getClassName f)] + (some #(s/starts-with? cls %) infra-prefixes))) + +(defn- fmt-frame [^StackTraceElement f] + (str " " (.getClassName f) "." (.getMethodName f) + " (" (.getFileName f) ":" (.getLineNumber f) ")")) + +(defn- render-stack [^Throwable ex] + (let [frames (seq (.getStackTrace ex)) + app-frames (filter orcpub-frame? frames) + suppressed (count (filter infra-frame? frames))] + (str + (if (seq app-frames) + (s/join "\n" (map fmt-frame app-frames)) + (let [fallback (->> frames (remove infra-frame?) last)] + (if fallback + (str (fmt-frame fallback) " <- deepest non-infrastructure frame") + " (no frames available)"))) + (when (pos? suppressed) + (str "\n ... " suppressed " infrastructure frames suppressed"))))) + +(defn- render-cause-chain [^Throwable ex] + (let [sb (java.lang.StringBuilder.)] + (loop [e ex, depth 0] + (when e + (.append sb (str (when (pos? depth) "\nCaused by: ") + (.getName (.getClass e)) + ": " (or (.getMessage e) "(no message)") "\n")) + (.append sb (render-stack e)) + (.append sb "\n") + (recur (.getCause e) (inc depth)))) + (str sb))) + +(defn- throttle-fingerprint [^Throwable ex] + (let [root (root-cause ex) + root-class (.getName (.getClass root)) + frames (seq (.getStackTrace ex)) + app-frame (first (filter orcpub-frame? frames))] + (if app-frame + (str root-class "+" (.getClassName app-frame) "." (.getMethodName app-frame)) + (let [msg (or (.getMessage root) "")] + (str root-class "+" (subs msg 0 (min 60 (count msg)))))))) + +(defn- throttled? [fp] + (when-let [t (get @error-throttle fp)] + (< (- (System/currentTimeMillis) t) throttle-window-ms))) + +(defn- record-sent! [fp] + (swap! error-throttle assoc fp (System/currentTimeMillis))) + +(def ^:private safe-headers + #{"user-agent" "referer" "content-type" "accept-language" "cf-ipcountry" + "x-forwarded-for" "x-real-ip" "cf-ray" "sec-fetch-site" "sec-fetch-mode" + "x-forwarded-host" "x-forwarded-proto"}) + +(def ^:private drop-req-keys + #{:json-params :transit-params :form-params :body :db :conn + :servlet-request :servlet-response :servlet :url-for + :async-supported? :identity :character-encoding :protocol + :path-params :content-length}) + +(defn- scrub-request [req] + (-> (apply dissoc req drop-req-keys) + (update :headers #(select-keys (or % {}) safe-headers)))) + +(defn- pedestal-wrapper? + "True when ex-data looks like a Pedestal interceptor error map." + [data] + (and (map? data) (contains? data :exception) (contains? data :interceptor))) + +(defn- email-subject [^Throwable real-ex request] + (let [cls (.getSimpleName (.getClass real-ex)) + msg (let [m (or (.getMessage real-ex) "(no message)")] + (subs m 0 (min 80 (count m)))) + method (some-> (:request-method request) name s/upper-case) + uri (or (:uri request) "?")] + (str "[" branding/app-name "] " cls ": " msg " @ " method " " uri))) + +(defn- build-body [request real-ex pedestal-meta] + (str + "=== Request ===\n" + (with-out-str (pprint/pprint (scrub-request request))) + (when-let [u (:username request)] (str "User: " u "\n")) + "\n=== Exception ===\n" + (render-cause-chain real-ex) + (when (instance? clojure.lang.ExceptionInfo real-ex) + (when-let [d (ex-data real-ex)] + (str "\n=== Exception Data ===\n" + (with-out-str (pprint/pprint d))))) + (when pedestal-meta + (str "\n=== Interceptor Context ===\n" + (with-out-str (pprint/pprint pedestal-meta)))))) + +;; --------------------------------------------------------------------------- + +(defn send-error-email + "Sends a scrubbed, readable error notification email to the configured admin + address (EMAIL_ERRORS_TO env var). + + - Strips credentials, cookies, body params, and Datomic objects from request + - Filters stack trace to orcpub.* frames; falls back to deepest non-infra frame + - Walks the full cause chain + - Throttles: one email per unique error fingerprint per 5 minutes + - Extracts Pedestal interceptor metadata as a separate section" [context exception] (when (not-empty (environ/env :email-errors-to)) - (try - (let [result (postal/send-message (email-cfg) - {:from (str branding/app-name " Errors <" (emailfrom) ">") - :to (str (environ/env :email-errors-to)) - :subject "Exception" - :body [{:type "text/plain" - :content (let [writer (java.io.StringWriter.)] - (clojure.pprint/pprint (:request context) writer) - (clojure.pprint/pprint (or (ex-data exception) exception) writer) - (str writer))}]})] - (when (not= :SUCCESS (:error result)) - (println "WARNING: Failed to send error notification email:" (:error result))) - result) - (catch Exception e - (println "ERROR: Failed to send error notification email:" (.getMessage e)) - nil)))) \ No newline at end of file + (let [data-map (ex-data exception) + pedestal? (pedestal-wrapper? data-map) + real-ex (if pedestal? (:exception data-map) exception) + pedestal-meta (when pedestal? (dissoc data-map :exception :exception-type)) + request (or (:request context) {}) + fp (throttle-fingerprint real-ex)] + (if (throttled? fp) + (println "INFO: Suppressed duplicate error email (fingerprint:" fp ")") + (do + (record-sent! fp) + (try + (let [result (postal/send-message + (email-cfg) + {:from (str branding/app-name " Errors <" (emailfrom) ">") + :to (str (environ/env :email-errors-to)) + :subject (email-subject real-ex request) + :body [{:type "text/plain" + :content (build-body request real-ex pedestal-meta)}]})] + (when (not= :SUCCESS (:error result)) + (println "WARNING: Failed to send error notification email:" (:error result))) + result) + (catch Exception e + (println "ERROR: Failed to send error notification email:" (.getMessage e)) + nil))))))) \ No newline at end of file From be34a68d8c6a3ac170ed63fbaf6706373a26c6da Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 19:42:39 +0000 Subject: [PATCH 24/50] =?UTF-8?q?fix:=20code=20quality=20cleanup=20?= =?UTF-8?q?=E2=80=94=20prn=E2=86=92log,=20dead=20require,=20preferences=20?= =?UTF-8?q?re-read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pedestal.clj: replace bare prn with io.pedestal.log/error in ETag interceptor - routes.clj: remove dead commented-out oauth require - routes.clj: update-user-preferences re-reads DB after transact (authoritative response) - routes.clj: re-throw unrecognized ExceptionInfo in save-character-handler --- src/clj/orcpub/pedestal.clj | 4 ++-- src/clj/orcpub/routes.clj | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/clj/orcpub/pedestal.clj b/src/clj/orcpub/pedestal.clj index 7efce5346..ad0104f37 100644 --- a/src/clj/orcpub/pedestal.clj +++ b/src/clj/orcpub/pedestal.clj @@ -2,6 +2,7 @@ (:require [com.stuartsierra.component :as component] [io.pedestal.http :as http] [io.pedestal.interceptor :as interceptor] + [io.pedestal.log :as log] [pandect.algo.sha1 :refer [sha1]] [datomic.api :as d] [clojure.string :as s] @@ -99,8 +100,7 @@ (if new-etag (assoc-in context [:response :headers "etag"] new-etag) context))) - (catch Throwable t (prn "T" t ))))})) - + (catch Throwable t (log/error :msg "ETag interceptor error" :exception t))))})) (defrecord Pedestal [service-map conn service] component/Lifecycle diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 818c668e3..5f9c3a25d 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -473,7 +473,8 @@ (defn update-user-preferences "PUT handler for /user — update user preferences (currently send-updates?). - Requires authentication. Only updates fields present in transit-params." + Requires authentication. Only updates fields present in transit-params. + Re-reads from DB after transact to return authoritative state." [{:keys [transit-params db conn identity]}] (let [username (:user identity) {:keys [:db/id]} (find-user-by-username db username)] @@ -481,8 +482,10 @@ (do (when (contains? transit-params :send-updates?) @(d/transact conn [{:db/id id :orcpub.user/send-updates? (boolean (:send-updates? transit-params))}])) - {:status 200 - :body {:send-updates? (boolean (:send-updates? transit-params))}}) + ;; Re-read from DB after transact for authoritative response + (let [updated-user (d/entity (d/db conn) id)] + {:status 200 + :body {:send-updates? (boolean (:orcpub.user/send-updates? updated-user))}})) {:status 400 :body {:error "User not found"}}))) (defn do-send-password-reset [user-id email conn request] @@ -939,7 +942,8 @@ (let [data (ex-data e)] (case (:error data) :character-problems {:status 400 :body (:problems data)} - :not-user-character {:status 401 :body "You do not own this character"}))) + :not-user-character {:status 401 :body "You do not own this character"} + (throw e)))) ; re-throw unrecognised ExceptionInfo (e.g. :db/error from Datomic) (catch Exception e (prn "ERROR" e) (throw e))))) (defn save-character [{:keys [db transit-params body conn identity] :as request}] From 00d970d1ca9bf5478152afc909cb0db5a105cb4f Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 20:21:38 +0000 Subject: [PATCH 25/50] =?UTF-8?q?fix:=20dev-mode=3F=20boolean=20parsing=20?= =?UTF-8?q?=E2=80=94=20(boolean=20"false")=20is=20truthy=20in=20Clojure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Env vars are strings. (boolean "false") returns true because any non-nil, non-false value is truthy. Fixed to compare against the string "true". This caused CSP nonce-interceptor to be a no-op in prod (DEV_MODE=false was parsed as truthy), resulting in empty Content-Security-Policy headers. Also removes dead devmode? def from index.clj (decoupled since 15b6d2a4). --- src/clj/orcpub/config.clj | 8 ++++---- src/clj/orcpub/index.clj | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/clj/orcpub/config.clj b/src/clj/orcpub/config.clj index 498f418b4..6d4471f28 100644 --- a/src/clj/orcpub/config.clj +++ b/src/clj/orcpub/config.clj @@ -43,11 +43,11 @@ (str/lower-case policy))) (defn dev-mode? - "Returns true when running in dev mode (DEV_MODE env var is truthy). - Used by CSP to determine if Figwheel/CLJS dev builds are in use. - See also: index.clj which uses the same env var pattern." + "Returns true when running in dev mode (DEV_MODE env var is 'true'). + Env vars are strings — (boolean \"false\") is true in Clojure, so we + must compare against the string \"true\" explicitly." [] - (boolean (env :dev-mode))) + (= "true" (str/lower-case (or (env :dev-mode) "")))) (defn strict-csp? "Returns true when CSP_POLICY=strict (regardless of dev mode). diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index 146a61fac..b48268fdf 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -8,8 +8,6 @@ [cheshire.core :as cheshire] [environ.core :refer [env]])) -(def devmode? (env :dev-mode)) - (def homebrew-url "URL to fetch server-hosted .orcbrew plugins from on first load. Set LOAD_HOMEBREW_URL to enable (e.g. \"/homebrew.orcbrew\" or a full URL). From 856947a76ec0e99231c4d675303bb427243fad3a Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 20:58:08 +0000 Subject: [PATCH 26/50] docs: update all docs for fork/ reorg, dev-mode? fix, and new KB entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BRANDING-AND-INTEGRATIONS.md: all paths updated to fork/ subdirectory, added integrations config bridge note and pedestal.clj CSP row - ENVIRONMENT.md: DEV_MODE must be string "true", added FIGWHEEL_PORT and FIGWHEEL_CONNECT_URL, updated file-reads table for fork/ paths - .env.example: fork/ path comments, APP_PAGE_TITLE rename, FIGWHEEL_PORT - README.md: test count 74→210, assertions 237→963 - dev-tooling.md: dev-mode? description matches actual behavior - docs/README.md: link to new KB index - docs/TODO.md: Datomic transactor crash investigation entry - docs/kb/: agent knowledge base — crash analysis with log evidence - docs/error-email-improvements.md: analysis and handoff doc --- .env.example | 10 +- README.md | 2 +- docs/BRANDING-AND-INTEGRATIONS.md | 138 ++++++++-- docs/ENVIRONMENT.md | 12 +- docs/README.md | 4 + docs/TODO.md | 40 +++ docs/error-email-improvements.md | 432 ++++++++++++++++++++++++++++++ docs/kb/README.md | 18 ++ docs/kb/datomic-crash-analysis.md | 204 ++++++++++++++ docs/migration/dev-tooling.md | 2 +- 10 files changed, 837 insertions(+), 25 deletions(-) create mode 100644 docs/error-email-improvements.md create mode 100644 docs/kb/README.md create mode 100644 docs/kb/datomic-crash-analysis.md diff --git a/.env.example b/.env.example index e8903834d..c7f1c38b0 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,7 @@ CSP_POLICY=strict # Dev mode: CSP violations are logged (Report-Only) instead of blocked, # allowing Figwheel hot-reload scripts to execute. +# Must be the string "true" (case-insensitive). Any other value is treated as false. DEV_MODE=true # --- Plugins --- @@ -55,6 +56,9 @@ DEV_MODE=true LOG_DIR= # --- Remote Dev (optional) --- +# Figwheel WebSocket port for hot-reload (default: 3449) +# FIGWHEEL_PORT=3449 + # Figwheel WebSocket URL override for remote environments (Gitpod, tunnels, etc.) # Auto-detected for GitHub Codespaces — only set this for other remote setups. # Example: wss://my-remote-host:3449/figwheel-connect @@ -73,7 +77,7 @@ EMAIL_TLS=FALSE # --- Branding (optional) --- # Override to customize the app identity for your deployment. -# All values have neutral defaults; set only what you want to change. +# All values have neutral defaults in fork/branding.clj; set only what you want to change. # APP_NAME=My D&D Toolkit # APP_TAGLINE=A custom character builder # APP_PAGE_TITLE=My Toolkit: D&D 5e Character Builder @@ -91,8 +95,8 @@ EMAIL_TLS=FALSE # APP_SOCIAL_DISCORD= # --- Third-Party Integrations (optional) --- -# Server-side <head> tags loaded via integrations.clj. -# Client-side lifecycle hooks in integrations.cljs. +# Server-side <head> tags loaded via fork/integrations.clj. +# Client-side lifecycle hooks in fork/integrations.cljs. # See docs/kb/fork-customization.md for the full pattern. # --- Initial Admin User (optional) --- diff --git a/README.md b/README.md index 77b360d03..49d408b6f 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ For the full list, see [docs/migration/dev-tooling.md](docs/migration/dev-toolin Run these before committing: ```bash -# Server-side tests (74 tests, 237 assertions) +# Server-side tests (210 tests, 963 assertions) lein test # Linter (0 errors expected; warnings are from third-party libs) diff --git a/docs/BRANDING-AND-INTEGRATIONS.md b/docs/BRANDING-AND-INTEGRATIONS.md index 78e51769a..55b3d65ac 100644 --- a/docs/BRANDING-AND-INTEGRATIONS.md +++ b/docs/BRANDING-AND-INTEGRATIONS.md @@ -4,9 +4,82 @@ How to customize the app's identity and add third-party integrations. ## Branding -All branding values are configured via environment variables in `.env`. Defaults are set in `src/clj/orcpub/branding.clj`. +All branding values are configured via environment variables in `.env`. Defaults are set in `src/clj/orcpub/fork/branding.clj`. -Server-side values reach the browser through a config bridge: `branding.clj` builds a map, `index.clj` injects it as `window.__BRANDING__` JSON in `<head>`, and `branding.cljs` reads it at runtime. +Now all fork-specific behavior lives in **6 small override files**. Shared files call the same functions on both branches — they just get different results. + +### Before + +``` +views.cljs:438 → hardcoded Patreon URL +views.cljs:1522 → hardcoded ad reload script +views.cljs:1576 → hardcoded donation banner HTML +views.cljs:3769 → hardcoded PDF upsell content +email.clj:93 → hardcoded "no-reply@dungeonmastersvault.com" +privacy.clj:127 → hardcoded ad-network <script> tags +events.cljs:1794 → hardcoded support email +``` + +### After + +``` +views.cljs:438 → integrations/supporter-link +views.cljs:1522 → integrations/on-app-mount! +views.cljs:1576 → integrations/support-banner +views.cljs:3769 → integrations/pdf-options-slot +email.clj:93 → branding/email-from-address +privacy.clj:127 → integrations/head-tags +events.cljs:1794 → branding/support-email +``` + +All the actual content now lives in override files that never conflict on merge. + +--- + +## The 6 Override Files + +All live under `src/clj/orcpub/fork/` (server) and `src/cljs/orcpub/fork/` (client). These are the only files that differ between public and production. On merge, always **keep production's version**. + +| File | Path | What it controls | Public repo | Production | +|------|------|-----------------|-------------|------------| +| `branding.clj` | `src/clj/orcpub/fork/` | App name, logos, emails, social links, field limits | OrcPub defaults | DMV defaults | +| `branding.cljs` | `src/cljs/orcpub/fork/` | Same values on the client side | OrcPub fallbacks | DMV fallbacks | +| `user_tier.cljs` | `src/cljs/orcpub/fork/` | User tier subscription (`:user-tier`) | Always `:free` | Derived from patron status | +| `user_data.clj` | `src/clj/orcpub/fork/` | API response enrichment | Pass-through | Adds patron fields | +| `integrations.clj` | `src/clj/orcpub/fork/` | Server-side `<head>` script tags | Empty | Matomo + AdSense | +| `integrations.cljs` | `src/cljs/orcpub/fork/` | Client-side UI hooks + analytics | No-op stubs | Full implementation | + +Everything else — views.cljs, events.cljs, email.clj, privacy.clj, character_builder.cljs — is **identical** on both branches. + +--- + +## How the Config Bridge Works + +Server-side values (from `.env` → `fork/branding.clj`) get to the browser through a JSON bridge: + +``` +.env → fork/branding.clj (reads env vars) + ↓ + client-config (builds a map) + ↓ + index.clj (serializes to JSON in <head>) + ↓ + <script>window.__BRANDING__ = {...};</script> + ↓ + fork/branding.cljs (reads window.__BRANDING__ at load time) + ↓ + Any CLJS file can require [orcpub.fork.branding :as branding] +``` + +A parallel bridge exists for integrations: `fork/integrations.clj` provides `client-config` which index.clj injects as `window.__INTEGRATIONS__`, read by `fork/integrations.cljs`. + +Why not just read env vars in ClojureScript? `environ.core/env` is JVM-only. CLJS runs in the browser — it needs the values injected. + +--- + +## Configuration Reference + +All values have defaults in `fork/branding.clj`. Set env vars in `.env` to override. ### App Identity @@ -64,11 +137,13 @@ Input validation constraints for form fields. Third-party services (analytics, ads) are managed through two files: -- **`integrations.clj`** (server-side) — injects `<script>` tags in `<head>` -- **`integrations.cljs`** (client-side) — provides lifecycle hooks and UI components +- **`fork/integrations.clj`** (server-side) — injects `<script>` tags in `<head>`, exports CSP domain allowlists for `pedestal.clj` +- **`fork/integrations.cljs`** (client-side) — provides lifecycle hooks and UI components, reads config from `window.__INTEGRATIONS__` ### Analytics & Ads +Server-side (`fork/integrations.clj`) injects SDK scripts in `<head>` and exports CSP domain allowlists for `pedestal.clj`. Client-side (`fork/integrations.cljs`) handles in-app behavior, reading ad client/slot IDs from the `window.__INTEGRATIONS__` config bridge. + | Env Var | Default | What it enables | |---------|---------|-----------------| | `MATOMO_URL` | *(empty = disabled)* | Matomo analytics tracking | @@ -77,7 +152,7 @@ Third-party services (analytics, ads) are managed through two files: ### Integration Hooks -The app calls these functions at specific points. By default they're no-ops — override them in `integrations.cljs` to add custom behavior. +The app calls these functions at specific points. By default they're no-ops — override them in `fork/integrations.cljs` to add custom behavior. **Lifecycle hooks** (called from events/views): @@ -102,23 +177,52 @@ The app calls these functions at specific points. By default they're no-ops — ## Adding a New Integration Hook -1. Add the stub function in `integrations.cljs` (empty body or `nil` return) +1. Add the stub function in `fork/integrations.cljs` (empty body or `nil` return) 2. Wire the call site in the appropriate shared file (views.cljs, etc.) 3. Implement the real behavior in the stub body --- -## Files That Read Config +## User Tier System (fork/user_tier.cljs) + +| Branch | `:user-tier` subscription returns | +|--------|----------------------------------| +| Public | Always `:free` | +| Production | Derived from `:patron` + `:patron-tier` → `:free`, `:patron`, `:gold`, etc. | + +All tier gating in shared code uses `@(subscribe [:user-tier])`. The integration hooks also self-gate — `content-slot` checks tier internally, so callers don't need to. + +--- + +## Merging + +When merging public → production: + +| File type | What happens | Action | +|-----------|-------------|--------| +| Override files (6 listed above) | Conflict | **Keep ours (production)** | +| Everything else | No conflict | Auto-merge | + +When adding a new integration hook: +1. Add stub on public first (empty body or `nil` return) +2. Add real implementation on production +3. Wire the call site in shared code (same on both branches) + +--- + +## Files That Read Branding/Integration Config | File | What it reads | |------|--------------| -| `branding.clj` | All `APP_*` env vars | -| `integrations.clj` | `MATOMO_URL`, `MATOMO_SITE_ID`, `ADSENSE_CLIENT` | -| `index.clj` | Calls `branding/client-config` + `integrations/head-tags` | -| `privacy.clj` | `branding/*` for app name + `integrations/head-tags` for scripts | -| `email.clj` | `branding/email-from-address`, `branding/email-sender-name` | -| `branding.cljs` | Reads `window.__BRANDING__` (injected by index.clj) | -| `integrations.cljs` | Reads `branding/*` via branding.cljs | -| `views.cljs` | `branding/*` + `integrations/*` hooks | -| `events.cljs` | `branding/support-email` | -| `character_builder.cljs` | `integrations/share-links` | +| `fork/branding.clj` | All `APP_*` env vars, `EMAIL_FROM_ADDRESS` | +| `fork/integrations.clj` | `MATOMO_URL`, `MATOMO_SITE_ID`, `ADSENSE_CLIENT`, `ADSENSE_SLOT` | +| `index.clj` | Calls `fork/branding/client-config` + `fork/integrations/head-tags` + `fork/integrations/client-config` | +| `privacy.clj` | Calls `fork/branding/*` for names + `fork/integrations/head-tags` for scripts | +| `email.clj` | `fork/branding/email-from-address`, `fork/branding/email-sender-name` | +| `routes.clj` | `fork/branding/*` (app-name), `fork/user-data/*` (response enrichment) | +| `pedestal.clj` | `fork/integrations/csp-domains` (CSP allowlists) | +| `fork/branding.cljs` | Reads `window.__BRANDING__` (injected by index.clj) | +| `fork/integrations.cljs` | Reads `window.__INTEGRATIONS__` + `fork/branding/*` via branding.cljs | +| `views.cljs` | Reads `fork/branding/*` + calls `fork/integrations/*` hooks | +| `events.cljs` | Reads `fork/branding/support-email`, calls `fork/integrations/track-page-view!` | +| `character_builder.cljs` | Calls `fork/integrations/share-links` | diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 07cd4f247..c7367affa 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -47,7 +47,7 @@ All configuration is managed via a `.env` file at the repository root. Copy `.en | Variable | Default | Description | |----------|---------|-------------| | `CSP_POLICY` | `strict` | Content Security Policy mode: `strict`, `permissive`, or `none` | -| `DEV_MODE` | `true` (in :dev profile) | Enables dev-mode CSP (Report-Only instead of enforcing) | +| `DEV_MODE` | `"true"` (in :dev profile) | Enables dev-mode CSP (Report-Only instead of enforcing). Must be the string `"true"` (case-insensitive) -- any other value (including `"1"`, `"yes"`, or empty) is treated as false. | CSP modes: - **strict** — nonce-based CSP with `strict-dynamic`. Dev mode uses `Report-Only` header (logs violations but doesn't block). Prod uses enforcing header. @@ -90,6 +90,8 @@ See `docker/transactor.properties.template` for the full transactor configuratio | Variable | Default | Description | |----------|---------|-------------| | `ORCPUB_ENV` | — | Set to `dev` to enable `add-test-user` in user.clj | +| `FIGWHEEL_PORT` | `3449` | Figwheel WebSocket port for frontend hot-reload. Read by `scripts/common.sh`. | +| `FIGWHEEL_CONNECT_URL` | *(auto-detected)* | Figwheel WebSocket URL override for remote environments (Gitpod, tunnels). Auto-detected for GitHub Codespaces. Example: `wss://my-remote-host:3449/figwheel-connect` | ## Files That Read Environment @@ -98,8 +100,12 @@ See `docker/transactor.properties.template` for the full transactor configuratio | `src/clj/orcpub/config.clj` | `DATOMIC_URL`, `CSP_POLICY`, `DEV_MODE` | | `src/clj/orcpub/system.clj` | `PORT` (via `System/getenv`) | | `src/clj/orcpub/routes.clj` | `SIGNATURE`, `EMAIL_*`, `ADMIN_PASSWORD` | -| `src/clj/orcpub/index.clj` | `DEV_MODE`, `LOAD_HOMEBREW_URL` | +| `src/clj/orcpub/index.clj` | `LOAD_HOMEBREW_URL` (calls `fork/branding` + `fork/integrations`) | +| `src/clj/orcpub/fork/branding.clj` | `APP_*`, `APP_SOCIAL_*`, `APP_FIELD_LIMIT_*`, `EMAIL_FROM_ADDRESS` | +| `src/clj/orcpub/fork/integrations.clj` | `MATOMO_URL`, `MATOMO_SITE_ID`, `ADSENSE_CLIENT`, `ADSENSE_SLOT` | +| `src/clj/orcpub/pedestal.clj` | (reads `fork/integrations` for CSP domain allowlists) | | `.devcontainer/post-create.sh` | `DATOMIC_VERSION`, `DATOMIC_TYPE` | -| `scripts/start.sh` | `DATOMIC_URL`, `LOG_DIR` | +| `scripts/start.sh` | `DATOMIC_URL`, `LOG_DIR`, `FIGWHEEL_PORT`, `FIGWHEEL_CONNECT_URL` | +| `scripts/common.sh` | `FIGWHEEL_PORT` (default: 3449) | | `deploy/start.sh` | `ADMIN_PASSWORD`, `DATOMIC_PASSWORD`, `ALT_HOST`, `ENCRYPT_CHANNEL`, `*_OLD` rotation vars | | `dev/user.clj` | `ORCPUB_ENV` (for add-test-user guard) | diff --git a/docs/README.md b/docs/README.md index ba1159f08..8c9941500 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,10 @@ Guides for developers and power users working with OrcPub's homebrew content sys - [🔍 Missing Content Detection](CONTENT_RECONCILIATION.md) - Find/fix missing content references - [📋 Required Fields Guide](HOMEBREW_REQUIRED_FIELDS.md) - Required fields per content type +**Agent Knowledge Base:** +- [📚 KB Index](kb/README.md) - Verified findings from deep investigations +- [💥 Datomic Crash Analysis](kb/datomic-crash-analysis.md) - Root cause, frequency, fix options + **For Developers:** - [🚨 Error Handling](ERROR_HANDLING.md) - Error handling utilities - [🗡️ Language Selection Fix](LANGUAGE_SELECTION_FIX.md) - Ranger favored enemy language corruption (#296) diff --git a/docs/TODO.md b/docs/TODO.md index 79134dada..b24f5f5b8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,45 @@ # TODO — Tracked Issues +## Datomic transactor crashes — investigate Postgres migration + +**Status:** Open +**Severity:** Critical — transactor crashing 3–5× per day, 2–3 min downtime each +**Reported:** 2026-02-26 +**KB doc:** [docs/kb/datomic-crash-analysis.md](kb/datomic-crash-analysis.md) + +### Summary + +The Datomic transactor is self-terminating multiple times daily with +`"Critical failure, cannot continue: Heartbeat failed"`. Root cause is H2 +write-lock contention during memoryIndex flushes starving the heartbeat thread. +`writeConcurrency=4` amplifies the problem — H2 cannot parallelize writes. + +### Immediate mitigation (low risk, config only) + +Set `datomic.writeConcurrency=1` in the transactor properties file. See KB doc +for caveats. + +### Permanent fix + +Migrate from Datomic Free + H2 to Datomic Pro + PostgreSQL. Datomic Pro is +free under Apache 2.0 (see `docs/migration/datomic-pro.md` — peer migration +already done). What remains is the **storage backend migration**: + +1. Provision PostgreSQL (Docker service or managed) +2. Run Datomic's SQL init scripts (`bin/sql/postgres-*.sql`) +3. Export data from H2 transactor with `bin/datomic backup-db` +4. Restore into Postgres transactor with `bin/datomic restore-db` +5. Update transactor properties: `storage-class=sql`, JDBC params +6. Update Docker Compose to add Postgres service and remove H2 volume + +### Related + +- `docker/datomic/` — transactor container and config templates +- `docs/migration/datomic-pro.md` — peer library already migrated to Pro +- `docs/kb/datomic-crash-analysis.md` — full root cause analysis with log evidence + +--- + ## localStorage corrupt data persistence **Status:** Open diff --git a/docs/error-email-improvements.md b/docs/error-email-improvements.md new file mode 100644 index 000000000..b3ba78ebe --- /dev/null +++ b/docs/error-email-improvements.md @@ -0,0 +1,432 @@ +# Error Email Improvements — Analysis & Handoff + +Live document. Updated as each email example is analyzed. When all examples are covered, convert the plan section into implementation tasks and commit. + +**Branch:** `dmv/hotfix-integrations` (keep in sync with `breaking/`) +**Files in scope:** `src/clj/orcpub/email.clj`, `src/clj/orcpub/routes.clj`, `docs/ERROR_HANDLING.md`, `docs/email-system.md` + +--- + +## How error emails work today + +1. Any unhandled exception in a Pedestal interceptor chain hits `service-error-handler` in `routes.clj:1378`. +2. That calls `email/send-error-email ctx ex` (`email.clj:238`). +3. `send-error-email` fires only when `EMAIL_ERRORS_TO` env var is set. +4. Subject is always the hard-coded string `"Exception"`. +5. Body is `pprint` of `(:request context)` + `pprint` of `(or (ex-data exception) exception)`. + +--- + +## Known problems (from codebase review, before example analysis) + +| # | Problem | Impact | +|---|---------|--------| +| P1 | Subject is always `"Exception"` | Inbox is untriadgeable; every email looks the same | +| P2 | Body uses `(or (ex-data exception) exception)` — for `ExceptionInfo` this prints the data map only, no stack trace | Root cause is invisible | +| P3 | For plain Java exceptions, pprinting the Java object is not a stack trace | Same — no frames | +| P4 | Full `(:request context)` is dumped — includes `Authorization` headers, session cookies, POST bodies | Security exposure + very noisy | +| P5 | No flood throttle — a bad code path firing in a loop sends unlimited emails | Admin inbox spam, alert fatigue | +| P6 | No cause-chain traversal — only the outermost exception is shown | Wrapped exceptions hide the real error | +| P7 | `:json-params` (parsed POST body) is included in request dump — login requests contain `:password` | **Critical: plaintext credentials in admin email** | +| P8 | `:db` and `:conn` fields (live Datomic objects) are in request context and get dumped — they pprint to internal DB identifiers, t-values, index-rev | Noise + exposes internal DB metadata | +| P9 | Pedestal wraps the real exception under an `:exception` key inside `ex-data` — current format buries the actual stack trace one level deep | Hard to read; actionable frames require digging into nested map | +| P10 | `:cookie` header is fully included — exposes CloudFlare clearance tokens, analytics IDs, consent UUIDs | User session data leaked to admin email | +| P11 | Java object refs (`:servlet-request`, `:servlet-response`, `:servlet`, `:url-for`, `:body` stream) are dumped as `#object[...]` strings | Pure noise, adds length with zero signal | + +--- + +## Example emails + +### Example 1 — Raw Jetty infrastructure stack + +**Full body received:** + +``` +{"ServletHolder.java" 845] + [org.eclipse.jetty.servlet.ServletHandler doHandle "ServletHandler.java" 583] + [org.eclipse.jetty.server.handler.ScopedHandler handle "ScopedHandler.java" 143] + [org.eclipse.jetty.server.handler.gzip.GzipHandler handle "GzipHandler.java" 399] + [org.eclipse.jetty.server.handler.ContextHandler doHandle "ContextHandler.java" 1162] + [org.eclipse.jetty.servlet.ServletHandler doScope "ServletHandler.java" 511] + [org.eclipse.jetty.server.handler.ContextHandler doScope "ContextHandler.java" 1092] + [org.eclipse.jetty.server.handler.ScopedHandler handle "ScopedHandler.java" 141] + [org.eclipse.jetty.server.handler.HandlerWrapper handle "HandlerWrapper.java" 134] + [org.eclipse.jetty.server.Server handle "Server.java" 518] + [org.eclipse.jetty.server.HttpChannel handle "HttpChannel.java" 308] + [org.eclipse.jetty.server.HttpConnection onFillable "HttpConnection.java" 244] + [org.eclipse.jetty.io.AbstractConnection$ReadCallback succeeded "AbstractConnection.java" 273] + [org.eclipse.jetty.io.FillInterest fillable "FillInterest.java" 95] + [org.eclipse.jetty.io.SelectChannelEndPoint$2 run "SelectChannelEndPoint.java" 93] + [org.eclipse.jetty.util.thread.strategy.ExecuteProduceConsume produceAndRun "ExecuteProduceConsume.java" 246] + [org.eclipse.jetty.util.thread.strategy.ExecuteProduceConsume run "ExecuteProduceConsume.java" 156] + [org.eclipse.jetty.util.thread.QueuedThreadPool runJob "QueuedThreadPool.java" 654] + [org.eclipse.jetty.util.thread.QueuedThreadPool$3 run "QueuedThreadPool.java" 572] + [java.lang.Thread run "Thread.java" 750]]}} +``` + +**What this tells us:** + +- Every single frame is `org.eclipse.jetty.*` or `java.lang.Thread`. Zero `orcpub.*` frames are visible. +- The body snippet begins mid-structure (the `{` at the start is a truncated pprint of the exception map). The actual exception type and message are not shown — they were in the part of the pprint that was cut off or came before. +- This is `(ex-data exception)` being pprinted for an `ExceptionInfo` whose `:cause` is a Jetty-wrapped exception. The entire call graph is infrastructure scaffolding — completely unactionable. +- The email format (P3, P2) is the direct cause: because `(or (ex-data exception) exception)` selects the data map over the Java exception, the `.getStackTrace()` frames are never rendered at all. What appears here is a Clojure/EDN representation of the stack frames stored inside the exception data — not a filtered Java stack trace. + +**What's needed to make this actionable:** + +- Walk `(.getCause exception)` chain to find the deepest cause. +- Render `.getStackTrace()` as text, filtered to keep only `orcpub.*` frames (with infrastructure count appended). +- Label the exception type and message clearly at the top. + +--- + +## Plan (current state) + +### Send-error-email rewrite (`email.clj`) + +1. **Subject:** `[AppName] ExceptionClassName: message-preview @ METHOD /path` + Example: `[DMV] NullPointerException: Cannot read field on nil @ GET /character/12345` + +2. **Stack trace rendering:** + - Walk `.getCause` chain (for Java exceptions) and `:via` chain (for `ExceptionInfo`); render each level. + - For each, print: `ExceptionClass: message` then indented frames. + - Filter frames: keep `orcpub\.` frames; suppress `org.eclipse.jetty`, `io.pedestal`, `clojure.lang`, `java.lang.Thread`, `sun.reflect`, `java.util.concurrent`. Append `... N frames suppressed`. + - **Fallback (P15):** if zero `orcpub.*` frames exist after filtering, include the deepest non-suppressed frame from the innermost cause (e.g., `datomic.sql/connect`) rather than rendering nothing. Label it `← deepest non-infrastructure frame`. + +3. **Request scrub:** extract only safe fields from `(:request context)`: + - Keep: `:request-method`, `:uri`, `:query-string`, `:remote-addr`, `:route-name`, `:username` (if present — it's useful context and not a secret) + - Drop from headers: `authorization`, `cookie`, `x-auth-token`, `x-session` + - Drop entirely: `:json-params`, `:transit-params`, `:form-params`, `:body`, `:db`, `:conn`, `:servlet-request`, `:servlet-response`, `:servlet`, `:url-for`, `:async-supported?`, `:identity` + +4. **`ex-data` block:** keep but exclude from Pedestal wrapper — extract `(-> ex-data :exception ex-data)` (the inner exception's data), not the outer Pedestal context map. The Pedestal context fields (`:execution-id`, `:stage`, `:interceptor`) go in a separate "Interceptor context" section. + +5. **Flood throttle (P5, P16):** + - Primary fingerprint: `exception-class + first-orcpub-frame` + - Fallback fingerprint (no orcpub frames): `root-cause-class + first 60 chars of root-cause-message` + - Skip send (log instead) if same fingerprint seen within 5 minutes. + - Log: `"Suppressed duplicate error email (fingerprint: %s, last sent: %s ago)"`. + +### Docs updates + +- `docs/ERROR_HANDLING.md` — update "Error Notification" section with new email format. +- `docs/email-system.md` — update § 4 with throttle behaviour and scrubbed fields list. + +--- + +### Example 2 — Transactor unavailable during login (full email) + +**What arrived:** Two pprinted blocks — the full Pedestal request map, then the Pedestal interceptor error context map. + +**What the exception is:** + +``` +clojure.lang.ExceptionInfo +:db.error/transactor-unavailable Transactor not available + at datomic.peer$transactor_unavailable (peer.clj:185) + datomic.peer.Connection transact (peer.clj:331) + datomic.api$transact (api.clj:94) + → orcpub.routes$create_login_response (routes.clj:205) + → orcpub.routes$login_response (routes.clj:233) + → orcpub.routes$login (routes.clj:237) + io.pedestal.interceptor.chain ... [infrastructure] + org.eclipse.jetty ... [infrastructure] +``` + +A user hit `POST /login` while the Datomic transactor was down. The three `orcpub.routes` frames tell us exactly what code path was live. This is completely actionable — with the current email format you have to hunt for it inside a deeply nested pprint. + +**New problems surfaced by this example:** + +- **P7 (critical):** The request dump includes `:json-params {:username "...", :password "<redacted>"}`. The `<redacted>` was done by whoever forwarded us the email — **the app itself sent the plaintext password**. Login, registration, and password-reset routes all POST credentials that would appear here. +- **P8:** `:db datomic.db.Db@82bd5625` and `:conn #object[datomic.peer.Connection ... {:db-id "orcpub-e1a68122-...", :next-t 181480137, ...}]` — both are live Java objects that Pedestal injects into the request map. They pprint to internal connection metadata including the full DB UUID and transaction counters. +- **P9:** The exception is nested as `(-> (ex-data interceptor-error) :exception)`. The current `pprint` of `ex-data` does output the full #error map (so the trace IS technically present), but it's buried under `:execution-id`, `:stage`, `:interceptor`, `:exception-type`, then `:exception`, then `:trace`. In Example 1 the email was likely truncated before those frames appeared. +- **P10:** The full `:cookie` string is in the headers dump — CloudFlare clearance token, Matomo `_pk_id`/`_pk_ses`, Google Analytics `_ga`, IAB consent UUIDs. These belong to individual users. +- **P11:** `:servlet-request`, `:servlet-response`, `:servlet`, `:url-for`, `:body`, `:async-supported?` are all Java/fn object refs — `#object[...]` strings with zero diagnostic value. + +**Flood throttle is important for this class of error:** Datomic going down means every authenticated request will throw the same exception. Without throttling, a 60-second Datomic blip at peak traffic could send hundreds of identical emails before anyone can react. + +**What a good email for this error would look like:** + +``` +Subject: [DMV] ExceptionInfo: :db.error/transactor-unavailable @ POST /login + +Request: POST /login (10.0.38.3 via 2605:a601:..., Firefox/147.0 Windows) + +Exception chain: + clojure.lang.ExceptionInfo: :db.error/transactor-unavailable Transactor not available + data: {:db/error :db.error/transactor-unavailable} + at orcpub.routes/create-login-response (routes.clj:205) + orcpub.routes/login-response (routes.clj:233) + orcpub.routes/login (routes.clj:237) + ... 40 infrastructure frames suppressed (datomic.*, io.pedestal.*, org.eclipse.jetty.*) + +Interceptor context: + {:execution-id 1329, :stage :enter, :interceptor :orcpub.routes/login} +``` + +--- + +### Example 3 — H2 storage backend refused connection on `GET /dnd/5e/items` + +**What the exception is — 3-level cause chain:** + +``` +java.util.concurrent.ExecutionException + wraps → org.h2.jdbc.JdbcSQLException + "Connection is broken: java.net.ConnectException: Connection refused: datomic:4335" + wraps → java.net.ConnectException + "Connection refused (Connection refused)" + at java.net.PlainSocketImpl.socketConnect (native) + org.h2.engine.SessionRemote.connectServer (SessionRemote.java:395) + datomic.sql/connect (sql.clj:16) + datomic.kv_sql.KVSql.get (kv_sql.clj:60) + datomic.kv_cluster ... [retry logic] + java.util.concurrent.FutureTask ... [thread pool] +``` + +**What this tells us:** + +- Port 4335 is the H2 SQL storage backend that Datomic Free uses underneath the transactor. This is a different failure layer from Example 2 (port 4334, transactor unreachable). Both happened within ~1 minute of each other (04:50:57 and 04:51:48 on the same day) with identical DB identifiers (`orcpub-e1a68122-...`, `next-t 181480137`). **This was a single Datomic outage event that generated at least two emails in under a minute** — possibly many more across all concurrent requests. +- Zero `orcpub.*` frames anywhere in the trace. The failure is entirely inside Datomic's storage layer. With the current frame-filter plan (keep only `orcpub.*`), this email would render an empty stack trace. The plan needs a fallback. +- The request is authenticated — `:identity {:user "millennialdoomer", :exp 1772560158}` is the decoded JWT payload injected by the auth interceptor, and `:username "millennialdoomer"` is an additional field. Both are in the request map. + +**New problems surfaced:** + +- **P12 (critical):** `authorization: Token eyJhbG...` — a live signed JWT is in the headers dump. It's still valid until `:exp 1772560158`. Anyone with this email can impersonate that user until expiry. +- **P13:** `:identity` (decoded JWT) is in the request map — exposes username and token expiry. +- **P14:** `:username` field (added by auth interceptor) echoes the username again — minor, but confirms auth-enriched fields are present on all authenticated routes. +- **P15 (plan gap):** When zero `orcpub.*` frames exist, the filtered stack trace should fall back to showing the deepest non-boilerplate frame — e.g., `datomic.sql/connect (sql.clj:16)` — rather than rendering nothing. The cause message alone (`Connection refused: datomic:4335`) is enough to diagnose layer but a single anchor frame is more useful than silence. +- **P16 (throttle fingerprint design):** Fingerprinting by `exception-class + first-orcpub-frame` won't work when there are no orcpub frames. Fingerprint should fall back to `root-cause-class + root-cause-message-prefix (first 60 chars)`. For this incident both Example 2 and Example 3 would get separate fingerprints (different root cause classes/messages) — which is correct, they're different failure modes. But many copies of Example 3 across concurrent `/items` requests would correctly collapse to one. + +**What a good email for this error would look like:** + +``` +Subject: [DMV] ExecutionException: Connection is broken "Connection refused: datomic:4335" @ GET /dnd/5e/items + +Request: GET /dnd/5e/items (10.0.38.3 via 2600:4040:..., Chrome/143 Opera GX Windows) +User: millennialdoomer + +Exception chain: + java.util.concurrent.ExecutionException + wraps → org.h2.jdbc.JdbcSQLException: Connection is broken: "java.net.ConnectException: Connection refused: datomic:4335" + wraps → java.net.ConnectException: Connection refused (Connection refused) + at datomic.sql/connect (sql.clj:16) ← deepest non-infra frame + ... 38 infrastructure frames suppressed (java.net.*, org.h2.*, org.apache.tomcat.*, datomic.kv_*) + +Interceptor context: + {:execution-id 1286, :stage :enter, :interceptor :orcpub.routes/item-list} +``` + +--- + +### Example 4 — `IllegalArgumentException: No matching clause` during character save + +**Timestamp:** 04:50:25 — 32 seconds *before* Example 3, same DB connection (`next-t 181480137`, same `db-id`). Same Datomic outage window. + +**What the exception is:** + +``` +java.lang.IllegalArgumentException: No matching clause: + at orcpub.routes/do-save-character (routes.clj:755) + orcpub.routes/save-character (routes.clj:761) + io.pedestal ... [infrastructure] + org.eclipse.jetty ... [infrastructure] +``` + +**What actually caused it — a secondary bug:** + +`do-save-character` (currently at `routes.clj:932`) has this pattern: + +```clojure +(catch clojure.lang.ExceptionInfo e + (let [data (ex-data e)] + (case (:error data) + :character-problems {:status 400 :body (:problems data)} + :not-user-character {:status 401 ...}))) + ; ← NO DEFAULT CLAUSE +``` + +The Datomic transactor-unavailable exception is an `ExceptionInfo` with `{:db/error :db.error/transactor-unavailable}` — note `:db/error`, not `:error`. So `(:error data)` returns `nil`. Clojure's `case` with no default clause throws `IllegalArgumentException: No matching clause: ` (empty, because `(str nil)` is `""`). + +**The Datomic outage caused a secondary application bug to fire.** The actual Datomic error was swallowed by the wrong catch block and transformed into a misleading `IllegalArgumentException`. Without this email (and the effort to trace it), the admin would have seen an obscure character-save crash with no obvious connection to the infrastructure failure already reported by Examples 2 and 3. + +**This is a separate fixable bug independent of the email improvements:** add a default `case` clause that re-throws: + +```clojure +(case (:error data) + :character-problems {:status 400 :body (:problems data)} + :not-user-character {:status 401 :body "You do not own this character"} + (throw e)) ; ← re-throw unrecognized ExceptionInfo +``` + +**New problems surfaced:** + +- **P17:** `:transit-params` contains the full parsed request body. For character saves, that's the entire character entity — deeply nested, including DB entity IDs, all equipment, stats, ability scores, class/race selections. ~5KB of user character data per email. Not credentials, but user data that has no business in admin emails. +- **P18:** `:content-length 5388` and `:content-type "application/transit+json"` confirm the email includes the full parsed transit body even for large POST requests. No size cap. + +**On "we still don't know WHY Datomic dropped":** + +Correct, and the error emails can never tell you. `send-error-email` fires at the HTTP request layer — it knows a request failed because Datomic was unreachable, but the transactor itself logs its own shutdown reason separately in `logs/datomic.log`. + +**However:** when the Datomic container drops, Docker restarts it and clears the container — `logs/datomic.log` is gone. **The error emails are the only post-mortem signal available.** This makes fixing them higher priority than previously assessed, and also means any Datomic-level diagnostics (restart count, OOM, OOD, etc.) must come from Docker/orchestration tooling, not the app. Out of scope for these improvements but worth tracking separately. + +--- + +## Pending examples + +*Paste additional error emails below as they come in. Each gets its own subsection with the same structure: raw body → what it tells us → any new problems identified → plan additions.* + +--- + +## Datomic crash root cause (from log analysis) + +### Logs examined +- `logs/datomic.1.log` — Feb 24 (64,695 lines) +- `logs/datomic.2.log` — Feb 25 +- `logs/datomic.3.log` — Feb 26 from 00:00 (the crash at 04:50 that generated the emails is past the visible excerpt) + +### Pattern — confirmed across all crashes + +Every crash follows the same sequence immediately before `"Critical failure, cannot continue: Heartbeat failed"`: + +``` +08:35:24 kv-cluster/create-val bufsize=74,458 msec=5,300 +08:35:24 kv-cluster/create-val bufsize=74,923 msec=5,440 +08:35:24 kv-cluster/create-val bufsize=1,394,358 msec=19,500 ← large write +08:35:39 transactor/heartbeat-failed cause=:timeout +08:35:39 ERROR Critical failure, cannot continue: Heartbeat failed +08:35:44 ActiveMQ Artemis stopped (uptime 7 days 19 hours) +08:37:23 Starting datomic:free://... ← Docker restart +08:38:01 System started ← recovery ~2.5 min after crash +``` + +**Root cause:** H2 write latency under concurrent load spikes (5–27 seconds per `kv-cluster/create-val`). Datomic's heartbeat must write to storage every 5 seconds. When a storage write saturates H2's I/O, the heartbeat misses its deadline and the transactor self-terminates with `cause: :timeout`. + +This is a known limitation of **Datomic Free + H2**: H2 is a single-file embedded database that cannot handle concurrent write contention. Under heavy character save traffic (large transit payloads — the 5 KB character from Example 4 becomes 400KB–5MB of kv-cluster segments), H2 latency spikes and the heartbeat dies. + +### Frequency + +| Log file | Date | Crashes observed | +|----------|------|-----------------| +| datomic.1.log | Feb 24 | 3 (08:35, 09:41, 21:21) | +| datomic.2.log | Feb 25 | 4+ (05:37, 06:56, 08:08, 09:19, plus more) | +| datomic.3.log | Feb 26 | ongoing (04:50 crash generated the example emails) | + +**This is not an occasional blip — Datomic is crashing multiple times per day.** + +### Why the container-restart wipes the log + +Docker restarts the Datomic container on crash. The container's log volume is written to rotated files (`datomic.N.log`) but the *current* container's live log is lost on kill. The rotated files are what we have. The restart cycle (crash → down ~1 min → restart → ready ~2 min) means 2–3 minutes of unavailability per crash. + +### Fix options (out of scope for this PR, but should be tracked) + +| Option | What it does | Complexity | +|--------|-------------|------------| +| Migrate to Datomic Pro + PostgreSQL | Replaces H2 with a proper concurrent database; eliminates the write-contention crash mode entirely | High — requires Datomic Pro license and storage migration | +| Tune H2 write-ahead log / connection pool | May reduce contention but doesn't eliminate it | Medium — requires Datomic Free config experimentation | +| Add Docker restart delay + health check | Avoids thundering-herd of app requests hitting a half-started transactor | Low — Docker Compose `healthcheck` config | +| Add `DATOMIC_TRANSACTOR_OPTS` memory tuning | More JVM heap → larger write buffers → fewer contention spikes | Low | + +The most important near-term mitigation is the **flood throttle** in the email fix: a Datomic outage currently generates one error email per in-flight request (potentially hundreds). With throttling, each distinct failure mode sends one email per 5 minutes. + +- [x] Fix missing `case` default in `do-save-character` catch block (`routes.clj:~947`) — **done** +- [ ] Fix bracket error in `email.clj:~390` (one extra `)`) — **in progress by another agent** +- [ ] Verify `email.clj` compiles clean (`lein check` or start REPL) +- [ ] Update `docs/ERROR_HANDLING.md` — see spec below +- [ ] Update `docs/email-system.md` — see spec below +- [ ] Smoke-test — see spec below +- [ ] Commit both files + docs to `dmv/hotfix-integrations` +- [ ] Sync commit to `breaking/` + +--- + +## Implementation guide (for the agent doing the work) + +### Current state of `email.clj` + +The rewrite of `send-error-email` has been applied but has a bracket imbalance at the closing of the function (~line 390). The intended final structure of the closing is: + +```clojure + nil)))))) ; catch / try / do / if / let / when +``` + +That is 6 closing parens after `nil`: +1. `)` closes `catch Exception` +2. `)` closes `try` +3. `)` closes `do` +4. `)` closes `if (throttled?)` +5. `)` closes `let [data-map ...]` +6. `)` closes `when (not-empty ...)` + +Do **not** add a 7th. Run `lein check` after fixing to confirm zero errors before proceeding. + +### `routes.clj` — already done + +The `case` default clause (`(throw e)`) is in place at `routes.clj:~947`. No further changes needed there. + +### `docs/ERROR_HANDLING.md` — what to change + +Find the "Error Notification" subsection under "Email Operations" and replace the description with: + +- Function: `email/send-error-email ctx ex` +- Triggered by: `service-error-handler` in `routes.clj` on any unhandled interceptor exception +- Subject format: `[AppName] ExceptionClassName: message-preview @ METHOD /path` +- Body sections: `=== Request ===` (scrubbed), `=== Exception ===` (cause chain + filtered frames), `=== Exception Data ===` (ex-data if present), `=== Interceptor Context ===` (Pedestal metadata) +- Scrubbed from request: all body params (`:json-params`, `:transit-params`, `:form-params`), credentials headers (`authorization`, `cookie`), Datomic objects (`:db`, `:conn`), Java object refs, `:identity` +- Throttle: one email per fingerprint per 5 minutes; duplicates logged as `INFO: Suppressed duplicate error email` + +### `docs/email-system.md` — what to change + +Update § 4 "Error Notification" (currently just "Called from exception handlers..."): + +- Add the scrubbed fields list (same as above) +- Add the throttle window (5 min) and log message +- Add a note that `EMAIL_ERRORS_TO` must be set; if unset, function is a no-op +- Add a note that `logs/datomic.log` is cleared on container restart — error emails are the only post-mortem signal for Datomic outages + +### Smoke test + +Since `EMAIL_ERRORS_TO` won't be set in dev, test the helper functions directly in a REPL: + +```clojure +(require '[orcpub.email :as email]) + +;; 1. Scrubbing — confirm no creds leak +(email/scrub-request {:uri "/login" + :request-method :post + :json-params {:username "foo" :password "secret"} + :headers {"authorization" "Token xyz" + "cookie" "cf_clearance=abc" + "user-agent" "Mozilla/5.0"} + :db (Object.) + :conn (Object.)}) +;; Expected: {:uri "/login", :request-method :post, :headers {"user-agent" "Mozilla/5.0"}} +;; Must NOT contain :json-params, :db, :conn, authorization, cookie + +;; 2. Subject line +(email/email-subject (Exception. "boom") {:request-method :get :uri "/test"}) +;; Expected: "[DMV] Exception: boom @ GET /test" + +;; 3. Throttle — second call suppressed +(let [ex (Exception. "test")] + (email/record-sent! (email/throttle-fingerprint ex)) + (email/throttled? (email/throttle-fingerprint ex))) +;; Expected: true (a Long timestamp, truthy) +``` + +Note: `scrub-request`, `email-subject`, `throttle-fingerprint`, `throttled?`, and `record-sent!` are `defn-` (private). Either make them `defn` temporarily for testing, or test via `#'orcpub.email/scrub-request`. + +### Commit message + +``` +fix: improve error notification emails and fix save-character exception masking + +- send-error-email: scrub credentials/cookies/body params from request dump +- send-error-email: render filtered stack trace (orcpub.* frames only, + fallback to deepest non-infra frame) +- send-error-email: walk full cause chain +- send-error-email: readable subject line with exception type + route +- send-error-email: 5-minute flood throttle per error fingerprint +- do-save-character: add case default clause to re-throw unrecognised + ExceptionInfo (previously masked Datomic errors as IllegalArgumentException) + +Fixes P1-P18 documented in docs/error-email-improvements.md +``` diff --git a/docs/kb/README.md b/docs/kb/README.md new file mode 100644 index 000000000..dad7d896f --- /dev/null +++ b/docs/kb/README.md @@ -0,0 +1,18 @@ +# OrcPub Agent Knowledge Base + +Verified, research-backed findings from in-depth investigations. Each document is sourced from +direct inspection of code, logs, or authoritative references. Speculation is marked +**⚠️ UNVALIDATED SPECULATION** and must not be treated as fact without further verification. + +## Index + +| Document | Topic | Source quality | +|----------|-------|---------------| +| [datomic-crash-analysis.md](datomic-crash-analysis.md) | Datomic transactor crashes — root cause, frequency, fix options | High — direct log analysis from `logs/datomic.{1,2,3}.log` | + +## Contribution rules + +- Only add findings you can cite directly (log lines, code lines, benchmark results, official docs). +- If you are reasoning from circumstantial evidence, mark the entire paragraph with **⚠️ UNVALIDATED SPECULATION — [brief rationale]**. +- Include the date the analysis was done and the artifact(s) it was based on. +- Do not remove speculation flags — if something is later verified, replace the flag with a **✅ VERIFIED — [how]** marker and update the text. diff --git a/docs/kb/datomic-crash-analysis.md b/docs/kb/datomic-crash-analysis.md new file mode 100644 index 000000000..7b21c5cea --- /dev/null +++ b/docs/kb/datomic-crash-analysis.md @@ -0,0 +1,204 @@ +# Datomic Transactor Crash Analysis + +**Analyzed:** 2026-02-26 +**Artifacts:** `logs/datomic.1.log` (64,695 lines, Feb 24), `logs/datomic.2.log` (Feb 25), `logs/datomic.3.log` (Feb 26 from 00:00) +**Branch at time of analysis:** `dmv/hotfix-integrations` + +--- + +## Active transactor configuration (verified from log startup lines) + +``` +heartbeatIntervalMsec=5000 +writeConcurrency=4 +memoryIndexMax=256m +memoryIndexThreshold=32m +txTimeoutMsec=10000 +``` + +Source: the transactor logs its own config on startup. These values were read directly +from the log startup block in `logs/datomic.1.log`. + +--- + +## Crash mechanism — verified + +Every crash in all three log files follows an identical sequence. Example from +`datomic.1.log` 2026-02-24 08:35: + +``` +08:35:19 kv-cluster/create-val bufsize=74,458 msec=5,300 (tid 1212) +08:35:19 kv-cluster/create-val bufsize=74,923 msec=5,440 (tid 1213) +... + ← 14-second gap; no log output of any kind → + +08:35:38 kv-cluster/create-val bufsize=1,394,358 msec=19,500 (tid 982) +08:35:39 transactor/heartbeat-failed cause=:timeout +08:35:39 ERROR Critical failure, cannot continue: Heartbeat failed +08:35:44 ActiveMQ Artemis stopped (uptime 7 days 19 hours) +08:35:46 kv-cluster/create-val bufsize=5,795,494 msec=27,100 (tid 1195) ← still draining +08:37:23 Starting datomic:free://... ← Docker restart +08:38:01 System started ← recovery +``` + +**What is happening:** + +1. The memoryIndex threshold triggers a segment flush. Multiple write threads + (up to `writeConcurrency=4`) begin writing `kv-cluster` segments to H2. +2. H2 is a single-writer embedded database. Concurrent writes serialize on an + exclusive file lock. When one large write is in progress, all other writes + — including the heartbeat's own timestamp write — queue behind it. +3. The heartbeat thread (tid 21) fires every `heartbeatIntervalMsec=5000` ms. + Its write is blocked by the H2 lock. After approximately 3× the interval + (~15 seconds, confirmed: heartbeat fired at 08:35:24.377, failed at + 08:35:39.350 = exactly 15 seconds), the transactor declares itself dead + and self-terminates. +4. Docker restarts the container. Recovery takes ~2.5 minutes. + +**The direct killer is H2 write serialization, not the writes themselves.** +A single 19.5-second write blocked the heartbeat from acquiring the H2 lock +for longer than the 15-second failure threshold. + +--- + +## GC role — verified not sufficient alone + +Datomic logs every JVM GC event via `datomic.log-gc`. All observed GC events are: + +``` +G1 Young Generation / end of minor GC / G1 Evacuation Pause +``` + +GC pause durations near the 08:35 crash: +- 08:30:58 — **1380 ms** (largest observed in entire log) +- 08:31:18 — 331 ms +- 08:31:31 — 364 ms +- 08:31:42 — 330 ms +- ...continuing through 08:33:47 at intervals of 5–15 seconds +- **Last GC before crash: 08:33:47 (295 ms) — 1 minute 52 seconds before crash** +- **No GC events between 08:33:47 and crash at 08:35:39** + +The largest GC pause observed (1380 ms) is well below the 15-second heartbeat +failure threshold. GC alone cannot kill the transactor. + +**⚠️ UNVALIDATED SPECULATION — [plausible mechanism, not directly observable in logs]:** +The GC storm between 08:30 and 08:33 (minor GC every ~10 seconds, up to 1.4s pauses) +likely reflects the memoryIndex flush churning through large object graphs. Each GC +pause interrupts the H2 write threads mid-operation. Because H2 holds file locks across +the full write duration (not just during active I/O), a write that would take 2–3s under +no-GC conditions may stretch to 19–27s when repeatedly interrupted by 300–1400ms STW +pauses. This is the probable mechanism connecting the GC activity to the anomalous write +latency, but it cannot be confirmed from logs alone — it would require JVM flight +recorder data or an H2 lock trace. + +--- + +## Crash frequency — verified + +| Log file | Date | Crash times (UTC) | Crashes | +|----------|------|-------------------|---------| +| datomic.1.log | Feb 24 | 08:35, 09:41, 21:21 | 3 | +| datomic.2.log | Feb 25 | 05:37, 06:56, 08:08, 09:19 + more | 4+ | +| datomic.3.log | Feb 26 | 04:50 (generated the email examples) | 1+ (log starts at 00:00) | + +This is not an occasional blip. The transactor is crashing multiple times daily with +roughly 60–90 minute intervals between crashes during high-activity windows. + +--- + +## Schema noHistory status — verified, no action needed + +`src/clj/orcpub/db/schema.clj` already applies `:db/noHistory true` to all high-churn +gameplay attributes: + +- `::char5e/current-hit-points` +- `::char5e/notes` +- `::char5e/prepared-spells` / `::char5e/prepared-spells-by-class` +- `::char5e/worn-armor`, `::char5e/wielded-shield`, `::char5e/main-hand-weapon`, `::char5e/off-hand-weapon` +- All spell slot usage (`::char5e/features-used`, `::spells5e/slots-used`, all slot-level keys) +- All time-unit usage trackers (`::units5e/minute`, `::units5e/round`, etc.) +- All of `magic-item-schema` and `weapon-schema` + +Removing history from additional attributes would not affect the crash. The crash +is caused by segment *flush* volume (memoryIndex → H2 kv-cluster writes), not by +the presence of historical datoms in those writes. + +--- + +## `writeConcurrency=4` is actively harmful with H2 + +H2 cannot parallelize writes — it serializes them internally on a file lock. +`writeConcurrency=4` causes 4 threads to contend simultaneously for that lock, +meaning all 4 wait while whichever one holds the lock makes slow progress. This +amplifies total write latency without increasing throughput. + +**⚠️ UNVALIDATED SPECULATION — [well-reasoned but untested in this codebase]:** +Reducing `writeConcurrency` to `1` should eliminate the multi-thread H2 contention +and reduce the probability of a single write holding the lock long enough to starve +the heartbeat. However, this has not been tested. It may reduce throughput under +bursty write loads if the bottleneck shifts from contention to raw H2 sequential I/O. +If the total volume of writes during a flush exceeds what a single thread can process +within the heartbeat window, crashes could still occur — just less frequently. + +Config to try: +``` +datomic.writeConcurrency=1 +``` + +This is a transactor properties file change — no code change required. + +--- + +## Increasing heartbeat interval — not recommended + +Setting `heartbeatIntervalMsec` to e.g. 60000 (1 minute) would raise the failure +threshold to ~3 minutes, which is longer than the observed 19.5-second worst-case +write. This would stop the self-termination. + +**This is not a fix.** During the same write-backpressure window, user transactions +queue behind the H2 lock with a `txTimeoutMsec=10000` (10s) timeout. Users would see +transaction timeout errors regardless. Raising the heartbeat masks the infrastructure +signal (crash + admin email) while leaving the user-visible failure intact — and makes +the system harder to monitor. + +--- + +## Recovery time + +Each crash results in approximately **2–3 minutes of complete unavailability**: +- Transactor self-terminates (~1s) +- Docker detects exit and restarts container (~10–30s depending on health check config) +- Datomic transactor starts, initializes storage, begins accepting peer connections (~90–120s) +- Peer reconnects + +During this window, all requests that require Datomic (all authenticated write routes, +all read routes that aren't cached in the peer) will fail with +`transactor-unavailable` or `Connection refused: datomic:4335`. + +--- + +## Fix options + +| Option | Severs which link in the failure chain | Verified effectiveness | Complexity | +|--------|----------------------------------------|----------------------|------------| +| `writeConcurrency=1` | Eliminates concurrent H2 lock contention | ⚠️ UNVALIDATED SPECULATION — should help, see above | Low — config only | +| `memoryIndexMax=512m` or higher | Fewer flushes → fewer contention windows | ⚠️ UNVALIDATED SPECULATION — trades memory for frequency | Low — config only | +| Migrate to Datomic Pro + PostgreSQL | Replaces H2 entirely; Postgres handles concurrent writers and is not subject to single-file lock contention | Established — Datomic Pro + Postgres is the documented production path | High — storage migration required | +| Application-layer circuit breaker | Detects slow `d/transact` calls (>2s) and returns 503 on write endpoints; reads remain up | ⚠️ UNVALIDATED SPECULATION — requires careful implementation; does not prevent crashes | Medium — application code | +| `heartbeatIntervalMsec=15000–60000` | Prevents self-termination | Verified would stop crashes | Low — config only; **NOT RECOMMENDED** — masks signal, see above | +| Docker health check + restart delay | Avoids thundering-herd of app reconnects against a half-started transactor | Verified useful as secondary measure | Low — Docker Compose config | + +**Recommended path:** `writeConcurrency=1` as immediate mitigation, Postgres migration +as the permanent fix. See TODO entry: [Investigate Datomic + Postgres migration path](../TODO.md). + +--- + +## What the error emails reveal about this (relation to P1–P18 analysis) + +Each Datomic crash generates one error email **per in-flight request at the time of +crash**. At peak traffic this could be dozens to hundreds of emails per minute. +The flood throttle in the `send-error-email` rewrite (P5) is therefore especially +important for this failure class — without it, a single crash event could saturate +the admin inbox and trigger alert fatigue that causes real bugs to be missed. + +See [error-email-improvements.md](../error-email-improvements.md) for full analysis. diff --git a/docs/migration/dev-tooling.md b/docs/migration/dev-tooling.md index 73a46190c..21ac66cb7 100644 --- a/docs/migration/dev-tooling.md +++ b/docs/migration/dev-tooling.md @@ -161,7 +161,7 @@ Single source of truth for all runtime configuration. Reads environment variable | `(config/get-datomic-uri)` | `DATOMIC_URL` env or `"datomic:dev://localhost:4334/orcpub"` | | `(config/get-csp-policy)` | `CSP_POLICY` env or `"strict"` | | `(config/strict-csp?)` | `true` when CSP policy is strict | -| `(config/dev-mode?)` | `true` when `DEV_MODE` env is truthy | +| `(config/dev-mode?)` | `true` when `DEV_MODE` env is the string `"true"` (case-insensitive) | | `(config/get-secure-headers-config)` | Pedestal secure-headers map based on CSP policy | Used by: `system.clj`, `pedestal.clj`, `user.clj` From bd7174ccdc63a4190adb10ef643a8e067dbec44f Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 26 Feb 2026 21:55:33 +0000 Subject: [PATCH 27/50] =?UTF-8?q?fix:=20mobile=20header=20tab=20flyout=20?= =?UTF-8?q?=E2=80=94=20click=20handler=20was=20returning=20fn,=20not=20cal?= =?UTF-8?q?ling=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit on-click had #(swap! hovered? not) which creates a new function and discards it. Changed to (swap! hovered? not) which actually toggles. Also: guard mouseenter/mouseleave with when-not mobile? to prevent synthetic mouse events from immediately closing the flyout on touch. Menu items now stopPropagation + close flyout on tap. --- src/cljs/orcpub/dnd/e5/views.cljs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index e665f2a9c..012e126fc 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -262,11 +262,12 @@ (fn [title icon on-click disabled active device-type & buttons] (let [mobile? (= :mobile device-type)] [:div.f-w-b.f-s-14.t-a-c.header-tab.m-l-2.m-r-2.posn-rel - {:on-click (fn [e] (if (seq buttons) - #(swap! hovered? not) - (on-click e))) - :on-mouse-enter #(reset! hovered? true) - :on-mouse-leave #(reset! hovered? false) + {:on-click (fn [e] + (if (seq buttons) + (swap! hovered? not) + (on-click e))) + :on-mouse-enter #(when-not mobile? (reset! hovered? true)) + :on-mouse-leave #(when-not mobile? (reset! hovered? false)) :style (when active active-style) :class (str (if disabled "disabled" "pointer") " " @@ -288,7 +289,10 @@ (let [current-route @(subscribe [:route])] {:style (when (or (= route current-route) (= route (get current-route :handler))) active-style) - :on-click (route-handler route)}) + :on-click (fn [e] + (.stopPropagation e) + (reset! hovered? false) + ((route-handler route) e))}) name]) buttons))])])))) From 89fe5e8b55b498e1069644e941ceac2a05d50ef7 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Fri, 27 Feb 2026 14:57:08 +0000 Subject: [PATCH 28/50] fix: clear loading overlay on forced login redirect route-to-login now sets :loading false in app-db. Prevents the loading overlay from covering the login page when multiple parallel 401 responses race to set/clear the boolean flag. --- src/cljs/orcpub/dnd/e5/events.cljs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 68885be31..77862af85 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -4546,8 +4546,10 @@ (reg-event-fx :route-to-login - (fn [_ _] - {:dispatch [:route routes/login-page-route {:secure? true :no-return? true}]})) + (fn [{:keys [db]} _] + ;; Force loading off — multiple parallel 401s can leave the overlay stuck + {:db (assoc db :loading false) + :dispatch [:route routes/login-page-route {:secure? true :no-return? true}]})) (reg-event-db ::char5e/show-options From 4ebc1220c23168d1a0484229a9265f0570ebf1a2 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Fri, 27 Feb 2026 15:00:52 +0000 Subject: [PATCH 29/50] =?UTF-8?q?fix:=20loading=20counter=20instead=20of?= =?UTF-8?q?=20boolean=20=E2=80=94=20parallel=20HTTP=20calls=20no=20longer?= =?UTF-8?q?=20fight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set-loading now increments/decrements a counter. Overlay shows when > 0. Multiple reg-sub-raw go blocks firing simultaneously (characters, parties, folders, items) each toggle loading independently without stomping each other. route-to-login resets counter to 0. --- src/cljs/orcpub/dnd/e5/events.cljs | 15 +++++++++++---- src/cljs/orcpub/dnd/e5/views.cljs | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 77862af85..50a904f29 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -1631,8 +1631,15 @@ :set-active-tabs set-active-tabs) -(defn set-loading [db [_ v]] - (assoc db :loading v)) +(defn set-loading + "Loading is a counter, not a boolean. true increments, false decrements. + Overlay shows when > 0. Multiple parallel HTTP calls no longer fight." + [db [_ v]] + (let [current (or (:loading db) 0)] + (assoc db :loading + (if v + (inc current) + (max 0 (dec current)))))) (reg-event-db :set-loading @@ -4547,8 +4554,8 @@ (reg-event-fx :route-to-login (fn [{:keys [db]} _] - ;; Force loading off — multiple parallel 401s can leave the overlay stuck - {:db (assoc db :loading false) + ;; Reset loading counter — multiple parallel 401s can leave the overlay stuck + {:db (assoc db :loading 0) :dispatch [:route routes/login-page-route {:secure? true :no-return? true}]})) (reg-event-db diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 012e126fc..2c83b759a 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -1477,7 +1477,7 @@ (fn [e]))} (when-not frame? [download-form]) - (when @(subscribe [:loading]) + (when (pos? (or @(subscribe [:loading]) 0)) [:div {:style loading-style} [:div.flex.justify-cont-s-a.align-items-c.h-100-p [:img.h-200.w-200.m-t-200 {:src "/image/spiral.gif"}]]]) From 12ba1e72c28b014f615d0ac9682da58fef1ac466 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Fri, 27 Feb 2026 18:56:18 +0000 Subject: [PATCH 30/50] fix: PDFBox 3.x API migration for spell card generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three breaking PDFBox 2.x → 3.x API changes were crashing spell card generation silently (caught by bare catch in add-spell-cards!), leaving a blank page in exported PDFs: 1. setStrokingColor(225,225,225) — 3.x float overload requires 0.0-1.0 range, not 0-255. Reflection dispatched to float method → validation error. Fixed by normalizing to (/ 225.0 255.0). 2. drawLine(x1,y1,x2,y2) — removed in 3.x. Replaced with moveTo/lineTo/stroke sequence. 3. moveTextPositionByAmount → newLineAtOffset, drawString → showText. All setNonStrokingColor/setStrokingColor calls now use explicit (float) casts to prevent reflection from hitting the wrong overload. --- src/clj/orcpub/pdf.clj | 43 ++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/clj/orcpub/pdf.clj b/src/clj/orcpub/pdf.clj index 0f413856e..97a89b565 100644 --- a/src/clj/orcpub/pdf.clj +++ b/src/clj/orcpub/pdf.clj @@ -262,11 +262,11 @@ fitting-lines (vec (take max-lines lines))] (.beginText cs) (.setFont cs font font-size) - (.moveTextPositionByAmount cs units-x units-y) + (.newLineAtOffset cs units-x units-y) (doseq [i (range (count fitting-lines))] (let [line (get fitting-lines i)] - (.moveTextPositionByAmount cs 0 (- leading)) - (.drawString cs line))) + (.newLineAtOffset cs 0 (- leading)) + (.showText cs line))) (.endText cs) (vec (drop max-lines lines)))) @@ -274,8 +274,10 @@ (let [lines (split-lines text font font-size width)] (draw-lines-to-box cs lines font font-size x y height))) -(defn set-text-color [cs r g b] - (.setNonStrokingColor cs r g b)) +(defn set-text-color + "Set text (non-stroking) color. Values must be 0.0-1.0 floats (PDFBox 3.x)." + [cs r g b] + (.setNonStrokingColor cs (float r) (float g) (float b))) (defn draw-text [cs text font font-size x y & [color]] (when text @@ -285,8 +287,8 @@ (.setFont cs font font-size) (when color (apply set-text-color cs color)) - (.moveTextPositionByAmount cs units-x units-y) - (.drawString cs (if (keyword? text) (common/safe-name text) text)) + (.newLineAtOffset cs units-x units-y) + (.showText cs (if (keyword? text) (common/safe-name text) text)) (when color (set-text-color cs 0 0 0)) (.endText cs)))) @@ -294,8 +296,12 @@ (defn draw-text-from-top [cs text font font-size x y & [color]] (draw-text cs text font font-size x (- 11.0 y) color)) -(defn draw-line [cs start-x start-y end-x end-y] - (.drawLine cs start-x start-y end-x end-y)) +(defn draw-line + "Draw a line. PDFBox 3.x removed drawLine — use moveTo/lineTo/stroke." + [cs start-x start-y end-x end-y] + (.moveTo cs (float start-x) (float start-y)) + (.lineTo cs (float end-x) (float end-y)) + (.stroke cs)) (defn inches-to-units [inches] (float (* inches 72))) @@ -303,7 +309,9 @@ (defn draw-line-in [cs & coords] (apply draw-line cs (map inches-to-units coords))) -(defn draw-grid [cs box-width box-height] +(defn draw-grid + "Draw the spell card grid. Light gray lines for card boundaries." + [cs box-width box-height] (let [num-boxes-x (int (/ 8.5 box-width)) num-boxes-y (int (/ 11.0 box-height)) total-width (* num-boxes-x box-width) @@ -311,12 +319,15 @@ remaining-width (- 8.5 total-width) margin-x (/ remaining-width 2) remaining-height (- 11.0 total-height) - margin-y (/ remaining-height 2)] - (.setStrokingColor cs 225 225 225) + margin-y (/ remaining-height 2) + ;; PDFBox 3.x: setStrokingColor(float,float,float) requires 0.0-1.0 range + ;; (PDFBox 2.x accepted 0-255 integers via a separate overload) + light-gray (float (/ 225.0 255.0))] + (.setStrokingColor cs light-gray light-gray light-gray) (doseq [i (range (inc num-boxes-x))] (let [x (+ margin-x (* box-width i))] (draw-line-in cs - x + x margin-y x (+ margin-y total-height)))) @@ -327,7 +338,7 @@ y (+ margin-x total-width) y))) - (.setStrokingColor cs 0 0 0))) + (.setStrokingColor cs (float 0) (float 0) (float 0)))) (defn spell-school-level [{:keys [level school]} class-nm] (let [school-str (if school (s/capitalize school) "Unknown")] @@ -343,14 +354,14 @@ (- 11 y 0.12) 0.25 0.25)) - (.setNonStrokingColor cs 0 0 0) + (.setNonStrokingColor cs (float 0) (float 0) (float 0)) (draw-text cs value HELVETICA_BOLD_OBLIQUE 8 x (- y 0.07)) - (.setNonStrokingColor cs 0 0 0)) + (.setNonStrokingColor cs (float 0) (float 0) (float 0))) (defn abbreviate-times [time] (-> time From 4fa1519182e05f92c11b4a2bf34ac863fe38b399 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Sun, 1 Mar 2026 01:20:58 +0000 Subject: [PATCH 31/50] =?UTF-8?q?fix:=20verify-user-session=20checked=20wr?= =?UTF-8?q?ong=20token=20path=20=E2=80=94=20stale=20sessions=20caused=20lo?= =?UTF-8?q?ading=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :verify-user-session (startup auth check) tested (:token (:user db)) instead of (:token (:user-data db)). This meant it never fired, leaving expired tokens in app-db. When reg-sub-raw subs later hit 401s, the loading overlay flashed before redirect to login. Also reset loading counter on 401 in the check, and fixed duplicate comment. --- src/cljs/orcpub/dnd/e5/events.cljs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 50a904f29..6bc7b9fa1 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -1601,18 +1601,18 @@ (fn [db [_ user-data]] (update db :user-data dissoc :user-data :token))) -;; Replaces top-level @(subscribe [:user false]) in core.cljs — that was a -;; Replaces top-level @(subscribe [:user false]) in core.cljs — that was a -;; side-effect-only subscribe used to trigger an HTTP auth check on app startup. +;; Startup auth check — validates stored token on app load (core.cljs). +;; Clears stale sessions before reg-sub-raw subs fire HTTP with expired tokens. (reg-event-fx :verify-user-session (fn [{:keys [db]} _] - (if (and (:user db) (:token (:user db))) + (if (:token (:user-data db)) (do (go (let [response (<! (http/get (url-for-route routes/user-route) {:headers (authorization-headers db)}))] (case (:status response) 200 nil - 401 (dispatch [:clear-login]) + 401 (do (dispatch [:clear-login]) + (dispatch [:set-loading false])) nil))) {}) {}))) From cda2d7788122d695e684466dc57f03e759896ec9 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Sun, 1 Mar 2026 01:52:40 +0000 Subject: [PATCH 32/50] fix: share-links path-for paren bug + PDFBox 3.x loadPDF InputStream fix Cherry-picked from dmv/hotfix-integrations (8627b598). fork/integrations.cljs conflict resolved: kept breaking/ version (stubs). --- src/clj/orcpub/routes.clj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 5f9c3a25d..601527ad6 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -52,7 +52,7 @@ [ring.util.request :as req]) ;; PDFBox 3.x: Use Loader class instead of PDDocument.load() static method ;; OLD (2.x): (PDDocument/load input-stream) - ;; NEW (3.x): (Loader/loadPDF input-stream) + ;; NEW (3.x): (Loader/loadPDF byte-array) — does NOT accept InputStream ;; ;; Import syntax notes for Clojure newcomers: ;; - (org.apache.pdfbox.pdmodel PDDocument PDPage) imports multiple classes from one package @@ -645,8 +645,9 @@ chrome? (re-matches #".*Chrome.*" user-agent) filename (str player-name " - " character-name " - " class-level ".pdf")] - ;; PDFBox 3.x: Loader/loadPDF replaces the deprecated PDDocument/load - (with-open [doc (Loader/loadPDF input)] + ;; PDFBox 3.x: Loader/loadPDF accepts byte[], File, or RandomAccessRead — + ;; NOT InputStream. Read the resource stream into a byte array first. + (with-open [doc (Loader/loadPDF (.readAllBytes input))] (pdf/write-fields! doc fields (not chrome?) font-sizes) (when (and print-spell-cards? (seq spells-known)) (add-spell-cards! doc spells-known spell-save-dcs spell-attack-mods custom-spells print-spell-card-dc-mod?)) From 6b39fc84ca8ca2bade16268713db6eabfcaff3e2 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Sun, 1 Mar 2026 04:08:56 +0000 Subject: [PATCH 33/50] =?UTF-8?q?fix:=20build-date=20uses=20UTC=20in=20Cod?= =?UTF-8?q?espaces=20=E2=80=94=20shows=20wrong=20date=20for=20CST=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalDate/now defaults to JVM timezone (UTC in Codespaces). A build at 10 PM CST reports as the next day. Macro now reads TZ env var if set, falls back to JVM default. Added TZ=America/Chicago to .env, .env.example, and Dockerfile (build + runtime stages with tzdata package). --- .env.example | 1 + docker/Dockerfile | 10 +++++++++- src/cljs/orcpub/ver.cljc | 9 +++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index c7f1c38b0..5fb056f47 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ # --- Application --- PORT=8890 +TZ=America/Chicago # Image tag for docker-compose.yaml (pre-built images only, ignored by build compose) # ORCPUB_TAG=release-v2.5.0.27 diff --git a/docker/Dockerfile b/docker/Dockerfile index d6d114c97..ac86176f8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -71,6 +71,11 @@ RUN lein deps ADD ./ /orcpub +# Timezone for build-date macro and runtime logging. +# Override via docker build --build-arg TZ=... or .env +ARG TZ=America/Chicago +ENV TZ=${TZ} + # Three-step build: CLJS, AOT compile, uberjar packaging. # # lein's compile task spawns a subprocess that hangs in no-TTY (Docker/CI) @@ -99,8 +104,11 @@ RUN timeout 600 lein with-profile uberjar,uberjar-package uberjar; \ # Alpine runner — BusyBox wget handles healthchecks (use -q --spider, not GNU flags) FROM eclipse-temurin:21-jre-alpine-3.22 AS app +ARG TZ=America/Chicago +ENV TZ=${TZ} + # PDFBox requires fontconfig, fonts, and lcms2 for PDF character sheet generation -RUN apk add --no-cache fontconfig ttf-dejavu freetype lcms2 +RUN apk add --no-cache fontconfig ttf-dejavu freetype lcms2 tzdata COPY --from=app-builder /orcpub/target/orcpub.jar /orcpub.jar diff --git a/src/cljs/orcpub/ver.cljc b/src/cljs/orcpub/ver.cljc index 00edd5741..3610839fd 100644 --- a/src/cljs/orcpub/ver.cljc +++ b/src/cljs/orcpub/ver.cljc @@ -4,11 +4,12 @@ #?(:clj (defmacro build-date "Captures the current date at compile time (MM-dd-yyyy). - In CLJS, the macro runs on the JVM during compilation, - so the date reflects when the JS was built." + In CLJS, the macro runs on the JVM during compilation. + Uses TZ env var if set, falls back to JVM default timezone." [] - (.format (java.time.LocalDate/now) - (java.time.format.DateTimeFormatter/ofPattern "MM-dd-yyyy")))) + (let [tz (or (System/getenv "TZ") (str (java.time.ZoneId/systemDefault)))] + (.format (java.time.LocalDate/now (java.time.ZoneId/of tz)) + (java.time.format.DateTimeFormatter/ofPattern "MM-dd-yyyy"))))) (defn version [] "2.4.0.28") (defn date [] (build-date)) From 214517a588c9e396f0b3b5d17d41b84a489b4866 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Sun, 1 Mar 2026 07:14:59 +0000 Subject: [PATCH 34/50] chore: merge docker-compose-build.yaml into docker-compose.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modern Docker Compose supports build: and image: in the same service. Two-file split (from 2019) is dead weight — admin builds with bare docker build, swarm ignores build: directives, nobody uses the separate file. Single file now handles both pull and build-from-source. --- .github/workflows/docker-integration.yml | 4 +- README.md | 6 +- docker-compose-build.yaml | 77 ------------------------ docker-compose.yaml | 16 +++-- docker-migrate.sh | 6 +- docker-setup.sh | 3 +- docker/Dockerfile | 2 +- docs/DOCKER.md | 25 +++----- docs/docker-user-management.md | 4 +- docs/migration/datomic-data-migration.md | 10 ++- 10 files changed, 34 insertions(+), 119 deletions(-) delete mode 100644 docker-compose-build.yaml diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index d16f0b058..37552e548 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -3,7 +3,7 @@ # # Java 8 (legacy): Pulls pre-built images from Docker Hub — skipped gracefully # when images are unavailable. -# Java 21 (modern): Builds from source using docker-compose-build.yaml with +# Java 21 (modern): Builds from source using docker-compose.yaml (--build) with # Datomic Pro and eclipse-temurin:21. name: Docker Integration Test @@ -38,7 +38,7 @@ jobs: if [ -f "dev.cljs.edn" ]; then echo "Detected: Java 21 / Datomic Pro / figwheel-main → build from source" echo "build-mode=build" >> $GITHUB_OUTPUT - echo "compose-file=docker-compose-build.yaml" >> $GITHUB_OUTPUT + echo "compose-file=docker-compose.yaml" >> $GITHUB_OUTPUT echo "stack-label=Java 21 / Datomic Pro (build)" >> $GITHUB_OUTPUT else echo "Detected: Java 8 / Datomic Free / cljsbuild → pull pre-built" diff --git a/README.md b/README.md index 49d408b6f..1ff5e1ab4 100644 --- a/README.md +++ b/README.md @@ -321,8 +321,7 @@ Visit `https://localhost` when running. To build from source instead of pulling images: ```bash -docker compose -f docker-compose-build.yaml build -docker compose -f docker-compose-build.yaml up -d +docker compose up --build -d ``` For environment variable details, see [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md). @@ -349,8 +348,7 @@ The storage protocols (`datomic:free://` vs `datomic:dev://`) use different form ```bash ./docker-migrate.sh backup # With old stack running docker compose down -docker compose -f docker-compose-build.yaml build -docker compose -f docker-compose-build.yaml up -d +docker compose up --build -d ./docker-migrate.sh restore # After new stack is healthy ./docker-migrate.sh verify ``` diff --git a/docker-compose-build.yaml b/docker-compose-build.yaml deleted file mode 100644 index 186b85c87..000000000 --- a/docker-compose-build.yaml +++ /dev/null @@ -1,77 +0,0 @@ ---- -services: - orcpub: - image: orcpub-app - build: - context: . - dockerfile: docker/Dockerfile - target: app - environment: - PORT: ${PORT:-8890} - EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} - EMAIL_ACCESS_KEY: ${EMAIL_ACCESS_KEY:-} - EMAIL_SECRET_KEY: ${EMAIL_SECRET_KEY:-} - EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587} - EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} - EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} - EMAIL_SSL: ${EMAIL_SSL:-FALSE} - EMAIL_TLS: ${EMAIL_TLS:-FALSE} - # Datomic Pro with dev storage protocol (required for Java 21 support) - DATOMIC_URL: ${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub?password=change-me} - SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} - CSP_POLICY: ${CSP_POLICY:-strict} - DEV_MODE: ${DEV_MODE:-} - LOAD_HOMEBREW_URL: ${LOAD_HOMEBREW_URL:-} - depends_on: - datomic: - condition: service_healthy - healthcheck: - # BusyBox wget (Alpine): only -q and --spider are supported. - # Use 127.0.0.1 (not localhost) to avoid IPv4/IPv6 ambiguity. - # /health returns 200 OK — lighter than / which renders the full SPA page. - test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:${PORT:-8890}/health"] - interval: 10s - timeout: 5s - retries: 30 - start_period: 60s - restart: always - datomic: - image: orcpub-datomic - build: - context: . - dockerfile: docker/Dockerfile - target: transactor - environment: - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} - DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} - ALT_HOST: ${ALT_HOST:-127.0.0.1} - ENCRYPT_CHANNEL: ${ENCRYPT_CHANNEL:-true} - volumes: - - ./data:/data - - ./logs:/log - - ./backups:/backups - healthcheck: - test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] - interval: 5s - timeout: 3s - retries: 30 - start_period: 40s - restart: always - web: - image: nginx:alpine - ports: - - "80:80" - - "443:443" - environment: - # nginx:alpine runs envsubst on /etc/nginx/templates/*.template at startup. - # Only defined env vars are substituted — nginx's own $host, $scheme, etc. are safe. - ORCPUB_PORT: ${PORT:-8890} - volumes: - - ./deploy/nginx.conf.template:/etc/nginx/templates/default.conf.template - - ./deploy/snakeoil.crt:/etc/nginx/snakeoil.crt - - ./deploy/snakeoil.key:/etc/nginx/snakeoil.key - - ./deploy/homebrew/:/usr/share/nginx/html/homebrew/ - depends_on: - orcpub: - condition: service_healthy - restart: always diff --git a/docker-compose.yaml b/docker-compose.yaml index 94fdc3cea..6735aade2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,13 @@ --- services: orcpub: - # No :latest tag on Docker Hub — pin to newest published version. - # Override with ORCPUB_TAG env var for custom builds. - image: orcpub/orcpub:${ORCPUB_TAG:-release-v2.5.0.27} + # Pre-built: docker compose up (pulls from Docker Hub) + # Local build: docker compose up --build (builds from Dockerfile) + image: ${ORCPUB_IMAGE:-orcpub/orcpub:release-v2.5.0.27} + build: + context: . + dockerfile: docker/Dockerfile + target: app environment: PORT: ${PORT:-8890} EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} @@ -34,7 +38,11 @@ services: start_period: 60s restart: always datomic: - image: orcpub/datomic:latest + image: ${DATOMIC_IMAGE:-orcpub/datomic:latest} + build: + context: . + dockerfile: docker/Dockerfile + target: transactor environment: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} diff --git a/docker-migrate.sh b/docker-migrate.sh index c3a49d35e..d056f58bc 100755 --- a/docker-migrate.sh +++ b/docker-migrate.sh @@ -72,7 +72,7 @@ Usage: ./docker-migrate.sh full Guided full migration (backup → swap → restore) Options (must come BEFORE the command): - --compose-yaml <file> Override compose file for rebuild (default: docker-compose-build.yaml) + --compose-yaml <file> Override compose file for rebuild (default: docker-compose.yaml) --old-uri <uri> Override source database URI detection --new-uri <uri> Override target database URI detection --help Show this help @@ -81,7 +81,7 @@ Examples: # Step-by-step (recommended for large databases) ./docker-migrate.sh backup # With old stack running docker compose down - docker compose -f docker-compose-build.yaml up -d + docker compose -f docker-compose.yaml up -d ./docker-migrate.sh restore # After new stack is healthy ./docker-migrate.sh verify # Verify backup integrity @@ -403,7 +403,7 @@ do_full() { # Use a distinct variable name to avoid colliding with Docker Compose's # COMPOSE_FILE env var (which changes docker compose's behavior globally). - local compose_yaml="${COMPOSE_YAML:-docker-compose-build.yaml}" + local compose_yaml="${COMPOSE_YAML:-docker-compose.yaml}" info "New compose file: $compose_yaml" echo "" diff --git a/docker-setup.sh b/docker-setup.sh index 2e89594fa..132be9e28 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -366,8 +366,7 @@ cat <<'NEXT' deploy/homebrew/homebrew.orcbrew 7. To build from source instead of pulling images: - docker compose -f docker-compose-build.yaml build - docker compose -f docker-compose-build.yaml up -d + docker compose up --build -d For more details, see README.md. NEXT diff --git a/docker/Dockerfile b/docker/Dockerfile index ac86176f8..149930a38 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ # transactor — datomic transactor (dev storage protocol) # app — orcpub uberjar runner # -# Usage via docker-compose-build.yaml (each service sets its target). +# Usage: docker compose up --build (each service sets its target). # ── Shared: download Datomic Pro distribution ───────────────── FROM alpine:3.22 AS datomic-dist diff --git a/docs/DOCKER.md b/docs/DOCKER.md index a3dca17f8..95ea40ceb 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -21,27 +21,17 @@ Boot order is enforced by healthcheck dependencies: datomic --> orcpub (waits for datomic healthy) --> web (waits for orcpub healthy) -## Compose Files +## Compose File -### `docker-compose-build.yaml` — Build from Source - -Builds images locally using the multi-target `docker/Dockerfile`. Use for CI -pipelines and local development. - -```sh -docker compose -f docker-compose-build.yaml up --build -``` - -### `docker-compose.yaml` — Pre-built Images - -Pulls pre-built images from Docker Hub. Use for production and release -deployments. +A single `docker-compose.yaml` handles both modes: ```sh -docker compose up -d +docker compose up -d # Pull pre-built images from Docker Hub +docker compose up --build -d # Build from source using docker/Dockerfile ``` -Both files are kept in sync: same env vars, healthchecks, and volume mounts. +Image names default to Docker Hub (`orcpub/orcpub:release-*`, `orcpub/datomic:latest`) +but can be overridden via `ORCPUB_IMAGE` and `DATOMIC_IMAGE` env vars. ## Transactor Configuration (Option C Hybrid Template) @@ -192,8 +182,7 @@ The current configuration is Swarm-ready with minimal changes: | `deploy/start.sh` | Transactor startup: secret substitution + exec | | `deploy/nginx.conf.template` | Nginx reverse proxy template (`envsubst` resolves `${ORCPUB_PORT}`) | | `deploy/snakeoil.sh` | Self-signed SSL certificate generator | -| `docker-compose-build.yaml` | Build-from-source compose | -| `docker-compose.yaml` | Pre-built images compose | +| `docker-compose.yaml` | Compose file (pull or build-from-source) | | `docker-setup.sh` | Interactive setup: generates `.env`, dirs, SSL certs | | `.env.example` | Environment variable reference with defaults | diff --git a/docs/docker-user-management.md b/docs/docker-user-management.md index eb2a1908e..6ed59c076 100644 --- a/docs/docker-user-management.md +++ b/docs/docker-user-management.md @@ -78,7 +78,7 @@ docker compose up -d ./docker-user.sh create admin admin@example.com MySecurePass123 ``` -The setup script creates a `.env` file used by both `docker-compose.yaml` and `docker-compose-build.yaml`. You can also copy and edit `.env.example` manually if you prefer. +The setup script creates a `.env` file used by `docker-compose.yaml`. You can also copy and edit `.env.example` manually if you prefer. ### Environment Variables @@ -170,7 +170,7 @@ Prints a table of all users in the database with their verification status. ## Docker Compose Changes -Both `docker-compose.yaml` and `docker-compose-build.yaml` have been updated: +`docker-compose.yaml` has been updated: - **Environment variables use `.env` interpolation** — no more editing passwords directly in the YAML. All config flows from the `.env` file generated by `docker-setup.sh`. - **Native healthchecks** — Datomic and the application containers declare healthchecks so that dependent services wait for readiness automatically. This replaces fragile startup-order workarounds. diff --git a/docs/migration/datomic-data-migration.md b/docs/migration/datomic-data-migration.md index 9c7fb757d..38c6cac71 100644 --- a/docs/migration/datomic-data-migration.md +++ b/docs/migration/datomic-data-migration.md @@ -81,8 +81,7 @@ bin/datomic verify-backup <backup-uri> true <t> # verify (Pro only) # 2. Stop old stack, build and start new: docker compose down -docker compose -f docker-compose-build.yaml build -docker compose -f docker-compose-build.yaml up -d +docker compose up --build -d # 3. After services are healthy, restore: ./docker-migrate.sh restore @@ -158,7 +157,7 @@ accessible, user accounts work. - Old Docker stack running (`docker compose ps` shows healthy datomic + orcpub) - `.env` file with correct `DATOMIC_PASSWORD` - Enough disk space (see [Performance](#performance)) -- New source code checked out (has `docker-compose-build.yaml` and migration scripts) +- New source code checked out (has `docker-compose.yaml` and migration scripts) Each phase launches a **temporary container** (`docker run --rm`) on the Compose network, bind-mounting `./backup/` for I/O. No running containers are modified. @@ -191,8 +190,7 @@ network, bind-mounting `./backup/` for I/O. No running containers are modified. docker compose down mv ./data ./data.free-backup mkdir -p ./data -docker compose -f docker-compose-build.yaml build -docker compose -f docker-compose-build.yaml up -d +docker compose up --build -d ``` Wait for healthy: `docker compose ps` @@ -239,7 +237,7 @@ mv ./data.free-backup ./data docker compose down rm -rf ./data mv ./data.free-backup ./data -docker compose up -d # OLD compose file, not docker-compose-build.yaml +docker compose up -d # pulls pre-built images (old stack) ``` The backup directory is never modified — you can re-attempt the restore as many From 5ea02b534903ceede8cd7654e19142160d5509f0 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Sun, 1 Mar 2026 21:35:38 +0000 Subject: [PATCH 35/50] fix: CI Docker test used Docker Hub images instead of locally-built ones docker build tags images as orcpub-app/orcpub-datomic, but compose defaults to Docker Hub names. Compose pulled old Datomic Free images, causing :unsupported-protocol :dev at runtime. Set ORCPUB_IMAGE and DATOMIC_IMAGE env vars so compose uses the locally-built Pro images. --- .github/workflows/docker-integration.yml | 4 ++++ docker-compose.yaml | 8 ++++---- docs/DOCKER.md | 8 ++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 37552e548..35210e17e 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -130,6 +130,10 @@ jobs: echo "=== Building orcpub (app) ===" docker build --target app -t orcpub-app -f docker/Dockerfile . echo "=== Build complete ===" + # Set image env vars so compose uses locally-built images + # instead of pulling old Datomic Free images from Docker Hub + echo "ORCPUB_IMAGE=orcpub-app" >> "$GITHUB_ENV" + echo "DATOMIC_IMAGE=orcpub-datomic" >> "$GITHUB_ENV" - name: Pull container images (Java 8) id: pull diff --git a/docker-compose.yaml b/docker-compose.yaml index 6735aade2..0e1004cd8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,9 +1,9 @@ --- services: orcpub: - # Pre-built: docker compose up (pulls from Docker Hub) - # Local build: docker compose up --build (builds from Dockerfile) - image: ${ORCPUB_IMAGE:-orcpub/orcpub:release-v2.5.0.27} + # Build: docker compose up --build + # Override image: ORCPUB_IMAGE=registry/name:tag docker compose up + image: ${ORCPUB_IMAGE:-orcpub-app} build: context: . dockerfile: docker/Dockerfile @@ -38,7 +38,7 @@ services: start_period: 60s restart: always datomic: - image: ${DATOMIC_IMAGE:-orcpub/datomic:latest} + image: ${DATOMIC_IMAGE:-orcpub-datomic} build: context: . dockerfile: docker/Dockerfile diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 95ea40ceb..663237f2b 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -23,15 +23,15 @@ Boot order is enforced by healthcheck dependencies: ## Compose File -A single `docker-compose.yaml` handles both modes: +A single `docker-compose.yaml`: ```sh -docker compose up -d # Pull pre-built images from Docker Hub docker compose up --build -d # Build from source using docker/Dockerfile ``` -Image names default to Docker Hub (`orcpub/orcpub:release-*`, `orcpub/datomic:latest`) -but can be overridden via `ORCPUB_IMAGE` and `DATOMIC_IMAGE` env vars. +Image names default to local build tags (`orcpub-app`, `orcpub-datomic`). +Override with `ORCPUB_IMAGE` and `DATOMIC_IMAGE` env vars to point at a +registry (e.g., `ORCPUB_IMAGE=registry/orcpub:2.6.0.0 docker compose up -d`). ## Transactor Configuration (Option C Hybrid Template) From 32502106ee466d4a20b8ada45681507a95afd2c5 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 3 Mar 2026 19:05:18 +0000 Subject: [PATCH 36/50] chore: add .gitattributes, gitignore credentials, restore DinD - .gitattributes: merge=ours for fork/ files and devcontainer.json - .gitignore: untrack .claude/, newrelic*, deploy/transactor.properties - devcontainer.json: restore Docker-in-Docker feature (lost in cherry-pick) --- .devcontainer/devcontainer.json | 3 ++- .gitattributes | 7 +++++++ .gitignore | 10 +++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c486f6670..15bc747ba 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,8 @@ "features": { "ghcr.io/devcontainers/features/sshd:1": { "version": "latest" - } + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": {} }, "forwardPorts": [8890, 3449, 4334], "portsAttributes": { diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..83e061405 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Fork override files — keep each branch's version during merges. +# Public/breaking has stubs, DMV has real implementations. +src/clj/orcpub/fork/** merge=ours +src/cljs/orcpub/fork/** merge=ours + +# Devcontainer — public has neutral config, DMV may add personal extensions. +.devcontainer/devcontainer.json merge=ours diff --git a/.gitignore b/.gitignore index 7375787d0..0d77308df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ # Ignore environment file (secrets/config) .env .editorconfig -.gitattributes /resources/public/css/compiled /resources/public/js/compiled /resources/*_backup.pdf @@ -68,6 +67,15 @@ cljs-test-runner-out # Claude Code local data (conversation history, credentials) .claude-data/ +# Agentic/AI tool files — belong in dotfiles or agents/ branch, not code branches +.claude/ + +# NewRelic (contains license key — should never be on public branches) +newrelic* + +# Transactor properties (may contain hardcoded passwords) +deploy/transactor.properties + # Ignore all log files in logs/ logs/ From ffd7afb7a640a5ae2e48a3b46b6dd420fd6c08d3 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 3 Mar 2026 19:07:00 +0000 Subject: [PATCH 37/50] =?UTF-8?q?chore:=20remove=20devcontainer.json=20fro?= =?UTF-8?q?m=20merge=3Dours=20=E2=80=94=20personal=20settings=20stay=20unt?= =?UTF-8?q?racked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitattributes b/.gitattributes index 83e061405..977df029d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,6 +2,3 @@ # Public/breaking has stubs, DMV has real implementations. src/clj/orcpub/fork/** merge=ours src/cljs/orcpub/fork/** merge=ours - -# Devcontainer — public has neutral config, DMV may add personal extensions. -.devcontainer/devcontainer.json merge=ours From f7e1973782ecd34183956c79906b257cdc10a4d1 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 3 Mar 2026 19:49:59 +0000 Subject: [PATCH 38/50] =?UTF-8?q?feat:=20docker=20infrastructure=20?= =?UTF-8?q?=E2=80=94=20check,=20build,=20deploy,=20upgrade,=20secrets=20+?= =?UTF-8?q?=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File-copy from dmv/hotfix-integrations with DMV branding stripped: - docker-setup.sh: 372 → 1332 lines (--check, --build, --deploy, --upgrade, --secrets) - docker-compose.yaml: aligned env vars, Docker Secrets docs, variable comments - docker-user.sh: ANSI-C color quoting fix, tr -d '\r' for Windows line endings - .env.example: separate DATOMIC_PASSWORD, image tag vars, generic branding placeholders - test/docker/: 46-test suite (test-upgrade.sh) with 8 fixture .env scenarios - .gitignore: add generated secrets files --- .env.example | 53 +- .gitignore | 3 + docker-compose.yaml | 91 +- docker-setup.sh | 1108 +++++++++++++++-- docker-user.sh | 12 +- test/docker/README.md | 52 + test/docker/fixtures/compose-hardcoded.yaml | 19 + test/docker/fixtures/env-production-like.env | 32 + .../docker/fixtures/env-v1-free-localhost.env | 21 + test/docker/fixtures/env-v2-missing-vars.env | 24 + .../fixtures/env-v2-password-in-url.env | 31 + .../fixtures/env-v2-password-mismatch.env | 18 + test/docker/fixtures/env-v2-windows-crlf.env | 17 + test/docker/fixtures/env-v3-current.env | 30 + test/docker/reset-test.sh | 48 + test/docker/test-upgrade.sh | 318 +++++ 16 files changed, 1775 insertions(+), 102 deletions(-) create mode 100644 test/docker/README.md create mode 100644 test/docker/fixtures/compose-hardcoded.yaml create mode 100644 test/docker/fixtures/env-production-like.env create mode 100644 test/docker/fixtures/env-v1-free-localhost.env create mode 100644 test/docker/fixtures/env-v2-missing-vars.env create mode 100644 test/docker/fixtures/env-v2-password-in-url.env create mode 100644 test/docker/fixtures/env-v2-password-mismatch.env create mode 100644 test/docker/fixtures/env-v2-windows-crlf.env create mode 100644 test/docker/fixtures/env-v3-current.env create mode 100755 test/docker/reset-test.sh create mode 100755 test/docker/test-upgrade.sh diff --git a/.env.example b/.env.example index 5fb056f47..5bf0a5988 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ============================================================================ -# Dungeon Master's Vault — Docker Environment Configuration +# Docker Environment Configuration # # Copy this file to .env and update the values: # cp .env.example .env @@ -12,17 +12,22 @@ PORT=8890 TZ=America/Chicago -# Image tag for docker-compose.yaml (pre-built images only, ignored by build compose) -# ORCPUB_TAG=release-v2.5.0.27 +# Docker image names. Set these if you tag your own builds +# so compose/swarm finds them. Defaults: orcpub-app, orcpub-datomic +# ORCPUB_IMAGE=orcpub-app:latest +# DATOMIC_IMAGE=orcpub-datomic:latest # --- Datomic Database --- # Datomic Pro with dev storage protocol (required for Java 21 support) # ADMIN_PASSWORD secures the Datomic admin interface -# DATOMIC_PASSWORD is used by the application to connect to Datomic -# The password in DATOMIC_URL must match DATOMIC_PASSWORD +# DATOMIC_PASSWORD is used by both the transactor and the app to authenticate. +# The app reads it separately and appends ?password= to DATOMIC_URL at startup, +# so you don't need to embed the password in the URL. +# DATOMIC_URL should NOT contain ?password= (the app adds it from DATOMIC_PASSWORD). +# Old URLs with ?password= still work — the embedded password takes priority. ADMIN_PASSWORD=change-me-admin DATOMIC_PASSWORD=change-me-datomic -DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=change-me-datomic +DATOMIC_URL=datomic:dev://datomic:4334/orcpub # --- Transactor Tuning --- # These rarely need changing. See docker/transactor.properties.template. @@ -77,28 +82,36 @@ EMAIL_SSL=FALSE EMAIL_TLS=FALSE # --- Branding (optional) --- -# Override to customize the app identity for your deployment. -# All values have neutral defaults in fork/branding.clj; set only what you want to change. +# Override app identity for forks. All have sensible defaults in fork/branding.clj. # APP_NAME=My D&D Toolkit -# APP_TAGLINE=A custom character builder -# APP_PAGE_TITLE=My Toolkit: D&D 5e Character Builder -# APP_LOGO_PATH=/image/my-logo.svg -# APP_OG_IMAGE=/image/my-og-preview.png -# APP_COPYRIGHT_HOLDER=My Org -# APP_COPYRIGHT_YEAR=2025 -# APP_EMAIL_SENDER_NAME=My Toolkit Team +# APP_URL=https://example.com +# APP_LOGO_PATH= +# APP_OG_IMAGE= +# APP_COPYRIGHT_HOLDER= +# APP_COPYRIGHT_YEAR= +# APP_EMAIL_SENDER_NAME= +# APP_PAGE_TITLE= +# APP_TAGLINE=A D&D 5e character builder and resource compendium # APP_SUPPORT_EMAIL=support@example.com -# APP_HELP_URL=https://example.com/help/ +# APP_HELP_URL= + +# --- Social Links (optional) --- +# Shown in app header. Leave empty to hide a link. # APP_SOCIAL_PATREON= # APP_SOCIAL_FACEBOOK= +# APP_SOCIAL_BLUESKY= # APP_SOCIAL_TWITTER= # APP_SOCIAL_REDDIT= # APP_SOCIAL_DISCORD= -# --- Third-Party Integrations (optional) --- -# Server-side <head> tags loaded via fork/integrations.clj. -# Client-side lifecycle hooks in fork/integrations.cljs. -# See docs/kb/fork-customization.md for the full pattern. +# --- Analytics & Ads (optional) --- +# Third-party integrations. Leave empty to disable. +# Server-side (fork/integrations.clj) loads SDK scripts; client-side (fork/integrations.cljs) +# provides in-app hooks. CSP domains are auto-derived from these values. +# MATOMO_URL= +# MATOMO_SITE_ID= +# ADSENSE_CLIENT= +# ADSENSE_SLOT= # --- Initial Admin User (optional) --- # Set these then run: ./docker-user.sh init diff --git a/.gitignore b/.gitignore index 0d77308df..1b4a3d7ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # Ignore environment file (secrets/config) .env +.env.secrets.backup +/secrets/ +docker-compose.secrets.yaml .editorconfig /resources/public/css/compiled /resources/public/js/compiled diff --git a/docker-compose.yaml b/docker-compose.yaml index 0e1004cd8..388c8e56d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,31 @@ --- +# ============================================================================ +# How variables work in this file +# ============================================================================ +# +# Each ${VAR:-default} reads from TWO places, in this order: +# 1. Shell environment variables (export VAR=value) +# 2. The .env file in this directory +# +# Shell env vars ALWAYS win over .env values. This means: +# - You can override any setting with: VAR=value docker compose up -d +# - If your shell already has a variable set (e.g. from a Codespace or +# .bashrc), it will override what's in .env — even if you didn't intend it. +# +# To check what compose actually resolves: +# docker compose config | grep DATOMIC_URL +# +# Recommended setup: +# 1. Run ./docker-setup.sh to generate .env with secure passwords +# 2. Run: docker compose up --build -d +# 3. If a variable isn't being picked up from .env, check for a conflicting +# shell variable with: echo $DATOMIC_URL +# Clear it with: unset DATOMIC_URL +# +# See docs/DOCKER.md for the full quick-start guide. +# See docs/ENVIRONMENT.md for all available variables. +# ============================================================================ + services: orcpub: # Build: docker compose up --build @@ -18,8 +45,12 @@ services: EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} EMAIL_SSL: ${EMAIL_SSL:-FALSE} EMAIL_TLS: ${EMAIL_TLS:-FALSE} - # Datomic Pro with dev storage protocol (required for Java 21 support) - DATOMIC_URL: ${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub?password=change-me} + # Datomic Pro with dev storage protocol (required for Java 21 support). + # The hostname MUST be "datomic" (the compose service name), NOT "localhost". + # Password is NOT in the URL — the app reads DATOMIC_PASSWORD separately + # and appends it at startup. Old URLs with ?password= still work. + DATOMIC_URL: ${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub} + DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} CSP_POLICY: ${CSP_POLICY:-strict} DEV_MODE: ${DEV_MODE:-} @@ -46,6 +77,8 @@ services: environment: ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} + # ALT_HOST: what the transactor advertises to peers for fallback connections. + # Default 127.0.0.1 works for single-host. Set to "datomic" for Swarm. ALT_HOST: ${ALT_HOST:-127.0.0.1} ENCRYPT_CHANNEL: ${ENCRYPT_CHANNEL:-true} volumes: @@ -77,3 +110,57 @@ services: orcpub: condition: service_healthy restart: always + +# --- Docker Secrets --- +# Uncomment to use Docker secrets instead of .env for passwords. +# Secrets are mounted as files at /run/secrets/<name> inside the container. +# Both deploy/start.sh (transactor) and the app (config.clj) check +# /run/secrets/ first, then fall back to environment variables. +# +# Option A: File-based secrets (works with plain docker compose, no Swarm) +# Each password goes in its own file instead of .env. Docker mounts +# them inside the container. Still plain files on your hard drive — +# but isolated with strict permissions instead of all in one .env. +# Create a secrets/ directory with one file per secret: +# mkdir -p secrets +# printf 'mypassword' > secrets/datomic_password +# printf 'mypassword' > secrets/admin_password +# printf 'mysecret' > secrets/signature +# chmod 600 secrets/* +# +# secrets: +# datomic_password: +# file: ./secrets/datomic_password +# admin_password: +# file: ./secrets/admin_password +# signature: +# file: ./secrets/signature +# +# Option B: External secrets (Swarm only — created via docker secret create) +# Swarm stores passwords encrypted inside the cluster. When a container +# needs one, Swarm delivers it into memory — the password is never +# saved to the server's hard drive. Use this for multi-server clusters. +# printf 'mypassword' | docker secret create datomic_password - +# printf 'mypassword' | docker secret create admin_password - +# printf 'mysecret' | docker secret create signature - +# +# secrets: +# datomic_password: +# external: true +# admin_password: +# external: true +# signature: +# external: true +# +# Then add to each service that needs secrets: +# orcpub: +# secrets: +# - datomic_password +# - signature +# datomic: +# secrets: +# - datomic_password +# - admin_password +# +# You can remove the corresponding env vars from .env, or leave them — +# secret files take priority over env vars. diff --git a/docker-setup.sh b/docker-setup.sh index 132be9e28..8de489a1c 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# OrcPub / Dungeon Master's Vault — Docker Setup Script +# OrcPub / Docker Setup Script # # Prepares everything needed to run the application via Docker Compose: # 1. Generates secure random passwords and a signing secret @@ -23,16 +23,47 @@ ENV_FILE="${SCRIPT_DIR}/.env" # Helpers # --------------------------------------------------------------------------- -color_green='\033[0;32m' -color_yellow='\033[1;33m' -color_red='\033[0;31m' -color_cyan='\033[0;36m' -color_reset='\033[0m' +color_green=$'\033[0;32m' +color_yellow=$'\033[1;33m' +color_red=$'\033[0;31m' +color_cyan=$'\033[0;36m' +color_magenta=$'\033[0;35m' +color_reset=$'\033[0m' + +info() { printf '%s[INFO]%s %s\n' "$color_green" "$color_reset" "$*"; } +warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } +change() { printf '%s[FIXD]%s %s\n' "$color_magenta" "$color_reset" "$*"; } +error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } +success() { printf '\n%s=== %s ===%s\n' "$color_green" "$*" "$color_reset"; } +header() { printf '\n%s=== %s ===%s\n\n' "$color_cyan" "$*" "$color_reset"; } +next() { printf '%s ▸%s %s\n' "$color_cyan" "$color_reset" "$*"; } + +# Read a variable value from a .env file, stripping Windows \r line endings. +# Usage: val=$(read_env_val "VAR_NAME" "/path/to/.env") +read_env_val() { + grep -m1 "^${1}=" "$2" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true +} -info() { printf '%s[INFO]%s %s\n' "$color_green" "$color_reset" "$*"; } -warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } -error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } -header() { printf '\n%s=== %s ===%s\n\n' "$color_cyan" "$*" "$color_reset"; } +# Source a .env file safely (strips Windows \r line endings). +# Usage: source_env "/path/to/.env" +source_env() { + # shellcheck disable=SC1090 + . <(tr -d '\r' < "$1") +} + +# Set a variable in a .env file. Uses awk to avoid sed delimiter issues with URLs. +# Usage: set_env_val "VAR_NAME" "value" "/path/to/.env" +set_env_val() { + local var="$1" val="$2" file="$3" + if grep -q "^${var}=" "$file" 2>/dev/null; then + awk -v var="$var" -v val="$val" '{ + if (index($0, var"=") == 1) print var"="val; else print + }' "$file" > "${file}.tmp" + mv "${file}.tmp" "$file" + else + echo "${var}=${val}" >> "$file" + fi +} generate_password() { # Generate a URL-safe random password (no special chars that break URLs/YAML) @@ -47,6 +78,179 @@ generate_password() { fi } +# Generate docker-compose.secrets.yaml and wire COMPOSE_FILE into .env. +# Usage: write_compose_secrets "file" → file-based secrets +# write_compose_secrets "external" → Swarm external secrets +write_compose_secrets() { + local mode="$1" # "file" or "external" + local secrets_compose="${SCRIPT_DIR}/docker-compose.secrets.yaml" + local compose_file_var="docker-compose.yaml:docker-compose.secrets.yaml" + + if [ -f "$secrets_compose" ] && [ "$FORCE_MODE" = "false" ]; then + warn "docker-compose.secrets.yaml already exists. Use --force to overwrite." + return 0 + fi + + if [ "$mode" = "file" ]; then + cat > "$secrets_compose" <<'YAML' +# Generated by ./docker-setup.sh --secrets +# Compose merges this with docker-compose.yaml automatically via COMPOSE_FILE. +secrets: + datomic_password: + file: ./secrets/datomic_password + admin_password: + file: ./secrets/admin_password + signature: + file: ./secrets/signature + +services: + orcpub: + secrets: + - datomic_password + - signature + datomic: + secrets: + - datomic_password + - admin_password +YAML + else + cat > "$secrets_compose" <<'YAML' +# Generated by ./docker-setup.sh --swarm +# Compose merges this with docker-compose.yaml automatically via COMPOSE_FILE. +secrets: + datomic_password: + external: true + admin_password: + external: true + signature: + external: true + +services: + orcpub: + secrets: + - datomic_password + - signature + datomic: + secrets: + - datomic_password + - admin_password +YAML + fi + + change "Created docker-compose.secrets.yaml" + + # Add COMPOSE_FILE to .env so compose merges both files automatically + if [ -f "$ENV_FILE" ]; then + local current_cf + current_cf=$(read_env_val COMPOSE_FILE "$ENV_FILE") + if [ -z "$current_cf" ]; then + echo "" >> "$ENV_FILE" + echo "# --- Compose file merge (secrets) ---" >> "$ENV_FILE" + echo "COMPOSE_FILE=${compose_file_var}" >> "$ENV_FILE" + change "Added COMPOSE_FILE to .env (compose will merge both files)" + elif [ "$current_cf" != "$compose_file_var" ]; then + warn "COMPOSE_FILE already set in .env: ${current_cf}" + warn "Make sure it includes docker-compose.secrets.yaml" + fi + else + warn "No .env file — add to your environment:" + warn " export COMPOSE_FILE=${compose_file_var}" + fi +} + +# Read DATOMIC_PASSWORD, ADMIN_PASSWORD, SIGNATURE from .env + shell env. +# Sets _pw_datomic, _pw_admin, _pw_signature. Exits on missing values. +# Usage: read_passwords "--secrets" (label for error message) +read_passwords() { + local mode_label="$1" + _pw_datomic="" + _pw_admin="" + _pw_signature="" + + if [ -f "$ENV_FILE" ]; then + info "Reading passwords from .env" + _pw_datomic=$(read_env_val DATOMIC_PASSWORD "$ENV_FILE") + _pw_admin=$(read_env_val ADMIN_PASSWORD "$ENV_FILE") + _pw_signature=$(read_env_val SIGNATURE "$ENV_FILE") + fi + + # Fill gaps from shell env vars (for admins who export directly) + _pw_datomic="${_pw_datomic:-${DATOMIC_PASSWORD:-}}" + _pw_admin="${_pw_admin:-${ADMIN_PASSWORD:-}}" + _pw_signature="${_pw_signature:-${SIGNATURE:-}}" + + if [ -z "$_pw_datomic" ] || [ -z "$_pw_admin" ] || [ -z "$_pw_signature" ]; then + error "Could not find all required passwords." + error "Checked .env file and shell environment variables." + [ -z "$_pw_datomic" ] && error " DATOMIC_PASSWORD — not found" + [ -z "$_pw_admin" ] && error " ADMIN_PASSWORD — not found" + [ -z "$_pw_signature" ] && error " SIGNATURE — not found" + echo "" + error "Either create a .env file (./docker-setup.sh) or export the" + error "variables in your shell before running ${mode_label}." + exit 1 + fi +} + +# Check that a file exists, incrementing ERRORS if not. +# Usage: check_file "label" "/path/to/file" +check_file() { + local label="$1" path="$2" + if [ -f "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING" + WARNING_MSGS+=("${label} is missing") + ERRORS=$((ERRORS + 1)) + fi +} + +# Check that a directory exists, incrementing ERRORS if not. +# Usage: check_dir "label" "/path/to/dir" +check_dir() { + local label="$1" path="$2" + if [ -d "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING" + WARNING_MSGS+=("${label} is missing") + ERRORS=$((ERRORS + 1)) + fi +} + +# Check if a shell env var conflicts with .env value, incrementing ERRORS if so. +# Usage: check_env_conflict "VAR_NAME" +check_env_conflict() { + local var_name="$1" + local env_val="${!var_name:-}" + local file_val + file_val=$(read_env_val "$var_name" "$ENV_FILE") + + if [ -n "$env_val" ] && [ -n "$file_val" ] && [ "$env_val" != "$file_val" ]; then + warn " Shell \$${var_name} differs from .env value" + warn " Shell: ${env_val}" + warn " .env: ${file_val}" + ENV_CONFLICTS+=("$var_name") + WARNING_MSGS+=("\$${var_name}: shell value overrides .env") + ERRORS=$((ERRORS + 1)) + fi +} + +# Build a docker compose command, prefixing with env -u for each shell/env conflict. +# Usage: build_compose_cmd "docker compose up --build -d" +build_compose_cmd() { + local base_cmd="$1" + if [ "${#ENV_CONFLICTS[@]}" -gt 0 ]; then + local prefix="" + for var in "${ENV_CONFLICTS[@]}"; do + prefix="${prefix}env -u ${var} " + done + echo "${prefix}${base_cmd}" + else + echo "$base_cmd" + fi +} + prompt_value() { local prompt_text="$1" local default_value="$2" @@ -71,14 +275,29 @@ usage() { Usage: ./docker-setup.sh [OPTIONS] Options: - --auto Non-interactive mode; accept all defaults - --force Overwrite existing .env file - --help Show this help message + --auto Non-interactive mode; accept all defaults + --force Overwrite existing .env file + --upgrade Update an existing .env to the latest format + --upgrade-secrets Upgrade .env + create Docker secret files (one step) + --upgrade-swarm Upgrade .env + create Swarm secrets (one step) + --secrets Convert .env passwords to Docker secret files + --swarm Convert .env passwords to Docker Swarm secrets + --build Build Docker images + --deploy Deploy as a Docker Swarm stack + --check Validate .env and environment (read-only, no changes) + --help Show this help message Examples: - ./docker-setup.sh # Interactive setup - ./docker-setup.sh --auto # Quick setup with generated defaults - ./docker-setup.sh --auto --force # Regenerate everything from scratch + ./docker-setup.sh # New install — interactive + ./docker-setup.sh --auto # New install — accept defaults + ./docker-setup.sh --upgrade # Existing install — fix old .env format + ./docker-setup.sh --upgrade-secrets # Upgrade + create Docker secret files + ./docker-setup.sh --upgrade-swarm # Upgrade + create Swarm secrets + ./docker-setup.sh --secrets # Switch passwords to secret files + ./docker-setup.sh --swarm # Switch passwords to Swarm secrets + ./docker-setup.sh --build # Build images + ./docker-setup.sh --deploy # Deploy Swarm stack + ./docker-setup.sh --build --deploy # Build + deploy USAGE } @@ -87,13 +306,27 @@ USAGE # --------------------------------------------------------------------------- AUTO_MODE=false +BUILD_MODE=false +CHECK_MODE=false +DEPLOY_MODE=false FORCE_MODE=false +SECRETS_MODE=false +SWARM_MODE=false +UPGRADE_MODE=false for arg in "$@"; do case "$arg" in - --auto) AUTO_MODE=true ;; - --force) FORCE_MODE=true ;; - --help) usage; exit 0 ;; + --auto) AUTO_MODE=true ;; + --check) CHECK_MODE=true ;; + --build) BUILD_MODE=true ;; + --deploy) DEPLOY_MODE=true ;; + --force) FORCE_MODE=true ;; + --secrets) SECRETS_MODE=true ;; + --swarm) SWARM_MODE=true ;; + --upgrade) UPGRADE_MODE=true ;; + --upgrade-swarm) UPGRADE_MODE=true; SWARM_MODE=true ;; + --upgrade-secrets) UPGRADE_MODE=true; SECRETS_MODE=true ;; + --help) usage; exit 0 ;; *) error "Unknown option: $arg" usage @@ -102,11 +335,697 @@ for arg in "$@"; do esac done +# --------------------------------------------------------------------------- +# Check mode (--check) — read-only validation +# --------------------------------------------------------------------------- +# Validates .env has all required variables, checks for common issues, +# and reports shell env conflicts. Makes no changes. + +if [ "$CHECK_MODE" = "true" ]; then + header "Environment Check" + + if [ ! -f "$ENV_FILE" ]; then + warn "No .env file found." + echo " 1) Interactive setup (prompts for each value)" + echo " 2) Auto setup (generates random passwords, safe defaults)" + echo " 3) Skip" + read -rp "Choice [1]: " _create + case "${_create:-1}" in + 1) exec "$0" ;; + 2) exec "$0" --auto ;; + *) exit 0 ;; + esac + fi + + ERRORS=0 + WARNING_MSGS=() + ENV_CONFLICTS=() + + # Required variables — check .env first, then secrets/ files + _has_secrets_dir=false + [ -d "${SCRIPT_DIR}/secrets" ] && _has_secrets_dir=true + + for _var in DATOMIC_URL DATOMIC_PASSWORD ADMIN_PASSWORD SIGNATURE; do + _val=$(read_env_val "$_var" "$ENV_FILE") + if [ -n "$_val" ]; then + info " ${_var}: OK" + elif [ "$_has_secrets_dir" = "true" ]; then + # Map var names to secret file names + case "$_var" in + DATOMIC_PASSWORD) _secret_file="datomic_password" ;; + ADMIN_PASSWORD) _secret_file="admin_password" ;; + SIGNATURE) _secret_file="signature" ;; + *) _secret_file="" ;; + esac + if [ -n "$_secret_file" ] && [ -f "${SCRIPT_DIR}/secrets/${_secret_file}" ]; then + info " ${_var}: OK (via secrets/${_secret_file})" + elif [ -n "$_secret_file" ] && docker secret inspect "$_secret_file" &>/dev/null; then + info " ${_var}: OK (via Swarm secret ${_secret_file})" + else + warn " ${_var}: MISSING" + WARNING_MSGS+=("${_var} is missing from .env") + ERRORS=$((ERRORS + 1)) + fi + else + warn " ${_var}: MISSING" + WARNING_MSGS+=("${_var} is missing from .env") + ERRORS=$((ERRORS + 1)) + fi + done + + echo "" + + # URL health checks + _url=$(read_env_val DATOMIC_URL "$ENV_FILE") + if [[ "$_url" == *"password="* ]]; then + warn " DATOMIC_URL has embedded password (legacy format)" + warn " Run ./docker-setup.sh --upgrade to clean it" + WARNING_MSGS+=("DATOMIC_URL has embedded password") + ERRORS=$((ERRORS + 1)) + fi + if [[ "$_url" == *"datomic:free://"* ]]; then + warn " DATOMIC_URL uses old Free protocol" + warn " Run ./docker-setup.sh --upgrade to convert to datomic:dev://" + WARNING_MSGS+=("DATOMIC_URL uses Free protocol") + ERRORS=$((ERRORS + 1)) + fi + if [[ "$_url" == *"localhost"* ]]; then + warn " DATOMIC_URL contains 'localhost' (should be 'datomic' for Docker)" + warn " Run ./docker-setup.sh --upgrade to fix" + WARNING_MSGS+=("DATOMIC_URL contains localhost") + ERRORS=$((ERRORS + 1)) + fi + + echo "" + + # Shell env conflicts + for _var in DATOMIC_URL DATOMIC_PASSWORD SIGNATURE ADMIN_PASSWORD; do + check_env_conflict "$_var" + done + + # Required files and directories + echo "" + check_file ".env" "$ENV_FILE" + check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" + check_file "nginx.conf.template" "${SCRIPT_DIR}/deploy/nginx.conf.template" + check_dir "data/" "${SCRIPT_DIR}/data" + check_dir "logs/" "${SCRIPT_DIR}/logs" + + echo "" + if [ "$ERRORS" -eq 0 ]; then + success "All checks passed" + else + warn "${ERRORS} issue(s) found." + read -rp "Run upgrade to fix? [Y/n]: " _fix + if [[ "${_fix,,}" =~ ^(y|)$ ]]; then + exec "$0" --upgrade + fi + fi + exit 0 +fi + +# --------------------------------------------------------------------------- +# Build mode (--build) — build Docker images +# --------------------------------------------------------------------------- + +if [ "$BUILD_MODE" = "true" ] && [ "$DEPLOY_MODE" != "true" ]; then + header "Build" + + if ! docker compose version &>/dev/null; then + error "docker compose is not available." + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + error "No .env file found. Run setup first." + exit 1 + fi + + info "Building images..." + docker compose build || { error "Build failed."; exit 1; } + + echo "" + success "Images built!" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Deploy mode (--deploy) — deploy as a Docker Swarm stack +# --------------------------------------------------------------------------- + +if [ "$DEPLOY_MODE" = "true" ]; then + header "Swarm Deploy" + + if ! docker compose version &>/dev/null; then + error "docker compose is not available." + exit 1 + fi + + # Verify Swarm is active + _swarm_state=$(docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null || true) + if [ "$_swarm_state" != "active" ]; then + error "Docker Swarm is not active. Run --swarm first." + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + error "No .env file found. Run setup first." + exit 1 + fi + + if ! command -v jq &>/dev/null; then + error "jq is required for --deploy. Install it with: apt-get install jq" + exit 1 + fi + + if [ "$BUILD_MODE" = "true" ]; then + info "Building images..." + docker compose build || { error "Build failed."; exit 1; } + echo "" + fi + + info "Deploying stack..." + # docker compose config outputs Compose Specification format; + # docker stack deploy expects legacy v3 schema. JSON + jq bridges the gap. + # See docs/kb/docker-swarm-compat.md for the full incompatibility list. + docker compose config --format json | jq ' + del(.name) | + .services |= with_entries( + .value.depends_on |= (if type == "object" then keys else . end) + ) | + .services |= with_entries( + .value.ports |= (if . then [.[] | .published |= tonumber] else . end) + ) + ' | docker stack deploy -c - orcpub || { error "Deploy failed."; exit 1; } + + echo "" + success "Stack deployed!" + echo "" + echo "Useful commands:" + echo " docker stack services orcpub # List services" + echo " docker stack ps orcpub # List tasks" + echo " docker service logs <service> # View logs" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Secrets migration (--secrets mode) +# --------------------------------------------------------------------------- +# Reads passwords from .env or shell environment and writes them as +# individual secret files. Works whether you use .env or export vars directly. +# Auto-generates docker-compose.secrets.yaml and wires it via COMPOSE_FILE. + +if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then + header "Secrets Migration" + + # Ask if they're running Swarm — different flow entirely + if [ "${AUTO_MODE}" != "true" ]; then + echo "This will create secret files on your machine (works with docker compose)." + echo "If you're running Docker Swarm, secrets are stored in the cluster instead." + echo "" + read -rp "Are you using Docker Swarm? [y/N]: " _is_swarm + if [[ "${_is_swarm,,}" == "y" ]]; then + exec "$0" --swarm + fi + fi + + SECRETS_DIR="${SCRIPT_DIR}/secrets" + + read_passwords "--secrets" + + # Check for existing secrets (--auto implies --force here) + if [ -d "$SECRETS_DIR" ] && [ "$FORCE_MODE" = "false" ] && [ "$AUTO_MODE" != "true" ]; then + warn "secrets/ directory already exists. Use --force to overwrite." + exit 1 + fi + + mkdir -p "$SECRETS_DIR" + + # Write each password to its own file (printf, not echo, to avoid trailing newline) + printf '%s' "$_pw_datomic" > "${SECRETS_DIR}/datomic_password" + printf '%s' "$_pw_admin" > "${SECRETS_DIR}/admin_password" + printf '%s' "$_pw_signature" > "${SECRETS_DIR}/signature" + chmod 600 "${SECRETS_DIR}"/* + + change "Created secrets/datomic_password" + change "Created secrets/admin_password" + change "Created secrets/signature" + change "File permissions set to 600" + + unset _pw_datomic _pw_admin _pw_signature + + # Generate compose override so secrets are wired in automatically + write_compose_secrets "file" + + # Move secret vars from .env to a backup file so they aren't duplicated + BACKUP_FILE="${ENV_FILE}.secrets.backup" + SECRET_VARS="DATOMIC_PASSWORD|ADMIN_PASSWORD|SIGNATURE" + _moved=0 + if grep -qE "^(${SECRET_VARS})=" "$ENV_FILE" 2>/dev/null; then + grep -E "^(${SECRET_VARS})=" "$ENV_FILE" >> "$BACKUP_FILE" + sed -i -E "/^(${SECRET_VARS})=/d" "$ENV_FILE" + _moved=1 + fi + + echo "" + success "Done! Passwords are in secrets/, compose is configured." + if [ "$_moved" -eq 1 ]; then + change "Moved DATOMIC_PASSWORD, ADMIN_PASSWORD, SIGNATURE from .env to .env.secrets.backup" + warn "Delete .env.secrets.backup after verifying secrets work." + fi + echo "" + next "docker compose down && docker compose up -d" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Swarm secrets (--swarm mode) +# --------------------------------------------------------------------------- +# Creates Docker secrets via `docker secret create` for use in Swarm clusters. +# Secrets are stored encrypted in the Swarm Raft log and delivered to +# containers in memory — never written to disk on any node. + +if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then + header "Swarm Secrets" + + # Verify this node is part of a Swarm — offer to initialize if not + _swarm_state=$(docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null || true) + if [ "$_swarm_state" != "active" ]; then + warn "This Docker node is not in Swarm mode." + echo "" + if [ "${AUTO_MODE}" = "true" ]; then + info "Initializing single-node Swarm (--auto)..." + docker swarm init 2>&1 || { error "Failed to initialize Swarm."; exit 1; } + change "Docker Swarm initialized." + else + read -rp "Initialize a single-node Swarm now? [Y/n]: " _init_swarm + if [[ "${_init_swarm,,}" =~ ^(y|)$ ]]; then + docker swarm init 2>&1 || { error "Failed to initialize Swarm."; exit 1; } + change "Docker Swarm initialized." + else + info "Run 'docker swarm init' manually, then re-run: ./docker-setup.sh --swarm" + exit 0 + fi + fi + fi + + read_passwords "--swarm" + + # Check for existing secrets and handle accordingly + _existing=0 + _created=0 + + for _name in datomic_password admin_password signature; do + if docker secret inspect "$_name" &>/dev/null; then + if [ "$FORCE_MODE" = "true" ]; then + # Swarm doesn't support updating secrets — must remove and recreate. + # Services using the secret must be stopped first. + docker secret rm "$_name" &>/dev/null || true + warn "Removed existing secret: $_name (--force)" + else + warn "Secret already exists: $_name (use --force to replace)" + _existing=$((_existing + 1)) + continue + fi + fi + + # Get the right password value for this secret name + case "$_name" in + datomic_password) _val="$_pw_datomic" ;; + admin_password) _val="$_pw_admin" ;; + signature) _val="$_pw_signature" ;; + esac + + if printf '%s' "$_val" | docker secret create "$_name" - &>/dev/null; then + change "Created Swarm secret: $_name" + _created=$((_created + 1)) + else + error "Failed to create Swarm secret: $_name" + exit 1 + fi + done + + unset _pw_datomic _pw_admin _pw_signature _val + + echo "" + if [ "$_existing" -gt 0 ] && [ "$FORCE_MODE" = "false" ]; then + warn "${_existing} secret(s) already existed (skipped)." + fi + if [ "$_created" -gt 0 ]; then + change "${_created} Swarm secret(s) created." + fi + + # Generate compose override so secrets are wired in automatically + write_compose_secrets "external" + + echo "" + success "Done! Swarm secrets created, compose is configured." + echo "" + echo "Next steps:" + echo "" + printf ' %sBUILD%s images:\n' "$color_cyan" "$color_reset" + next " ./docker-setup.sh --build" + echo "" + printf ' %sDEPLOY%s as a Swarm stack:\n' "$color_cyan" "$color_reset" + next " ./docker-setup.sh --deploy" + echo "" + info "Or both in one step: ./docker-setup.sh --build --deploy" + echo "" + echo "--- Tip: Managing secrets later ---" + echo " docker secret ls # List all secrets" + echo " docker secret inspect <name> # Show metadata (not the value)" + echo " docker secret rm <name> # Remove (stop services first)" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Upgrade existing .env (--upgrade mode) +# --------------------------------------------------------------------------- +# Reads the current .env, detects old patterns, fixes them, adds missing +# variables. Backs up the original first. No data loss. + +if [ "$UPGRADE_MODE" = "true" ]; then + header "Upgrade .env" + + if [ ! -f "$ENV_FILE" ]; then + warn "No .env file found." + info "Scanning config files for hardcoded values..." + + _compose="${SCRIPT_DIR}/docker-compose.yaml" + _transactor="${SCRIPT_DIR}/deploy/transactor.properties" + _found=0 + + # Declare associative arrays for values from each source + declare -A _compose_vals _transactor_vals + + # Scan docker-compose.yaml for hardcoded values (not ${...} templated) + if [ -f "$_compose" ]; then + for _var in DATOMIC_URL DATOMIC_PASSWORD ADMIN_PASSWORD SIGNATURE; do + _val=$(grep -E "^\s+${_var}:" "$_compose" | head -1 | sed "s/^[[:space:]]*${_var}: *//" | tr -d '\r') + if [ -n "$_val" ] && [ "${_val#'${'}" = "$_val" ]; then + _compose_vals[$_var]="$_val" + fi + done + fi + + # Scan deploy/transactor.properties for hardcoded passwords (not ${...} templated) + if [ -f "$_transactor" ]; then + for _prop in "storage-admin-password:ADMIN_PASSWORD" "storage-datomic-password:DATOMIC_PASSWORD"; do + _key="${_prop%%:*}" + _var="${_prop##*:}" + _val=$(grep -E "^${_key}=" "$_transactor" | head -1 | sed 's/.*=//' | tr -d '\r') + if [ -n "$_val" ] && [ "${_val#'${'}" = "$_val" ]; then + _transactor_vals[$_var]="$_val" + fi + done + fi + + # Build .env, checking for conflicts between sources + for _var in DATOMIC_URL DATOMIC_PASSWORD ADMIN_PASSWORD SIGNATURE; do + _cv="${_compose_vals[$_var]:-}" + _tv="${_transactor_vals[$_var]:-}" + + if [ -n "$_cv" ] && [ -n "$_tv" ] && [ "$_cv" != "$_tv" ]; then + echo "" + warn " ${_var}: CONFLICT between docker-compose.yaml and transactor.properties" + warn " 1) compose: ${_cv}" + warn " 2) transactor: ${_tv}" + if [ "${AUTO_MODE}" = "true" ]; then + warn " Using transactor value (that's what the database is actually using)" + echo "${_var}=${_tv}" >> "$ENV_FILE" + else + read -rp " Use which? [1] compose [2] transactor (recommended): " _choice + if [ "$_choice" = "1" ]; then + echo "${_var}=${_cv}" >> "$ENV_FILE" + change " Using compose value for ${_var}" + else + echo "${_var}=${_tv}" >> "$ENV_FILE" + change " Using transactor value for ${_var}" + fi + fi + _found=$((_found + 1)) + elif [ -n "$_tv" ]; then + echo "${_var}=${_tv}" >> "$ENV_FILE" + change " Found ${_var} in transactor.properties" + _found=$((_found + 1)) + elif [ -n "$_cv" ]; then + echo "${_var}=${_cv}" >> "$ENV_FILE" + change " Found ${_var} in docker-compose.yaml" + _found=$((_found + 1)) + fi + done + + if [ "$_found" -eq 0 ]; then + error "No hardcoded values found in docker-compose.yaml or transactor.properties." + error "For a new install, run: ./docker-setup.sh --auto" + exit 1 + fi + + chmod 600 "$ENV_FILE" + change "Created .env with ${_found} value(s) extracted from config files" + info "Continuing with upgrade to fill any gaps..." + echo "" + fi + + CHANGES=0 + BACKUP="${ENV_FILE}.backup.$(date +%s)" + cp "$ENV_FILE" "$BACKUP" + info "Backed up .env to $(basename "$BACKUP")" + + # Load current values + source_env "$ENV_FILE" + + _url=$(read_env_val DATOMIC_URL "$ENV_FILE") + _pw=$(read_env_val DATOMIC_PASSWORD "$ENV_FILE") + _sig=$(read_env_val SIGNATURE "$ENV_FILE") + _admin=$(read_env_val ADMIN_PASSWORD "$ENV_FILE") + + echo "" + + # --- Check 1: Password embedded in DATOMIC_URL --- + if [[ "$_url" == *"password="* ]]; then + # Extract password from URL + _url_pw=$(echo "$_url" | sed -n 's/.*[?&]password=\([^&]*\).*/\1/p') + _clean_url=$(echo "$_url" | sed 's/[?&]password=[^&]*//') + + if [ -n "$_url_pw" ]; then + change "Found password embedded in DATOMIC_URL — extracting" + + # If DATOMIC_PASSWORD exists and differs, warn + if [ -n "$_pw" ] && [ "$_pw" != "$_url_pw" ]; then + warn " DATOMIC_PASSWORD in .env differs from URL password!" + warn " Using the URL password (that's what the transactor is using)" + fi + + # Update the file + set_env_val DATOMIC_URL "$_clean_url" "$ENV_FILE" + set_env_val DATOMIC_PASSWORD "$_url_pw" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " DATOMIC_URL: password removed" + change " DATOMIC_PASSWORD: set to extracted password" + fi + else + info "DATOMIC_URL: OK (no embedded password)" + fi + + # --- Check 2: Missing DATOMIC_PASSWORD --- + if [ -z "$_pw" ] && ! grep -q '^DATOMIC_PASSWORD=' "$ENV_FILE"; then + if [ "${AUTO_MODE}" = "true" ]; then + _new_pw="$(generate_password 24)" + set_env_val DATOMIC_PASSWORD "$_new_pw" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new DATOMIC_PASSWORD (random)" + warn " IMPORTANT: Update your transactor to use this password too!" + else + warn " DATOMIC_PASSWORD is missing." + warn " This should match what your transactor uses." + read -rp " Generate a random one? [Y/n]: " _gen + if [[ "${_gen,,}" =~ ^(y|)$ ]]; then + _new_pw="$(generate_password 24)" + set_env_val DATOMIC_PASSWORD "$_new_pw" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new DATOMIC_PASSWORD (random)" + warn " IMPORTANT: Update your transactor to use this password too!" + fi + fi + else + info "DATOMIC_PASSWORD: OK" + fi + + # --- Check 3: Missing SIGNATURE --- + if [ -z "$_sig" ] && ! grep -q '^SIGNATURE=' "$ENV_FILE"; then + if [ "${AUTO_MODE}" = "true" ]; then + _new_sig="$(generate_password 32)" + set_env_val SIGNATURE "$_new_sig" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new SIGNATURE (random)" + warn " Note: Changing this later will log out all active users." + else + warn " SIGNATURE is missing (needed for login/API)." + read -rp " Generate a random one? [Y/n]: " _gen + if [[ "${_gen,,}" =~ ^(y|)$ ]]; then + _new_sig="$(generate_password 32)" + set_env_val SIGNATURE "$_new_sig" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new SIGNATURE (random)" + warn " Note: Changing this later will log out all active users." + fi + fi + else + info "SIGNATURE: OK" + fi + + # --- Check 4: Missing ADMIN_PASSWORD --- + if [ -z "$_admin" ] && ! grep -q '^ADMIN_PASSWORD=' "$ENV_FILE"; then + if [ "${AUTO_MODE}" = "true" ]; then + _new_admin="$(generate_password 24)" + set_env_val ADMIN_PASSWORD "$_new_admin" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new ADMIN_PASSWORD (random)" + else + warn " ADMIN_PASSWORD is missing." + read -rp " Generate a random one? [Y/n]: " _gen + if [[ "${_gen,,}" =~ ^(y|)$ ]]; then + _new_admin="$(generate_password 24)" + set_env_val ADMIN_PASSWORD "$_new_admin" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Generated new ADMIN_PASSWORD (random)" + fi + fi + else + info "ADMIN_PASSWORD: OK" + fi + + # --- Check 5: Old Free protocol → upgrade to dev --- + # Re-read URL after password extraction may have changed it + _url=$(read_env_val DATOMIC_URL "$ENV_FILE") + + if [[ "$_url" == *"datomic:free://"* ]]; then + _new_url="${_url/datomic:free:\/\//datomic:dev:\/\/}" + set_env_val DATOMIC_URL "$_new_url" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Changed datomic:free:// to datomic:dev:// (Datomic Pro)" + warn " If you have existing Free data, migrate it:" + warn " 1. Back up first: ./docker-migrate.sh backup" + warn " 2. Rebuild: docker compose up --build -d" + warn " 3. Restore: ./docker-migrate.sh restore" + warn " See docs/migration/datomic-data-migration.md for the full guide." + # Update for Check 6 + _url="$_new_url" + fi + + # --- Check 6: localhost → datomic (Docker service name) --- + if [[ "$_url" == *"localhost"* ]]; then + _new_url="${_url//localhost/datomic}" + set_env_val DATOMIC_URL "$_new_url" "$ENV_FILE" + CHANGES=$((CHANGES + 1)) + change " Changed 'localhost' to 'datomic' (Docker service name)" + fi + + # --- Check 7: Image tags (ORCPUB_IMAGE / DATOMIC_IMAGE) --- + _orcpub_img=$(read_env_val ORCPUB_IMAGE "$ENV_FILE") + _datomic_img=$(read_env_val DATOMIC_IMAGE "$ENV_FILE") + if [ -z "$_orcpub_img" ] && [ -z "$_datomic_img" ]; then + if [ "${AUTO_MODE}" != "true" ]; then + echo "" + info "No image tags set (builds use default names: orcpub-app, orcpub-datomic)" + _tag=$(prompt_value "Image tag to version your builds (leave empty to skip)" "") + if [ -n "$_tag" ]; then + set_env_val ORCPUB_IMAGE "orcpub-app:${_tag}" "$ENV_FILE" + set_env_val DATOMIC_IMAGE "orcpub-datomic:${_tag}" "$ENV_FILE" + CHANGES=$((CHANGES + 2)) + change " Set ORCPUB_IMAGE=orcpub-app:${_tag}" + change " Set DATOMIC_IMAGE=orcpub-datomic:${_tag}" + fi + fi + else + if [ "${AUTO_MODE}" != "true" ]; then + # Extract current tag from image name (e.g. "orcpub-app:v2.6" → "v2.6") + _current_tag="${_orcpub_img##*:}" + [ "$_current_tag" = "$_orcpub_img" ] && _current_tag="" + info "ORCPUB_IMAGE: ${_orcpub_img}" + info "DATOMIC_IMAGE: ${_datomic_img}" + _tag=$(prompt_value "Image tag" "${_current_tag}") + if [ -n "$_tag" ] && [ "$_tag" != "$_current_tag" ]; then + # Preserve base name, update tag + _orcpub_base="${_orcpub_img%%:*}" + _datomic_base="${_datomic_img%%:*}" + set_env_val ORCPUB_IMAGE "${_orcpub_base}:${_tag}" "$ENV_FILE" + set_env_val DATOMIC_IMAGE "${_datomic_base}:${_tag}" "$ENV_FILE" + CHANGES=$((CHANGES + 2)) + change " Set ORCPUB_IMAGE=${_orcpub_base}:${_tag}" + change " Set DATOMIC_IMAGE=${_datomic_base}:${_tag}" + else + info " Image tags unchanged." + fi + else + info "ORCPUB_IMAGE: ${_orcpub_img}" + info "DATOMIC_IMAGE: ${_datomic_img}" + fi + fi + + echo "" + if [ "$CHANGES" -gt 0 ]; then + change "${CHANGES} change(s) applied to .env" + else + info "No changes needed — .env is already up to date." + fi + info "Backup saved: $(basename "$BACKUP")" + + # --- Chain into secrets if combined (--upgrade-swarm / --upgrade-secrets) --- + if [ "$SWARM_MODE" = "true" ]; then + echo "" + info "Continuing to Swarm secrets setup..." + if [ "${AUTO_MODE}" = "true" ]; then + exec "$0" --swarm --auto + else + exec "$0" --swarm + fi + elif [ "$SECRETS_MODE" = "true" ]; then + echo "" + info "Continuing to secrets setup..." + if [ "${AUTO_MODE}" = "true" ]; then + exec "$0" --secrets --auto + else + exec "$0" --secrets + fi + fi + + # Standalone --upgrade: offer secrets interactively + if [ ! -d "${SCRIPT_DIR}/secrets" ]; then + echo "" + if [ "${AUTO_MODE}" = "true" ]; then + info "Tip: Run ./docker-setup.sh --upgrade-secrets or --upgrade-swarm to also" + info " move passwords out of .env in one step." + else + echo "" + read -rp "Move passwords to Docker secret files? (more secure) [y/N]: " _do_secrets + if [[ "${_do_secrets,,}" == "y" ]]; then + exec "$0" --secrets + fi + fi + else + info "Docker secrets: already configured (secrets/ exists)" + fi + + # Detect shell/env conflicts for the launch command + ENV_CONFLICTS=() + WARNING_MSGS=() + ERRORS=0 + for _var in DATOMIC_URL DATOMIC_PASSWORD SIGNATURE ADMIN_PASSWORD; do + check_env_conflict "$_var" + done + + echo "" + next "$(build_compose_cmd "docker compose up -d")" + exit 0 +fi + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- -header "Dungeon Master's Vault — Docker Setup" +header "Docker Setup" # ---- Step 1: .env file --------------------------------------------------- @@ -115,8 +1034,7 @@ if [ -f "$ENV_FILE" ] && [ "$FORCE_MODE" = "false" ]; then else # Source existing .env (if any) so current values become defaults for prompts if [ -f "$ENV_FILE" ]; then - # shellcheck disable=SC1090 - . "$ENV_FILE" + source_env "$ENV_FILE" fi header "Database Passwords" @@ -133,6 +1051,16 @@ else header "Application" PORT=$(prompt_value "Application port" "8890") + + # Image tag — used to version builds (e.g. "v2.6.0") + _image_tag=$(prompt_value "Image tag (leave empty for default)" "") + if [ -n "$_image_tag" ]; then + ORCPUB_IMAGE="orcpub-app:${_image_tag}" + DATOMIC_IMAGE="orcpub-datomic:${_image_tag}" + else + ORCPUB_IMAGE="" + DATOMIC_IMAGE="" + fi EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") EMAIL_ACCESS_KEY="" EMAIL_SECRET_KEY="" @@ -162,16 +1090,22 @@ else if [ -n "$INIT_ADMIN_USER" ] && [ -n "$INIT_ADMIN_EMAIL" ] && [ -n "$INIT_ADMIN_PASSWORD" ]; then info "Using admin user from environment: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" elif [ "${AUTO_MODE}" = "true" ]; then - info "No INIT_ADMIN_* variables set. Skipping admin user setup." - info "Create users later with: ./docker-user.sh create ..." + INIT_ADMIN_USER="admin" + INIT_ADMIN_EMAIL="admin@localhost" + INIT_ADMIN_PASSWORD=$(generate_password 16) + change "Generated admin user: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" + change "Generated admin password: ${INIT_ADMIN_PASSWORD}" + info "Change these in .env before going to production." else info "Optionally create an initial admin account." info "You can skip this and create users later with ./docker-user.sh" echo "" INIT_ADMIN_USER=$(prompt_value "Admin username (leave empty to skip)" "") if [ -n "$INIT_ADMIN_USER" ]; then - INIT_ADMIN_EMAIL=$(prompt_value "Admin email" "") - INIT_ADMIN_PASSWORD=$(prompt_value "Admin password" "") + _default_email="${INIT_ADMIN_USER}@example.com" + _default_pw="$(generate_password 16)" + INIT_ADMIN_EMAIL=$(prompt_value "Admin email" "$_default_email") + INIT_ADMIN_PASSWORD=$(prompt_value "Admin password" "$_default_pw") if [ -z "$INIT_ADMIN_EMAIL" ] || [ -z "$INIT_ADMIN_PASSWORD" ]; then warn "Email and password are required. Skipping admin user setup." INIT_ADMIN_USER="" @@ -185,20 +1119,26 @@ else cat > "$ENV_FILE" <<EOF # ============================================================================ -# Dungeon Master's Vault — Docker Environment Configuration +# Docker Environment Configuration # Generated by docker-setup.sh on $(date -u +"%Y-%m-%d %H:%M:%S UTC") # ============================================================================ # --- Application --- PORT=${PORT} +# --- Docker Images --- +# Set these to version your builds (e.g. orcpub-app:v2.6.0) +# Leave empty to use default names (orcpub-app, orcpub-datomic) +ORCPUB_IMAGE=${ORCPUB_IMAGE} +DATOMIC_IMAGE=${DATOMIC_IMAGE} + # --- Datomic Database --- # ADMIN_PASSWORD secures the Datomic admin interface -# DATOMIC_PASSWORD is used by the application to connect to Datomic -# The password in DATOMIC_URL must match DATOMIC_PASSWORD +# DATOMIC_PASSWORD is shared by transactor and app — the app appends it +# to DATOMIC_URL automatically at startup (no need to embed in the URL) ADMIN_PASSWORD=${ADMIN_PASSWORD} DATOMIC_PASSWORD=${DATOMIC_PASSWORD} -DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=${DATOMIC_PASSWORD} +DATOMIC_URL=datomic:dev://datomic:4334/orcpub # --- Transactor Tuning --- # These rarely need changing. See docker/transactor.properties.template. @@ -236,7 +1176,7 @@ INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD} EOF chmod 600 "$ENV_FILE" - info ".env file created at ${ENV_FILE} (permissions: 600)" + change ".env file created at ${ENV_FILE} (permissions: 600)" fi # ---- Step 2: Directories ------------------------------------------------- @@ -246,7 +1186,7 @@ header "Directories" for dir in "${SCRIPT_DIR}/data" "${SCRIPT_DIR}/logs" "${SCRIPT_DIR}/backups" "${SCRIPT_DIR}/deploy/homebrew"; do if [ ! -d "$dir" ]; then mkdir -p "$dir" - info "Created directory: ${dir#"${SCRIPT_DIR}"/}" + change "Created directory: ${dir#"${SCRIPT_DIR}"/}" else info "Directory exists: ${dir#"${SCRIPT_DIR}"/}" fi @@ -273,7 +1213,7 @@ else -keyout "$KEY_FILE" \ -out "$CERT_FILE" \ 2>/dev/null - info "SSL certificate generated (valid for 365 days)." + change "SSL certificate generated (valid for 365 days)." else warn "openssl not found — cannot generate SSL certificates." warn "Install openssl and run: ./deploy/snakeoil.sh" @@ -285,46 +1225,42 @@ fi header "Validation" ERRORS=0 +WARNING_MSGS=() +ENV_CONFLICTS=() -check_file() { - local label="$1" path="$2" - if [ -f "$path" ]; then - info " ${label}: OK" - else - warn " ${label}: MISSING (${path})" - ERRORS=$((ERRORS + 1)) - fi -} - -check_dir() { - local label="$1" path="$2" - if [ -d "$path" ]; then - info " ${label}: OK" - else - warn " ${label}: MISSING (${path})" - ERRORS=$((ERRORS + 1)) - fi -} - -# Validate DATOMIC_PASSWORD matches the password in DATOMIC_URL +# Validate DATOMIC_PASSWORD vs DATOMIC_URL if [ -f "$ENV_FILE" ]; then - # Read specific values without polluting current shell namespace - _env_datomic_pw=$(grep -m1 '^DATOMIC_PASSWORD=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-) - _env_datomic_url=$(grep -m1 '^DATOMIC_URL=' "$ENV_FILE" 2>/dev/null | cut -d= -f2-) + _env_datomic_pw=$(read_env_val DATOMIC_PASSWORD "$ENV_FILE") + _env_datomic_url=$(read_env_val DATOMIC_URL "$ENV_FILE") if [ -n "$_env_datomic_pw" ] && [ -n "$_env_datomic_url" ]; then - if [[ "$_env_datomic_url" != *"password=${_env_datomic_pw}"* ]]; then - warn " DATOMIC_PASSWORD does not match the password in DATOMIC_URL" - ERRORS=$((ERRORS + 1)) + if [[ "$_env_datomic_url" == *"password="* ]]; then + # Legacy: password embedded in URL — check it matches DATOMIC_PASSWORD + if [[ "$_env_datomic_url" != *"password=${_env_datomic_pw}"* ]]; then + warn " DATOMIC_URL has an embedded password that doesn't match DATOMIC_PASSWORD" + warn " Remove ?password= from DATOMIC_URL — the app adds it from DATOMIC_PASSWORD automatically" + WARNING_MSGS+=("DATOMIC_URL embedded password doesn't match DATOMIC_PASSWORD") + ERRORS=$((ERRORS + 1)) + else + info " DATOMIC_URL password: OK (embedded — consider removing ?password= from URL)" + fi else - info " DATOMIC_URL password: OK" + # Modern: password separate — the app appends it at startup + info " DATOMIC_PASSWORD: OK (app will append to URL at startup)" fi fi unset _env_datomic_pw _env_datomic_url fi +if [ -f "$ENV_FILE" ]; then + check_env_conflict DATOMIC_URL + check_env_conflict SIGNATURE + check_env_conflict ADMIN_PASSWORD + check_env_conflict DATOMIC_PASSWORD +fi + check_file ".env" "$ENV_FILE" check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" -check_file "nginx.conf" "${SCRIPT_DIR}/deploy/nginx.conf" +check_file "nginx.conf.template" "${SCRIPT_DIR}/deploy/nginx.conf.template" check_file "SSL certificate" "$CERT_FILE" check_file "SSL key" "$KEY_FILE" check_dir "data/" "${SCRIPT_DIR}/data" @@ -344,29 +1280,53 @@ fi header "Next Steps" -cat <<'NEXT' +COMPOSE_CMD=$(build_compose_cmd "docker compose up --build -d") + +cat <<NEXT 1. Review your .env file and adjust values if needed. -2. Launch the application: - docker compose up -d +2. Build and launch: + ${COMPOSE_CMD} + + First build takes ~10 minutes. Subsequent builds use cache. -3. Create your first user (once containers are running): - ./docker-user.sh init # uses admin from .env +3. Wait for healthy (app takes ~2 minutes to boot): + docker compose ps + +4. Create your first user (once all services show "healthy"): + ./docker-user.sh init # uses INIT_ADMIN_* from .env ./docker-user.sh create <username> <email> <password> # or specify directly -4. Access the site at: +5. Access the site at: https://localhost -5. Manage users later with: +6. Manage users later with: ./docker-user.sh list # List all users ./docker-user.sh check <user> # Check a user's status ./docker-user.sh verify <user> # Verify an unverified user -6. To import homebrew content, place your .orcbrew file at: +7. To import homebrew content, place your .orcbrew file at: deploy/homebrew/homebrew.orcbrew -7. To build from source instead of pulling images: - docker compose up --build -d - -For more details, see README.md. +For more details, see docs/DOCKER.md NEXT + +# ---- Final status banner ---------------------------------------------------- + +echo "" +if [ "$ERRORS" -gt 0 ]; then + printf '%s!!! %d WARNING(S) — review before starting !!!%s\n' "$color_yellow" "$ERRORS" "$color_reset" + for msg in "${WARNING_MSGS[@]}"; do + printf ' %s• %s%s\n' "$color_yellow" "$msg" "$color_reset" + done + if [ "${#ENV_CONFLICTS[@]}" -gt 0 ]; then + echo "" + printf ' %sTo launch with .env values:%s\n' "$color_cyan" "$color_reset" + printf ' %s\n' "$COMPOSE_CMD" + fi + echo "" +else + success "SUCCESS — ready to launch" + printf ' %s\n' "$COMPOSE_CMD" + echo "" +fi diff --git a/docker-user.sh b/docker-user.sh index 914ed1bc4..ae3d80b8f 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -24,10 +24,10 @@ MANAGE_SCRIPT="${SCRIPT_DIR}/docker/scripts/manage-user.clj" # Helpers # --------------------------------------------------------------------------- -color_green='\033[0;32m' -color_red='\033[0;31m' -color_yellow='\033[1;33m' -color_reset='\033[0m' +color_green=$'\033[0;32m' +color_red=$'\033[0;31m' +color_yellow=$'\033[1;33m' +color_reset=$'\033[0m' info() { printf '%s[OK]%s %s\n' "$color_green" "$color_reset" "$*"; } error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } @@ -244,9 +244,9 @@ if [ "${1:-}" = "init" ]; then exit 1 fi - # Source .env to get INIT_ADMIN_* variables + # Source .env to get INIT_ADMIN_* variables (tr -d '\r' for Windows line endings) # shellcheck disable=SC1090 - . "$ENV_FILE" + . <(tr -d '\r' < "$ENV_FILE") if [ -z "${INIT_ADMIN_USER:-}" ]; then error "INIT_ADMIN_USER is not set in .env" diff --git a/test/docker/README.md b/test/docker/README.md new file mode 100644 index 000000000..937cefb5d --- /dev/null +++ b/test/docker/README.md @@ -0,0 +1,52 @@ +# Docker Setup Tests + +Manual and automated tests for `docker-setup.sh` and `docker-user.sh`. + +## Scripts + +| Script | Purpose | +|---|---| +| `reset-test.sh [scenario]` | Reset environment to a clean test state | +| `test-upgrade.sh` | Automated upgrade tests (46 assertions, no Docker daemon needed) | + +## Reset Scenarios + +```bash +./test/docker/reset-test.sh fresh # No .env, templated compose (default) +./test/docker/reset-test.sh conflict # No .env, hardcoded compose vs transactor +./test/docker/reset-test.sh upgrade # Old v1 .env (free protocol, localhost) +./test/docker/reset-test.sh secrets # Modern .env with passwords, ready for --secrets +``` + +## Fixtures + +Test `.env` files representing real-world configurations: + +| Fixture | Scenario | +|---|---| +| `env-v1-free-localhost.env` | Original orcpub: Free protocol, localhost, password in URL | +| `env-v2-missing-vars.env` | Hand-edited: has some vars, missing others | +| `env-v2-password-in-url.env` | Early DMV: password still embedded in URL | +| `env-v2-password-mismatch.env` | URL password differs from DATOMIC_PASSWORD | +| `env-v2-windows-crlf.env` | Windows-edited with CRLF line endings | +| `env-v3-current.env` | Current format, already up to date | +| `env-production-like.env` | Production-like with SMTP and admin configured | +| `compose-hardcoded.yaml` | docker-compose.yaml with hardcoded values (no templating) | + +## Manual Test Flow + +```bash +# New install +./test/docker/reset-test.sh fresh +./docker-setup.sh --auto +docker compose up --build -d +./docker-user.sh init + +# Upgrade + secrets +./test/docker/reset-test.sh upgrade +./docker-setup.sh --upgrade-secrets --auto + +# Upgrade + swarm (conflict detection) +./test/docker/reset-test.sh conflict +./docker-setup.sh --upgrade-swarm --auto +``` diff --git a/test/docker/fixtures/compose-hardcoded.yaml b/test/docker/fixtures/compose-hardcoded.yaml new file mode 100644 index 000000000..997d6ea71 --- /dev/null +++ b/test/docker/fixtures/compose-hardcoded.yaml @@ -0,0 +1,19 @@ +# Fixture: docker-compose.yaml with hardcoded values (no .env templating) +# Simulates an admin who edited docker-compose.yaml directly instead of using .env +# Usage: cp this over docker-compose.yaml, remove .env, run --upgrade +# +# To test: make sure no .env exists, then: +# cp test/docker/fixtures/compose-hardcoded.yaml docker-compose.yaml +# ./docker-setup.sh --upgrade --auto +# +# Expected: script extracts hardcoded values into a new .env, then upgrades it +services: + orcpub: + environment: + DATOMIC_URL: datomic:free://localhost:4334/orcpub?password=hardcoded123 + DATOMIC_PASSWORD: hardcoded123 + SIGNATURE: my-secret-signature-key + datomic: + environment: + ADMIN_PASSWORD: hardcoded-admin-pw + DATOMIC_PASSWORD: hardcoded123 diff --git a/test/docker/fixtures/env-production-like.env b/test/docker/fixtures/env-production-like.env new file mode 100644 index 000000000..b78fc9985 --- /dev/null +++ b/test/docker/fixtures/env-production-like.env @@ -0,0 +1,32 @@ +# Scenario: Production-like .env (mirrors dmv/hotfix origin) +# - Password embedded in DATOMIC_URL (the pattern live was using) +# - All vars present but using the old URL-embedded-password style +# - Has SMTP configured +# - Has admin user configured +# +# Expected --upgrade results: +# - Extract password from URL into DATOMIC_PASSWORD +# - Clean URL (remove ?password=) +# - SIGNATURE: OK +# - ADMIN_PASSWORD: OK + +PORT=8890 +ADMIN_PASSWORD=prod-admin-placeholder +DATOMIC_PASSWORD=prod-db-placeholder +DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=prod-db-placeholder +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=prod-signature-placeholder-long-enough +CSP_POLICY=strict +DEV_MODE=false +EMAIL_SERVER_URL=smtp.production.example.com +EMAIL_ACCESS_KEY=smtp-user@production.example.com +EMAIL_SECRET_KEY=smtp-password-placeholder +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS=noreply@production.example.com +EMAIL_ERRORS_TO=ops@production.example.com +EMAIL_SSL=FALSE +EMAIL_TLS=TRUE +INIT_ADMIN_USER=dmadmin +INIT_ADMIN_EMAIL=admin@production.example.com +INIT_ADMIN_PASSWORD=admin-password-placeholder diff --git a/test/docker/fixtures/env-v1-free-localhost.env b/test/docker/fixtures/env-v1-free-localhost.env new file mode 100644 index 000000000..56f0b455a --- /dev/null +++ b/test/docker/fixtures/env-v1-free-localhost.env @@ -0,0 +1,21 @@ +# Scenario: Original orcpub .env (pre-DMV, pre-Docker rework) +# - Datomic Free protocol (not Pro) +# - localhost instead of Docker service name +# - Password embedded in DATOMIC_URL +# - No separate DATOMIC_PASSWORD +# - No SIGNATURE +# - No ADMIN_PASSWORD +# - Old email var names (EMAIL_HOST vs EMAIL_SERVER_URL) +# - Port 8080 (old default) +# +# Expected --upgrade results: +# - Extract password from URL into DATOMIC_PASSWORD +# - Clean URL (remove ?password=) +# - Convert datomic:free:// to datomic:dev:// (Datomic Pro) +# - Convert localhost to datomic (Docker service name) +# - Final URL: datomic:dev://datomic:4334/orcpub +# - Add SIGNATURE (auto-generate) +# - Add ADMIN_PASSWORD (auto-generate) + +DATOMIC_URL=datomic:free://localhost:4334/orcpub?password=changeme +PORT=8080 diff --git a/test/docker/fixtures/env-v2-missing-vars.env b/test/docker/fixtures/env-v2-missing-vars.env new file mode 100644 index 000000000..9f8cc7c57 --- /dev/null +++ b/test/docker/fixtures/env-v2-missing-vars.env @@ -0,0 +1,24 @@ +# Scenario: Partial .env — someone hand-edited and missed variables +# - Has DATOMIC_URL (clean, no embedded password) — good +# - Missing DATOMIC_PASSWORD entirely +# - Missing SIGNATURE entirely +# - Has ADMIN_PASSWORD +# +# Expected --upgrade results: +# - DATOMIC_URL: OK +# - Warn DATOMIC_PASSWORD not found (generate in --auto) +# - Warn SIGNATURE not found (generate in --auto) +# - ADMIN_PASSWORD: OK + +ADMIN_PASSWORD=MyAdm1nP4ss +DATOMIC_URL=datomic:dev://datomic:4334/orcpub +ALT_HOST=127.0.0.1 +PORT=8890 +EMAIL_SERVER_URL=smtp.example.com +EMAIL_ACCESS_KEY=user@example.com +EMAIL_SECRET_KEY=smtp-password-here +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS=noreply@example.com +EMAIL_ERRORS_TO=errors@example.com +EMAIL_SSL=FALSE +EMAIL_TLS=TRUE diff --git a/test/docker/fixtures/env-v2-password-in-url.env b/test/docker/fixtures/env-v2-password-in-url.env new file mode 100644 index 000000000..38a131bd3 --- /dev/null +++ b/test/docker/fixtures/env-v2-password-in-url.env @@ -0,0 +1,31 @@ +# Scenario: Early DMV .env (password still embedded in URL) +# - Datomic Pro dev protocol (correct) +# - Docker service name "datomic" (correct) +# - Password embedded in DATOMIC_URL (old pattern) +# - Has DATOMIC_PASSWORD but it matches URL password +# - Has SIGNATURE and ADMIN_PASSWORD +# +# Expected --upgrade results: +# - Extract password from URL into DATOMIC_PASSWORD +# - Clean URL (remove ?password=) +# - SIGNATURE: OK +# - ADMIN_PASSWORD: OK + +ADMIN_PASSWORD=MyAdm1nP4ss +DATOMIC_PASSWORD=MyD4tom1cP4ss +DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=MyD4tom1cP4ss +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=a7b3c9d2e1f4a8b6c3d7e2f5a9b4c8d1 +PORT=8890 +EMAIL_SERVER_URL= +EMAIL_ACCESS_KEY= +EMAIL_SECRET_KEY= +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS= +EMAIL_ERRORS_TO= +EMAIL_SSL=FALSE +EMAIL_TLS=FALSE +INIT_ADMIN_USER=admin +INIT_ADMIN_EMAIL=admin@example.com +INIT_ADMIN_PASSWORD=Admin123 diff --git a/test/docker/fixtures/env-v2-password-mismatch.env b/test/docker/fixtures/env-v2-password-mismatch.env new file mode 100644 index 000000000..c45238de5 --- /dev/null +++ b/test/docker/fixtures/env-v2-password-mismatch.env @@ -0,0 +1,18 @@ +# Scenario: Early DMV .env with mismatched passwords +# - Password in URL differs from DATOMIC_PASSWORD variable +# - This happens when someone changed one but not the other +# +# Expected --upgrade results: +# - Warn about mismatch +# - Use URL password (that's what the transactor is actually using) +# - Clean URL +# - SIGNATURE: OK +# - ADMIN_PASSWORD: OK + +ADMIN_PASSWORD=MyAdm1nP4ss +DATOMIC_PASSWORD=old-password-I-forgot-to-update +DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=the-REAL-password +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=a7b3c9d2e1f4a8b6c3d7e2f5a9b4c8d1 +PORT=8890 diff --git a/test/docker/fixtures/env-v2-windows-crlf.env b/test/docker/fixtures/env-v2-windows-crlf.env new file mode 100644 index 000000000..c6050a71d --- /dev/null +++ b/test/docker/fixtures/env-v2-windows-crlf.env @@ -0,0 +1,17 @@ +# Scenario: Windows-edited .env with CRLF line endings +# - Every line has \r\n instead of \n +# - Password embedded in URL (old pattern) +# - This tests that our CRLF stripping works in --upgrade +# +# Expected --upgrade results: +# - CRLF handled transparently +# - Extract password from URL (no trailing \r corruption) +# - All checks pass same as env-v2-password-in-url + +ADMIN_PASSWORD=WinAdm1nP4ss +DATOMIC_PASSWORD=WinD4tom1cP4ss +DATOMIC_URL=datomic:dev://datomic:4334/orcpub?password=WinD4tom1cP4ss +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=w1nd0ws3d1t3d4s1gn4tur3k3y0000 +PORT=8890 diff --git a/test/docker/fixtures/env-v3-current.env b/test/docker/fixtures/env-v3-current.env new file mode 100644 index 000000000..73f6e6ebe --- /dev/null +++ b/test/docker/fixtures/env-v3-current.env @@ -0,0 +1,30 @@ +# Scenario: Current format — already up to date +# - Clean URL (no embedded password) +# - All required vars present +# - Should pass --upgrade with "No changes needed" +# +# Expected --upgrade results: +# - DATOMIC_URL: OK +# - DATOMIC_PASSWORD: OK +# - SIGNATURE: OK +# - ADMIN_PASSWORD: OK +# - No changes needed + +ADMIN_PASSWORD=xK7mN2pQ9rT4vW8yB3cF6hJ +DATOMIC_PASSWORD=aL5nR8sU2wY4bD7fH9jM3qT6 +DATOMIC_URL=datomic:dev://datomic:4334/orcpub +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +SIGNATURE=gZ4kP7tX2vA8dG5jN9qS3wF6mC1rY8bH +PORT=8890 +EMAIL_SERVER_URL= +EMAIL_ACCESS_KEY= +EMAIL_SECRET_KEY= +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS= +EMAIL_ERRORS_TO= +EMAIL_SSL=FALSE +EMAIL_TLS=FALSE +INIT_ADMIN_USER=admin +INIT_ADMIN_EMAIL=admin@example.com +INIT_ADMIN_PASSWORD=TestAdmin123 diff --git a/test/docker/reset-test.sh b/test/docker/reset-test.sh new file mode 100755 index 000000000..cf3d3d7dc --- /dev/null +++ b/test/docker/reset-test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Reset Docker test environment to a clean state. +# Usage: ./test/docker/reset-test.sh [scenario] +# +# Scenarios: +# fresh — no .env, templated compose (default) +# conflict — no .env, hardcoded compose values that conflict with transactor +# upgrade — old-format .env (v1-free-localhost), templated compose +# secrets — modern .env with passwords, ready for --secrets +# +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$SCRIPT_DIR" + +scenario="${1:-fresh}" + +# --- Clean everything --- +rm -f .env .env.backup.* .env.secrets.backup +rm -rf secrets/ docker-compose.secrets.yaml +docker secret rm datomic_password admin_password signature 2>/dev/null || true +git checkout docker-compose.yaml 2>/dev/null || true + +case "$scenario" in + fresh) + echo "Reset: fresh (no .env, templated compose)" + ;; + conflict) + echo "Reset: conflict (hardcoded compose vs transactor)" + sed -i 's|DATOMIC_URL: ${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub}|DATOMIC_URL: datomic:free://localhost:4334/orcpub?password=compose-pass|' docker-compose.yaml + sed -i 's|DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me}|DATOMIC_PASSWORD: compose-pass|g' docker-compose.yaml + sed -i 's|SIGNATURE: ${SIGNATURE:-change-me-to-something-unique}|SIGNATURE: compose-signature-value|' docker-compose.yaml + sed -i 's|ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin}|ADMIN_PASSWORD: compose-admin-pw|' docker-compose.yaml + ;; + upgrade) + echo "Reset: upgrade (old v1 .env)" + cp test/docker/fixtures/env-v1-free-localhost.env .env + ;; + secrets) + echo "Reset: secrets (modern .env with passwords)" + cp test/docker/fixtures/env-v3-current.env .env + ;; + *) + echo "Unknown scenario: $scenario" + echo "Options: fresh, conflict, upgrade, secrets" + exit 1 + ;; +esac +echo "Done." diff --git a/test/docker/test-upgrade.sh b/test/docker/test-upgrade.sh new file mode 100755 index 000000000..1de6ed5d4 --- /dev/null +++ b/test/docker/test-upgrade.sh @@ -0,0 +1,318 @@ +#!/usr/bin/env bash +# +# Test docker-setup.sh --upgrade against historical .env formats. +# +# Copies each fixture to a temp directory as .env, runs --upgrade --auto, +# then validates the result. No Docker daemon needed — only tests the +# .env transformation logic. +# +# Usage: +# ./test/docker/test-upgrade.sh # Run all fixtures +# ./test/docker/test-upgrade.sh <name> # Run one fixture (e.g., "v1-free-localhost") + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +FIXTURE_DIR="${SCRIPT_DIR}/fixtures" +SETUP_SCRIPT="${PROJECT_ROOT}/docker-setup.sh" + +# Colors +green='\033[0;32m' +red='\033[0;31m' +yellow='\033[1;33m' +cyan='\033[0;36m' +reset='\033[0m' + +pass() { printf '%sPASS%s %s\n' "$green" "$reset" "$*"; } +fail() { printf '%sFAIL%s %s\n' "$red" "$reset" "$*"; FAILURES=$((FAILURES + 1)); } +info() { printf '%sINFO%s %s\n' "$cyan" "$reset" "$*"; } +warn() { printf '%sWARN%s %s\n' "$yellow" "$reset" "$*"; } + +TESTS=0 +FAILURES=0 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Read a value from an .env file (same logic as docker-setup.sh) +read_val() { + grep -m1 "^${1}=" "$2" 2>/dev/null | cut -d= -f2- | tr -d '\r' +} + +# Check a value in the upgraded .env +assert_val() { + local var="$1" expected="$2" file="$3" label="${4:-}" + local actual + actual=$(read_val "$var" "$file") + + if [ "$actual" = "$expected" ]; then + pass "${label}${var} = ${expected}" + else + fail "${label}${var}: expected '${expected}', got '${actual}'" + fi + TESTS=$((TESTS + 1)) +} + +# Check that a variable exists (any value) +assert_exists() { + local var="$1" file="$2" label="${3:-}" + if grep -q "^${var}=" "$file"; then + pass "${label}${var} exists" + else + fail "${label}${var} missing" + fi + TESTS=$((TESTS + 1)) +} + +# Check that a variable does NOT exist +assert_missing() { + local var="$1" file="$2" label="${3:-}" + if grep -q "^${var}=" "$file"; then + fail "${label}${var} should not exist" + else + pass "${label}${var} absent (expected)" + fi + TESTS=$((TESTS + 1)) +} + +# Check that a value does NOT contain a substring +assert_not_contains() { + local var="$1" substring="$2" file="$3" label="${4:-}" + local actual + actual=$(read_val "$var" "$file") + if [[ "$actual" == *"$substring"* ]]; then + fail "${label}${var} should not contain '${substring}' (got '${actual}')" + else + pass "${label}${var} does not contain '${substring}'" + fi + TESTS=$((TESTS + 1)) +} + +# Check that output contains a string +assert_output_contains() { + local needle="$1" output="$2" label="${3:-}" + if echo "$output" | grep -qF "$needle"; then + pass "${label}output contains '${needle}'" + else + fail "${label}output missing '${needle}'" + fi + TESTS=$((TESTS + 1)) +} + +# --------------------------------------------------------------------------- +# Run one fixture through --upgrade --auto +# --------------------------------------------------------------------------- + +run_fixture() { + local fixture_file="$1" + local fixture_name + fixture_name=$(basename "$fixture_file" .env | sed 's/^env-//') + + local tmpdir + tmpdir=$(mktemp -d) + # Don't trap RETURN — we clean up at the end of the function + + # Copy fixture as .env and the setup script into tmpdir. + # docker-setup.sh uses SCRIPT_DIR (dirname of the script) to find .env, + # so the script must live next to the .env for it to find the fixture. + cp "$fixture_file" "${tmpdir}/.env" + cp "$SETUP_SCRIPT" "${tmpdir}/docker-setup.sh" + + local output + output=$(bash "${tmpdir}/docker-setup.sh" --upgrade --auto 2>&1) || true + + local result_env="${tmpdir}/.env" + local label="[${fixture_name}] " + + printf '\n%s--- %s ---%s\n' "$cyan" "$fixture_name" "$reset" + + case "$fixture_name" in + + v1-free-localhost) + # Password extracted from URL + assert_exists DATOMIC_PASSWORD "$result_env" "$label" + assert_val DATOMIC_PASSWORD "changeme" "$result_env" "$label" + # URL cleaned: no password, free→dev, localhost→datomic + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + assert_not_contains DATOMIC_URL "datomic:free://" "$result_env" "$label" + assert_not_contains DATOMIC_URL "localhost" "$result_env" "$label" + assert_val DATOMIC_URL "datomic:dev://datomic:4334/orcpub" "$result_env" "$label" + # Output mentions what was fixed + assert_output_contains "datomic:free://" "$output" "$label" + assert_output_contains "localhost" "$output" "$label" + # SIGNATURE and ADMIN_PASSWORD auto-generated + assert_exists SIGNATURE "$result_env" "$label" + assert_exists ADMIN_PASSWORD "$result_env" "$label" + ;; + + v2-password-in-url) + # Password extracted, URL cleaned + assert_val DATOMIC_PASSWORD "MyD4tom1cP4ss" "$result_env" "$label" + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + # Existing vars untouched + assert_val ADMIN_PASSWORD "MyAdm1nP4ss" "$result_env" "$label" + assert_val SIGNATURE "a7b3c9d2e1f4a8b6c3d7e2f5a9b4c8d1" "$result_env" "$label" + assert_val PORT "8890" "$result_env" "$label" + ;; + + v2-password-mismatch) + # URL password wins over DATOMIC_PASSWORD + assert_val DATOMIC_PASSWORD "the-REAL-password" "$result_env" "$label" + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + # Warn about mismatch + assert_output_contains "differs" "$output" "$label" + ;; + + v2-missing-vars) + # URL was already clean + assert_val DATOMIC_URL "datomic:dev://datomic:4334/orcpub" "$result_env" "$label" + # Missing vars auto-generated + assert_exists DATOMIC_PASSWORD "$result_env" "$label" + assert_exists SIGNATURE "$result_env" "$label" + # Existing var untouched + assert_val ADMIN_PASSWORD "MyAdm1nP4ss" "$result_env" "$label" + # SMTP config preserved + assert_val EMAIL_SERVER_URL "smtp.example.com" "$result_env" "$label" + ;; + + v2-windows-crlf) + # CRLF shouldn't corrupt values + assert_val DATOMIC_PASSWORD "WinD4tom1cP4ss" "$result_env" "$label" + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + assert_val SIGNATURE "w1nd0ws3d1t3d4s1gn4tur3k3y0000" "$result_env" "$label" + assert_val ADMIN_PASSWORD "WinAdm1nP4ss" "$result_env" "$label" + ;; + + v3-current) + # No changes needed + assert_output_contains "No changes needed" "$output" "$label" + # All values preserved exactly + assert_val DATOMIC_PASSWORD "aL5nR8sU2wY4bD7fH9jM3qT6" "$result_env" "$label" + assert_val DATOMIC_URL "datomic:dev://datomic:4334/orcpub" "$result_env" "$label" + assert_val SIGNATURE "gZ4kP7tX2vA8dG5jN9qS3wF6mC1rY8bH" "$result_env" "$label" + assert_val ADMIN_PASSWORD "xK7mN2pQ9rT4vW8yB3cF6hJ" "$result_env" "$label" + ;; + + production-like) + # Password extracted, URL cleaned + assert_val DATOMIC_PASSWORD "prod-db-placeholder" "$result_env" "$label" + assert_not_contains DATOMIC_URL "password=" "$result_env" "$label" + # Non-password config preserved + assert_val ADMIN_PASSWORD "prod-admin-placeholder" "$result_env" "$label" + assert_val SIGNATURE "prod-signature-placeholder-long-enough" "$result_env" "$label" + assert_val EMAIL_SERVER_URL "smtp.production.example.com" "$result_env" "$label" + assert_val INIT_ADMIN_USER "dmadmin" "$result_env" "$label" + assert_val DEV_MODE "false" "$result_env" "$label" + ;; + + *) + warn "No assertions defined for fixture: $fixture_name" + ;; + esac + + rm -rf "$tmpdir" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +if [ ! -f "$SETUP_SCRIPT" ]; then + echo "docker-setup.sh not found at: $SETUP_SCRIPT" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Test --secrets compose override generation +# --------------------------------------------------------------------------- + +run_secrets_test() { + printf '\n%s--- secrets (compose override) ---%s\n' "$cyan" "$reset" + + local tmpdir + tmpdir=$(mktemp -d) + + # Use v3-current as the base .env (already up to date) + cp "${FIXTURE_DIR}/env-v3-current.env" "${tmpdir}/.env" + cp "$SETUP_SCRIPT" "${tmpdir}/docker-setup.sh" + + local output + output=$(bash "${tmpdir}/docker-setup.sh" --secrets --auto 2>&1) || true + + local label="[secrets] " + + # Secret files created + if [ -f "${tmpdir}/secrets/datomic_password" ]; then + pass "${label}secrets/datomic_password created" + else + fail "${label}secrets/datomic_password missing" + fi + TESTS=$((TESTS + 1)) + + if [ -f "${tmpdir}/secrets/signature" ]; then + pass "${label}secrets/signature created" + else + fail "${label}secrets/signature missing" + fi + TESTS=$((TESTS + 1)) + + # Compose override created + if [ -f "${tmpdir}/docker-compose.secrets.yaml" ]; then + pass "${label}docker-compose.secrets.yaml created" + else + fail "${label}docker-compose.secrets.yaml missing" + fi + TESTS=$((TESTS + 1)) + + # Compose override has file-based secrets (not external) + if [ -f "${tmpdir}/docker-compose.secrets.yaml" ]; then + assert_output_contains "file: ./secrets/datomic_password" \ + "$(cat "${tmpdir}/docker-compose.secrets.yaml")" "$label" + assert_output_contains "file: ./secrets/signature" \ + "$(cat "${tmpdir}/docker-compose.secrets.yaml")" "$label" + fi + + # COMPOSE_FILE added to .env + assert_exists COMPOSE_FILE "${tmpdir}/.env" "$label" + assert_val COMPOSE_FILE "docker-compose.yaml:docker-compose.secrets.yaml" \ + "${tmpdir}/.env" "$label" + + rm -rf "$tmpdir" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Run specific fixture or all +if [ $# -gt 0 ]; then + if [ "$1" = "secrets" ]; then + run_secrets_test + else + fixture="${FIXTURE_DIR}/env-${1}.env" + if [ ! -f "$fixture" ]; then + echo "Fixture not found: $fixture" >&2 + echo "Available:" >&2 + ls "$FIXTURE_DIR"/*.env 2>/dev/null | sed 's/.*env-/ /;s/\.env$//' >&2 + echo " secrets" >&2 + exit 1 + fi + run_fixture "$fixture" + fi +else + for f in "$FIXTURE_DIR"/env-*.env; do + run_fixture "$f" + done + run_secrets_test +fi + +# Summary +printf '\n%s===%s Results: %d tests, ' "$cyan" "$reset" "$TESTS" +if [ "$FAILURES" -eq 0 ]; then + printf '%s0 failures%s\n' "$green" "$reset" +else + printf '%s%d failure(s)%s\n' "$red" "$FAILURES" "$reset" + exit 1 +fi From 912548447b4bd90bf5454bc0db0153a2c9f7897b Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Tue, 3 Mar 2026 22:04:57 +0000 Subject: [PATCH 39/50] refactor: extract branch-specific code into fork/ overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of dmv/hotfix-integrations fork extraction. Shared source files are now identical between branches — only fork/ files differ. New fork/ files (community/public stubs): - fork/auth.clj: 24h tokens, no login tracking - fork/splash.cljc: community label, no generators - fork/privacy_content.clj: standard privacy policy Extended fork/ files: - fork/branding.clj + .cljs: copyright-url (empty), registration-logo-class (h-55), restrict-print-to-owner? (false) Shared files updated to consume fork/ values: - routes.clj, views_2.cljc, views.cljs, privacy.clj, .gitattributes --- .gitattributes | 1 + src/clj/orcpub/fork/auth.clj | 25 ++ src/clj/orcpub/fork/branding.clj | 35 ++- src/clj/orcpub/fork/privacy_content.clj | 78 ++++++ src/clj/orcpub/privacy.clj | 145 +++-------- src/clj/orcpub/routes.clj | 35 ++- src/cljc/orcpub/dnd/e5/views_2.cljc | 73 ++++-- src/cljc/orcpub/fork/splash.cljc | 30 +++ src/cljs/orcpub/dnd/e5/views.cljs | 325 ++++++++++++++++-------- src/cljs/orcpub/fork/branding.cljs | 16 ++ 10 files changed, 501 insertions(+), 262 deletions(-) create mode 100644 src/clj/orcpub/fork/auth.clj create mode 100644 src/clj/orcpub/fork/privacy_content.clj create mode 100644 src/cljc/orcpub/fork/splash.cljc diff --git a/.gitattributes b/.gitattributes index 977df029d..54a10968c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ # Fork override files — keep each branch's version during merges. # Public/breaking has stubs, DMV has real implementations. src/clj/orcpub/fork/** merge=ours +src/cljc/orcpub/fork/** merge=ours src/cljs/orcpub/fork/** merge=ours diff --git a/src/clj/orcpub/fork/auth.clj b/src/clj/orcpub/fork/auth.clj new file mode 100644 index 000000000..b4ff4edb4 --- /dev/null +++ b/src/clj/orcpub/fork/auth.clj @@ -0,0 +1,25 @@ +(ns orcpub.fork.auth + "Fork-specific auth and session configuration. + Public/community edition: short sessions, no login tracking." + (:require [clojure.string :as s] + [orcpub.fork.branding :as branding])) + +;; ─── Session ──────────────────────────────────────────────────────── + +(def token-lifetime-hours + "JWT token lifetime in hours." + 24) + +(def track-last-login? + "Whether to record last-login timestamp on each login." + false) + +(def record-last-login-at-registration? + "Whether to set initial last-login when a user registers." + false) + +;; ─── Display ──────────────────────────────────────────────────────── + +(def verification-display-name + "Name shown in verification and password-reset emails." + "User") diff --git a/src/clj/orcpub/fork/branding.clj b/src/clj/orcpub/fork/branding.clj index ffc2cecfc..18e6a4fb2 100644 --- a/src/clj/orcpub/fork/branding.clj +++ b/src/clj/orcpub/fork/branding.clj @@ -84,6 +84,22 @@ :reddit (or (env :app-social-reddit) "") :discord (or (env :app-social-discord) "")}) +;; ─── Footer ───────────────────────────────────────────────────── + +(def copyright-url + "URL for copyright holder name in footer. Empty string = plain text." + (or (env :app-copyright-url) "")) + +;; ─── UI Behavior ──────────────────────────────────────────────── + +(def registration-logo-class + "CSS class for logo on registration/login page." + "h-55") + +(def restrict-print-to-owner? + "Whether the print button on character list is restricted to the character owner." + false) + ;; ─── Field Limits ──────────────────────────────────────────────── ;; Input field max-length constraints for form validation. @@ -100,11 +116,14 @@ (defn client-config "Map of branding values for CLJS injection. Serialized to JSON by index.clj." [] - {:app-name app-name - :logo-path logo-path - :copyright-holder copyright-holder - :copyright-year copyright-year - :support-email support-email - :help-url help-url - :social-links social-links - :field-limits field-limits}) + {:app-name app-name + :logo-path logo-path + :copyright-holder copyright-holder + :copyright-year copyright-year + :copyright-url copyright-url + :support-email support-email + :help-url help-url + :social-links social-links + :field-limits field-limits + :registration-logo-class registration-logo-class + :restrict-print-to-owner? restrict-print-to-owner?}) diff --git a/src/clj/orcpub/fork/privacy_content.clj b/src/clj/orcpub/fork/privacy_content.clj new file mode 100644 index 000000000..f49e08ea7 --- /dev/null +++ b/src/clj/orcpub/fork/privacy_content.clj @@ -0,0 +1,78 @@ +(ns orcpub.fork.privacy-content + "Fork-specific privacy policy content. + Public/community edition: standard privacy policy." + (:require [clojure.string :as s] + [environ.core :as environ] + [orcpub.fork.branding :as branding])) + +(def privacy-policy-section + {:title "Privacy Policy" + :font-size 48 + :subsections + [{:title (str "Thank you for using " branding/app-name "!") + :font-size 32 + :paragraphs + ["We wrote this policy to help you understand what information we collect, how we use it, and what choices you have. Because we're an internet company, some of the concepts below are a little technical, but we've tried our best to explain things in a simple and clear way. We welcome your questions and comments on this policy."]} + {:title "How We Collect Your Information" + :font-size 32 + :subsections + [{:title "When you give it to us or give us permission to obtain it" + :font-size 28 + :paragraphs + [(str "When you sign up for or use our products, you voluntarily give us certain information. This can include your name, profile photo, role-playing game characters, comments, likes, the email address or phone number you used to sign up, and any other information you provide us. If you're using " branding/app-name " on your mobile device, you can also choose to provide us with location data. And if you choose to buy something on " branding/app-name ", you provide us with payment information, contact information (ex., address and phone number), and what you purchased. If you buy something for someone else on " branding/app-name ", you'd also provide us with their shipping details and contact information.") + (str "You also may give us permission to access your information in other services. For example, you may link your Facebook or Twitter account to " branding/app-name ", which allows us to obtain information from those accounts (like your friends or contacts). The information we get from those services often depends on your settings or their privacy policies, so be sure to check what those are.")]} + {:title "We also get technical information when you use our products" + :font-size 28 + :paragraphs + ["These days, whenever you use a website, mobile application, or other internet service, there's certain information that almost always gets created and recorded automatically. The same is true when you use our products. Here are some of the types of information we collect:" + (str "Log data. When you use " branding/app-name ", our servers may automatically record information (\"log data\"), including information that your browser sends whenever you visit a website or your mobile app sends when you're using it. This log data may include your Internet Protocol address, the address of the web pages you visited that had " branding/app-name " features, browser type and settings, the date and time of your request, how you used " branding/app-name ", and cookie data.") + (str "Cookie data. Depending on how you're accessing our products, we may use \"cookies\" (small text files sent by your computer each time you visit our website, unique to your " branding/app-name " account or your browser) or similar technologies to record log data. When we use cookies, we may use \"session\" cookies (that last until you close your browser) or \"persistent\" cookies (that last until you or your browser delete them). For example, we may use cookies to store your language preferences or other " branding/app-name " settings so you don't have to set them up every time you visit " branding/app-name ". Some of the cookies we use are associated with your " branding/app-name " account (including personal information about you, such as the email address you gave us), and other cookies are not.") + (str "Device information. In addition to log data, we may also collect information about the device you're using " branding/app-name " on, including what type of device it is, what operating system you're using, device settings, unique device identifiers, and crash data. Whether we collect some or all of this information often depends on what type of device you're using and its settings. For example, different types of information are available depending on whether you're using a Mac or a PC, or an iPhone or an Android phone. To learn more about what information your device makes available to us, please also check the policies of your device manufacturer or software provider.")]} + {:title "Our partners and advertisers may share information with us" + :font-size 28 + :paragraphs + [(str "We may get information about you and your activity off " branding/app-name " from our affiliates, advertisers, partners and other third parties we work with. For example:") + "Online advertisers typically share information with the websites or apps where they run ads to measure and/or improve those ads. We also receive this information, which may include information like whether clicks on ads led to purchases or a list of criteria to use in targeting ads."]}]} + {:title "How do we use the information we collect?" + :font-size 32 + :paragraphs + [(str "We use the information we collect to provide our products to you and make them better, develop new products, and protect " branding/app-name " and our users. For example, we may log how often people use two different versions of a product, which can help us understand which version is better. If you make a purchase on " branding/app-name ", we'll save your payment information and contact information so that you can use them the next time you want to buy something on " branding/app-name ".") + "We also use the information we collect to offer you customized content, including:" + "Showing you ads you might be interested in." + "We also use the information we collect to:" + (str "Send you updates (such as when certain activity, like shares or comments, happens on " branding/app-name "), newsletters, marketing materials and other information that may be of interest to you. For example, depending on your email notification settings, we may send you weekly updates that include content you may like. You can decide to stop getting these updates by updating your account settings (or through other settings we may provide).") + (str "Help your friends and contacts find you on " branding/app-name ". For example, if you sign up using a Facebook account, we may help your Facebook friends find your account on " branding/app-name " when they first sign up for " branding/app-name ". Or, we may allow people to search for your account on " branding/app-name " using your email address.") + "Respond to your questions or comments."]} + {:title "Transferring your Information" + :font-size 32 + :paragraphs + [(str branding/app-name " is headquartered in the United States. By using our products or services, you authorize us to transfer and store your information inside the United States, for the purposes described in this policy. The privacy protections and the rights of authorities to access your personal information in such countries may not be equivalent to those in your home country.")]} + {:title "How and when do we share information" + :font-size 32 + :paragraphs + [(str "Anyone can see the public role-playing game characters and other content you create, and the profile information you give us. We may also make this public information available through what are called \"APIs\" (basically a technical way to share information quickly). For example, a partner might use a " branding/app-name " API to integrate with other applications our users may be interested in. The other limited instances where we may share your personal information include:") + (str "When we have your consent. This includes sharing information with other services (like Facebook or Twitter) when you've chosen to link to your " branding/app-name " account to those services or publish your activity on " branding/app-name " to them. For example, you can choose to share your characters on Facebook or Twitter.") + (str "When you buy something on " branding/app-name " using your credit card, we may share your credit card information, contact information, and other information about the transaction with the merchant you're buying from. The merchants treat this information just as if you had made a purchase from their website directly, which means their privacy policies and marketing policies apply to the information we share with them.") + (str "Online advertisers typically use third party companies to audit the delivery and performance of their ads on websites and apps. We also allow these companies to collect this information on " branding/app-name ". To learn more, please see our Help Center.") + "We may employ third party companies or individuals to process personal information on our behalf based on our instructions and in compliance with this Privacy Policy. For example, we share credit card information with the payment companies we use to store your payment information. Or, we may share data with a security consultant to help us get better at identifying spam. In addition, some of the information we request may be collected by third party providers on our behalf." + (str "If we believe that disclosure is reasonably necessary to comply with a law, regulation or legal request; to protect the safety, rights, or property of the public, any person, or " branding/app-name "; or to detect, prevent, or otherwise address fraud, security or technical issues. We may share the information described in this Policy with our wholly-owned subsidiaries and affiliates. We may engage in a merger, acquisition, bankruptcy, dissolution, reorganization, or similar transaction or proceeding that involves the transfer of the information described in this Policy. We may also share aggregated or non-personally identifiable information with our partners, advertisers or others.")]} + {:title "What choices do you have about your information?" + :font-size 32 + :paragraphs + (if (not (s/blank? (environ/env :email-access-key))) + ["You may close your account at any time by emailing " (environ/env :email-access-key) (str "We will then inactivate your account and remove your content from " branding/app-name ". We may retain archived copies of you information as required by law or for legitimate business purposes (including to help address fraud and spam). ")] + [(str "You may remove any content you create from " branding/app-name " at any time, although we may retain archived copies of the information. You may also disable sharing of content you create at any time, whether publicly shared or privately shared with specific users.") + "Also, we support the Do Not Track browser setting."])} + {:title "Our policy on children's information" + :font-size 32 + :paragraphs + [(str branding/app-name " is not directed to children under 13. If you learn that your minor child has provided us with personal information without your consent, please contact us.")]} + {:title "How do we make changes to this policy?" + :font-size 32 + :paragraphs + [(str "We may change this policy from time to time, and if we do we'll post any changes on this page. If you continue to use " branding/app-name " after those changes are in effect, you agree to the revised policy. If the changes are significant, we may provide more prominent notice or get your consent as required by law.")]} + (when (not (s/blank? (environ/env :email-access-key))) + {:title "How can you contact us?" + :font-size 32 + :paragraphs + ["You can contact us by emailing " (environ/env :email-access-key) ]})]}) diff --git a/src/clj/orcpub/privacy.clj b/src/clj/orcpub/privacy.clj index 3ffe26372..c8560df2e 100644 --- a/src/clj/orcpub/privacy.clj +++ b/src/clj/orcpub/privacy.clj @@ -1,8 +1,10 @@ (ns orcpub.privacy (:require [hiccup.page :as page] [clojure.string :as s] - [environ.core :as environ] - [orcpub.fork.branding :as branding])) + [orcpub.fork.branding :as branding] + [orcpub.fork.integrations :as integrations] + [orcpub.fork.privacy-content :as content] + [environ.core :as environ])) (defn section [{:keys [title font-size paragraphs subsections]}] [:div @@ -17,89 +19,25 @@ subsections)]) (def privacy-policy-section - {:title "Privacy Policy" - :font-size 48 - :subsections - [{:title (str "Thank you for using " branding/app-name "!") - :font-size 32 - :paragraphs - ["We wrote this policy to help you understand what information we collect, how we use it, and what choices you have. Because we're an internet company, some of the concepts below are a little technical, but we've tried our best to explain things in a simple and clear way. We welcome your questions and comments on this policy."]} - {:title "How We Collect Your Information" - :font-size 32 - :subsections - [{:title "When you give it to us or give us permission to obtain it" - :font-size 28 - :paragraphs - [(str "When you sign up for or use our products, you voluntarily give us certain information. This can include your name, profile photo, role-playing game characters, comments, likes, the email address or phone number you used to sign up, and any other information you provide us. If you're using " branding/app-name " on your mobile device, you can also choose to provide us with location data. And if you choose to buy something on " branding/app-name ", you provide us with payment information, contact information (ex., address and phone number), and what you purchased. If you buy something for someone else on " branding/app-name ", you'd also provide us with their shipping details and contact information.") - (str "You also may give us permission to access your information in other services. For example, you may link your Facebook or Twitter account to " branding/app-name ", which allows us to obtain information from those accounts (like your friends or contacts). The information we get from those services often depends on your settings or their privacy policies, so be sure to check what those are.")]} - {:title "We also get technical information when you use our products" - :font-size 28 - :paragraphs - ["These days, whenever you use a website, mobile application, or other internet service, there’s certain information that almost always gets created and recorded automatically. The same is true when you use our products. Here are some of the types of information we collect:" - (str "Log data. When you use " branding/app-name ", our servers may automatically record information (\"log data\"), including information that your browser sends whenever you visit a website or your mobile app sends when you’re using it. This log data may include your Internet Protocol address, the address of the web pages you visited that had " branding/app-name " features, browser type and settings, the date and time of your request, how you used " branding/app-name ", and cookie data.") - (str "Cookie data. Depending on how you’re accessing our products, we may use \"cookies\" (small text files sent by your computer each time you visit our website, unique to your " branding/app-name " account or your browser) or similar technologies to record log data. When we use cookies, we may use \"session\" cookies (that last until you close your browser) or \"persistent\" cookies (that last until you or your browser delete them). For example, we may use cookies to store your language preferences or other " branding/app-name " settings so you don’t have to set them up every time you visit " branding/app-name ". Some of the cookies we use are associated with your " branding/app-name " account (including personal information about you, such as the email address you gave us), and other cookies are not.") - (str "Device information. In addition to log data, we may also collect information about the device you’re using " branding/app-name " on, including what type of device it is, what operating system you’re using, device settings, unique device identifiers, and crash data. Whether we collect some or all of this information often depends on what type of device you’re using and its settings. For example, different types of information are available depending on whether you’re using a Mac or a PC, or an iPhone or an Android phone. To learn more about what information your device makes available to us, please also check the policies of your device manufacturer or software provider.")]} - {:title "Our partners and advertisers may share information with us" - :font-size 28 - :paragraphs - [(str "We may get information about you and your activity off " branding/app-name " from our affiliates, advertisers, partners and other third parties we work with. For example:") - "Online advertisers typically share information with the websites or apps where they run ads to measure and/or improve those ads. We also receive this information, which may include information like whether clicks on ads led to purchases or a list of criteria to use in targeting ads."]}]} - {:title "How do we use the information we collect?" - :font-size 32 - :paragraphs - [(str "We use the information we collect to provide our products to you and make them better, develop new products, and protect " branding/app-name " and our users. For example, we may log how often people use two different versions of a product, which can help us understand which version is better. If you make a purchase on " branding/app-name ", we'll save your payment information and contact information so that you can use them the next time you want to buy something on " branding/app-name ".") - "We also use the information we collect to offer you customized content, including:" - "Showing you ads you might be interested in." - "We also use the information we collect to:" - (str "Send you updates (such as when certain activity, like shares or comments, happens on " branding/app-name "), newsletters, marketing materials and other information that may be of interest to you. For example, depending on your email notification settings, we may send you weekly updates that include content you may like. You can decide to stop getting these updates by updating your account settings (or through other settings we may provide).") - (str "Help your friends and contacts find you on " branding/app-name ". For example, if you sign up using a Facebook account, we may help your Facebook friends find your account on " branding/app-name " when they first sign up for " branding/app-name ". Or, we may allow people to search for your account on " branding/app-name " using your email address.") - "Respond to your questions or comments."]} - {:title "Transferring your Information" - :font-size 32 - :paragraphs - [(str branding/app-name " is headquartered in the United States. By using our products or services, you authorize us to transfer and store your information inside the United States, for the purposes described in this policy. The privacy protections and the rights of authorities to access your personal information in such countries may not be equivalent to those in your home country.")]} - {:title "How and when do we share information" - :font-size 32 - :paragraphs - [(str "Anyone can see the public role-playing game characters and other content you create, and the profile information you give us. We may also make this public information available through what are called \"APIs\" (basically a technical way to share information quickly). For example, a partner might use a " branding/app-name " API to integrate with other applications our users may be interested in. The other limited instances where we may share your personal information include:") - (str "When we have your consent. This includes sharing information with other services (like Facebook or Twitter) when you've chosen to link to your " branding/app-name " account to those services or publish your activity on " branding/app-name " to them. For example, you can choose to share your characters on Facebook or Twitter.") - (str "When you buy something on " branding/app-name " using your credit card, we may share your credit card information, contact information, and other information about the transaction with the merchant you're buying from. The merchants treat this information just as if you had made a purchase from their website directly, which means their privacy policies and marketing policies apply to the information we share with them.") - (str "Online advertisers typically use third party companies to audit the delivery and performance of their ads on websites and apps. We also allow these companies to collect this information on " branding/app-name ". To learn more, please see our Help Center.") - "We may employ third party companies or individuals to process personal information on our behalf based on our instructions and in compliance with this Privacy Policy. For example, we share credit card information with the payment companies we use to store your payment information. Or, we may share data with a security consultant to help us get better at identifying spam. In addition, some of the information we request may be collected by third party providers on our behalf." - (str "If we believe that disclosure is reasonably necessary to comply with a law, regulation or legal request; to protect the safety, rights, or property of the public, any person, or " branding/app-name "; or to detect, prevent, or otherwise address fraud, security or technical issues. We may share the information described in this Policy with our wholly-owned subsidiaries and affiliates. We may engage in a merger, acquisition, bankruptcy, dissolution, reorganization, or similar transaction or proceeding that involves the transfer of the information described in this Policy. We may also share aggregated or non-personally identifiable information with our partners, advertisers or others.")]} - {:title "What choices do you have about your information?" - :font-size 32 - :paragraphs - (if (not (s/blank? (environ/env :email-access-key))) - ["You may close your account at any time by emailing " (environ/env :email-access-key) (str "We will then inactivate your account and remove your content from " branding/app-name ". We may retain archived copies of you information as required by law or for legitimate business purposes (including to help address fraud and spam). ")] - [(str "You may remove any content you create from " branding/app-name " at any time, although we may retain archived copies of the information. You may also disable sharing of content you create at any time, whether publicly shared or privately shared with specific users.") - "Also, we support the Do Not Track browser setting."])} - {:title "Our policy on children's information" - :font-size 32 - :paragraphs - [(str branding/app-name " is not directed to children under 13. If you learn that your minor child has provided us with personal information without your consent, please contact us.")]} - {:title "How do we make changes to this policy?" - :font-size 32 - :paragraphs - [(str "We may change this policy from time to time, and if we do we'll post any changes on this page. If you continue to use " branding/app-name " after those changes are in effect, you agree to the revised policy. If the changes are significant, we may provide more prominent notice or get your consent as required by law.")]} - (when (not (s/blank? (environ/env :email-access-key))) - {:title "How can you contact us?" - :font-size 32 - :paragraphs - ["You can contact us by emailing " (environ/env :email-access-key) ]})]}) + "Privacy policy content — fork-specific. See fork/privacy_content.clj." + content/privacy-policy-section) (defn terms-page [sections] (page/html5 [:head [:link {:rel :stylesheet :href "/css/style.css" :type "text/css"}] - [:link {:rel :stylesheet :href "/css/compiled/styles.css" :type "text/css"}]] + [:link {:rel :stylesheet :href "/css/compiled/styles.css" :type "text/css"}] + + ;; Third-party integration tags (analytics, ads) — same as index.clj. + ;; Empty on public repo, populated on DMV via integrations.clj. + (integrations/head-tags nil)] [:body.sans [:div [:div.app-header-bar.container {:style "background-color:#2c3445"} [:div.content [:div.flex.justify-cont-s-b.align-items-c.w-100-p.p-l-20.p-r-20 - [:img.h-60 {:src branding/logo-path}]]]] + [:a {:href "/" } [:img.h-72.pointer {:src branding/logo-path}]]]]] [:div.container [:div.content [:div.f-s-24 @@ -146,46 +84,42 @@ :font-size 28 :paragraphs [(str "We value hearing from our users, and are always interested in learning about ways we can make " branding/app-name " more awesome. If you choose to submit comments, ideas or feedback, you agree that we are free to use them without any restriction or compensation to you. By accepting your submission, " branding/app-name " does not waive any rights to use similar or related Feedback previously known to " branding/app-name ", or developed by its employees, or obtained from sources other than you")]}]} - {:title "3. Copyright policy" - :font-size 32 - :paragraphs - [(str branding/app-name " has adopted and implemented the " branding/app-name " Copyright policy in accordance with the Digital Millennium Copyright Act and other applicable copyright laws. For more information, please read our Copyright policy.")]} - {:title "4. Security" + {:title "3. Security" :font-size 32 :paragraphs [(str "We care about the security of our users. While we work to protect the security of your content and account, " branding/app-name " cannot guarantee that unauthorized third parties will not be able to defeat our security measures. We ask that you keep your password secure. Please notify us immediately of any compromise or unauthorized use of your account.")]} - {:title "5. Third-party links, sites, and services" + {:title "4. Third-party links, sites, and services" :font-size 32 :paragraphs [(str "Our Products may contain links to third-party websites, advertisers, services, special offers, or other events or activities that are not owned or controlled by " branding/app-name ". We do not endorse or assume any responsibility for any such third-party sites, information, materials, products, or services. If you access any third party website, service, or content from " branding/app-name ", you do so at your own risk and you agree that " branding/app-name " will have no liability arising from your use of or access to any third-party website, service, or content.")]} - {:title "6. Termination" + {:title "5. Termination" :font-size 32 :paragraphs - [(str branding/app-name " may terminate or suspend this license at any time, with or without cause or notice to you. Upon termination, you continue to be bound by Sections 2 and 6-12 of these Terms.")]} - {:title "7. Indemnity" + [(str branding/app-name " may terminate or suspend this license at any time, with or without cause or notice to you. Upon termination, you continue to be bound by Sections 2 and 6-11 of these Terms.")]} + {:title "6. Indemnity" :font-size 32 :paragraphs - [(str "If you use our Products for commercial purposes without agreeing to our Business Terms as required by Section 1(c), as determined in our sole and absolute discretion, you agree to indemnify and hold harmless " branding/app-name " and its respective officers, directors, employees and agents, from and against any claims, suits, proceedings, disputes, demands, liabilities, damages, losses, costs and expenses, including, without limitation, reasonable legal and accounting fees (including costs of defense of claims, suits or proceedings brought by third parties), in any way related to (a) your access to or use of our Products, (b) your User Content, or (c) your breach of any of these Terms.")]} - {:title "8. Disclaimers" + [(str "If you use our Products for commercial purposes without agreeing to our Business Terms as required by Section 1, as determined in our sole and absolute discretion, you agree to indemnify and hold harmless " branding/app-name " and its respective officers, directors, employees and agents, from and against any claims, suits, proceedings, disputes, demands, liabilities, damages, losses, costs and expenses, including, without limitation, reasonable legal and accounting fees (including costs of defense of claims, suits or proceedings brought by third parties), in any way related to (a) your access to or use of our Products, (b) your User Content, or (c) your breach of any of these Terms.")]} + {:title "7. Disclaimers" :font-size 32 :paragraphs ["The Products and all included content are provided on an \"as is\" basis without warranty of any kind, whether express or implied." - (str (.toUpperCase branding/app-name) " SPECIFICALLY DISCLAIMS ANY AND ALL WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF DEALING OR USAGE OF TRADE.") + (str branding/app-name " SPECIFICALLY DISCLAIMS ANY AND ALL WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF DEALING OR USAGE OF TRADE.") (str branding/app-name " takes no responsibility and assumes no liability for any User Content that you or any other user or third party posts or transmits using our Products. You understand and agree that you may be exposed to User Content that is inaccurate, objectionable, inappropriate for children, or otherwise unsuited to your purpose.")]} - {:title "9. Limitation of liability" + {:title "8. Limitation of liability" :font-size 32 :paragraphs - [(str "TO THE MAXIMUM EXTENT PERMITTED BY LAW, " (.toUpperCase branding/app-name) " SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOOD-WILL, OR OTHER INTANGIBLE LOSSES, RESULTING FROM (A) YOUR ACCESS TO OR USE OF OR INABILITY TO ACCESS OR USE THE PRODUCTS; (B) ANY CONDUCT OR CONTENT OF ANY THIRD PARTY ON THE PRODUCTS, INCLUDING WITHOUT LIMITATION, ANY DEFAMATORY, OFFENSIVE OR ILLEGAL CONDUCT OF OTHER USERS OR THIRD PARTIES; OR (C) UNAUTHORIZED ACCESS, USE OR ALTERATION OF YOUR TRANSMISSIONS OR CONTENT. IN NO EVENT SHALL " (.toUpperCase branding/app-name) "'S AGGREGATE LIABILITY FOR ALL CLAIMS RELATING TO THE PRODUCTS EXCEED ONE HUNDRED U.S. DOLLARS (U.S. $100.00).")]} - :title "10. Arbitration" - :font-size 32 - :paragraphs - [(str "For any dispute you have with " branding/app-name ", you agree to first contact us and attempt to resolve the dispute with us informally. If " branding/app-name " has not been able to resolve the dispute with you informally, we each agree to resolve any claim, dispute, or controversy (excluding claims for injunctive or other equitable relief) arising out of or in connection with or relating to these Terms by binding arbitration by the American Arbitration Association (\"AAA\") under the Commercial Arbitration Rules and Supplementary Procedures for Consumer Related Disputes then in effect for the AAA, except as provided herein. Unless you and " branding/app-name " agree otherwise, the arbitration will be conducted in the county where you reside. Each party will be responsible for paying any AAA filing, administrative and arbitrator fees in accordance with AAA rules, except that " branding/app-name " will pay for your reasonable filing, administrative, and arbitrator fees if your claim for damages does not exceed $75,000 and is non-frivolous (as measured by the standards set forth in Federal Rule of Civil Procedure 11(b)). The award rendered by the arbitrator shall include costs of arbitration, reasonable attorneys' fees and reasonable costs for expert and other witnesses, and any judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction. Nothing in this Section shall prevent either party from seeking injunctive or other equitable relief from the courts for matters related to data security, intellectual property or unauthorized access to the Service. ALL CLAIMS MUST BE BROUGHT IN THE PARTIES' INDIVIDUAL CAPACITY, AND NOT AS A PLAINTIFF OR CLASS MEMBER IN ANY PURPORTED CLASS OR REPRESENTATIVE PROCEEDING, AND, UNLESS WE AGREE OTHERWISE, THE ARBITRATOR MAY NOT CONSOLIDATE MORE THAN ONE PERSON'S CLAIMS. YOU AGREE THAT, BY ENTERING INTO THESE TERMS, YOU AND " (.toUpperCase branding/app-name) " ARE EACH WAIVING THE RIGHT TO A TRIAL BY JURY OR TO PARTICIPATE IN A CLASS ACTION.") - (str "To the extent any claim, dispute or controversy regarding " branding/app-name " or our Products isn't arbitrable under applicable laws or otherwise: you and " branding/app-name " both agree that any claim or dispute regarding " branding/app-name " will be resolved exclusively in accordance with Clause 11 of these Terms.")] - {:title "11. Governing law and jurisdiction" + [(str "TO THE MAXIMUM EXTENT PERMITTED BY LAW, " branding/app-name " SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOOD-WILL, OR OTHER INTANGIBLE LOSSES, RESULTING FROM (A) YOUR ACCESS TO OR USE OF OR INABILITY TO ACCESS OR USE THE PRODUCTS; (B) ANY CONDUCT OR CONTENT OF ANY THIRD PARTY ON THE PRODUCTS, INCLUDING WITHOUT LIMITATION, ANY DEFAMATORY, OFFENSIVE OR ILLEGAL CONDUCT OF OTHER USERS OR THIRD PARTIES; OR (C) UNAUTHORIZED ACCESS, USE OR ALTERATION OF YOUR TRANSMISSIONS OR CONTENT. IN NO EVENT SHALL " branding/app-name "'s AGGREGATE LIABILITY FOR ALL CLAIMS RELATING TO THE PRODUCTS EXCEED ONE HUNDRED U.S. DOLLARS (U.S. $100.00).")]} + {:title "9. Arbitration" :font-size 32 :paragraphs - ["These Terms shall be governed by the laws of the State of Utah, without respect to its conflict of laws principles. We each agree to submit to the personal jurisdiction of a state court located in Salt Lake County, Utah or the United States District Court for the District of Utah, for any actions not subject to Section 10 (Arbitration)."]} - {:title "12. General terms" + [(str "For any dispute you have with " branding/app-name ", you agree to first contact us and attempt to resolve the dispute with us informally. If " branding/app-name " has not been able to resolve the dispute with you informally, we each agree to resolve any claim, dispute, or controversy (excluding claims for injunctive or other equitable relief) arising out of or in connection with or relating to these Terms by binding arbitration by the American Arbitration Association (\"AAA\") under the Commercial Arbitration Rules and Supplementary Procedures for Consumer Related Disputes then in effect for the AAA, except as provided herein. Unless you and " branding/app-name " agree otherwise, the arbitration will be conducted in Tulsa County, Oklahoma or the United States District Court for the District of Oklahoma with in the United states. Each party will be responsible for paying any AAA filing, administrative and arbitrator fees in accordance with AAA rules, except that " branding/app-name " will pay for your reasonable filing, administrative, and arbitrator fees if your claim for damages does not exceed $75,000 and is non-frivolous (as measured by the standards set forth in Federal Rule of Civil Procedure 11(b)). The award rendered by the arbitrator shall include costs of arbitration, reasonable attorneys' fees and reasonable costs for expert and other witnesses, and any judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction. Nothing in this Section shall prevent either party from seeking injunctive or other equitable relief from the courts for matters related to data security, intellectual property or unauthorized access to the Service. ALL CLAIMS MUST BE BROUGHT IN THE PARTIES' INDIVIDUAL CAPACITY, AND NOT AS A PLAINTIFF OR CLASS MEMBER IN ANY PURPORTED CLASS OR REPRESENTATIVE PROCEEDING, AND, UNLESS WE AGREE OTHERWISE, THE ARBITRATOR MAY NOT CONSOLIDATE MORE THAN ONE PERSON'S CLAIMS. YOU AGREE THAT, BY ENTERING INTO THESE TERMS, YOU AND " branding/app-name " ARE EACH WAIVING THE RIGHT TO A TRIAL BY JURY OR TO PARTICIPATE IN A CLASS ACTION.") + (str "To the extent any claim, dispute or controversy regarding " branding/app-name " or our Products isn't arbitrable under applicable laws or otherwise: you and " branding/app-name " both agree that any claim or dispute regarding " branding/app-name " will be resolved exclusively in accordance with Clause 10 of these Terms.")]} + {:title "10. Governing law and jurisdiction" + :font-size 32 + :paragraphs + ["These Terms shall be governed by the laws of the State of Oklahoma, without respect to its conflict of laws principles. We each agree to submit to the personal jurisdiction of a state court located in Tulsa County, Oklahoma or the United States District Court for the District of Oklahoma, for any actions not subject to Section 9 (Arbitration)."]} + {:title "11. General terms" :font-size 32 :subsections [{:title "Notification procedures and changes to these Terms" @@ -195,7 +129,7 @@ {:title "Assignment" :font-size 28 :paragraphs - [(str "These Terms, and any rights and licenses granted hereunder, may not be transferred or assigned by you, but may be assigned by " branding/app-name " without restriction. Any attempted transfer or assignment in violation hereof shall be null and void.\n")]} + [(str "These Terms, and any rights and licenses granted hereunder, may not be transferred or assigned by you, but may be assigned by " branding/app-name " without restriction. Any attempted transfer or assignment in violation hereof shall be null and void.")]} {:title "Entire agreement/severability" :font-size 28 :paragraphs @@ -204,11 +138,15 @@ :font-size 28 :paragraphs [(str "No waiver of any term of these Terms shall be deemed a further or continuing waiver of such term or any other term, and " branding/app-name "'s failure to assert any right or provision under these Terms shall not constitute a waiver of such right or provision.")]} + {:title "Terms of reuse" + :font-size 28 + :paragraphs + ["Code and portions of this Derivative Works are used under Eclipse Public License 2.0 https://github.com/Orcpub/orcpub/blob/develop/LICENSE"]} {:title "Parties" :font-size 28 :paragraphs [(str "These Terms are a contract between you and " branding/app-name) - "Effective May 1, 2017"]}]}]}) + "Effective Dec 28th, 2021"]}]}]}) (defn terms-of-use [] (terms-page terms-section)) @@ -240,7 +178,7 @@ [(str "To respect the rights of people on and off " branding/app-name ", please:") "Don't infringe anyone's intellectual property, privacy or other rights." "Don't do anything or post any content that violates laws or regulations." - (str "Don't use " branding/app-name "'s name, logo or trademark in a way that confuses people (check out our brand guidelines for more details).")]} + (str "Don't use " branding/app-name "'s name, logo or trademark in a way that confuses people.")]} {:title "Site security and access" :font-size 32 :paragraphs @@ -261,7 +199,8 @@ "Attempts to artificially boost views and other metrics." "Repetitive or unwanted posts." "Off-domain redirects, cloaking or other ways of obscuring where content leads." - "Misleading content."]}]}) + "Misleading content." + "Effective Nov 4th, 2020"]}]}) (defn community-guidelines [] (terms-page community-guidelines-section)) @@ -277,11 +216,11 @@ {:title "What's a cookie?" :font-size 32 :paragraphs - ["When you go online, you use a program called a \"browser\" (like Apple's Safari or Google's Chrome). Most websites store a small amount of text in the browser—and that text is called a \"cookie.\""]} + ["When you go online, you use a program called a \"browser\" (like Apple's Safari or Google's Chrome). Most websites store a small amount of text in the browser and that text is called a \"cookie.\""]} {:title "How we use cookies" :font-size 32 :paragraphs - [(str "We use cookies for lots of essential things on " branding/app-name "—like helping you log in and tailoring your " branding/app-name " experience. Here are some specifics on how we use cookies.")]} + [(str "We use cookies for lots of essential things on " branding/app-name " like helping you log in and tailoring your " branding/app-name " experience. Here are some specifics on how we use cookies.")]} {:title "What we use cookies for" :font-size 32 :subsections @@ -320,7 +259,7 @@ :paragraphs ["Your browser probably gives you cookie choices. For example, most browsers let you block \"third party cookies,\" which are cookies from sites other than the one you're visiting. Those options vary from browser to browser, so check your browser settings for more info." (str "Some browsers also have a privacy setting called \"Do Not Track,\" which we support. This setting is another way for you to decide whether we use info from our partners and other services to customize " branding/app-name " for you.") - "Effective November 1, 2016"]}]}) + "Effective Nov 4th, 2020"]}]}) (defn cookie-policy [] (terms-page cookie-policy-section)) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 601527ad6..3e104a68c 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -33,17 +33,17 @@ [orcpub.errors :as errors] [orcpub.privacy :as privacy] [orcpub.email :as email] - [orcpub.fork.branding :as branding] - [orcpub.fork.user-data :as user-data] [orcpub.index :refer [index-page]] [orcpub.pdf :as pdf] [orcpub.registration :as registration] [orcpub.entity.strict :as se] [orcpub.entity :as entity] [orcpub.security :as security] + [orcpub.fork.branding :as branding] + [orcpub.fork.auth :as auth] + [orcpub.fork.user-data :as user-data] [orcpub.routes.party :as party] [orcpub.routes.folder :as folder] - [orcpub.oauth :as oauth] [hiccup.page :as page] [environ.core :as environ] [clojure.set :as sets] @@ -258,16 +258,20 @@ errors/bad-credentials errors/no-account))))) -(defn create-login-response [db user & [headers]] +(defn create-login-response [db conn user id & [headers]] (let [token (create-token (:orcpub.user/username user) - (-> 24 hours from-now))] + (-> auth/token-lifetime-hours hours from-now)) + now (java.util.Date.)] + (when auth/track-last-login? + (d/transact conn [{:db/id id + :orcpub.user/last-login now}])) {:status 200 :headers headers :body {:user-data (user-body db user) :token token}})) (defn login-response - [{:keys [json-params db remote-addr] :as request}] + [{:keys [json-params db conn remote-addr] :as request}] (let [{raw-username :username raw-password :password} json-params] (cond (s/blank? raw-username) (login-error errors/username-required) @@ -284,7 +288,8 @@ (nil? id) (bad-credentials-response db username remote-addr) (and unverified? expired?) (login-error errors/unverified-expired) unverified? (login-error errors/unverified {:email email}) - :else (create-login-response db user)))))) + :else + (create-login-response db conn user id)))))) (defn login [{:keys [json-params db] :as request}] (try @@ -345,7 +350,8 @@ validation (registration/validate-registration json-params (seq (d/q email-query db email)) - (seq (d/q username-query db username)))] + (seq (d/q username-query db username))) + now (java.util.Date.)] (try (if (seq validation) {:status 400 @@ -359,7 +365,9 @@ :orcpub.user/username username :orcpub.user/password (hashers/encrypt password) :orcpub.user/send-updates? send-updates? - :orcpub.user/created (java.util.Date.)} + :orcpub.user/created now} + (when auth/record-last-login-at-registration? + {:orcpub.user/last-login now}) (user-data/registration-defaults)))) (catch Throwable e (prn e) (throw e))))) @@ -438,7 +446,7 @@ (redirect route-map/verify-success-route) (do-verification request (merge query-params - {:first-and-last-name "DMV Patron"}) + {:first-and-last-name auth/verification-display-name}) conn {:db/id id})))) @@ -498,7 +506,7 @@ :orcpub.user/password-reset-sent (java.util.Date.)}]) (email/send-reset-email (base-url request) - {:first-and-last-name "DMV Patron" + {:first-and-last-name auth/verification-display-name :email email} key) {:status 200} @@ -643,7 +651,10 @@ output (ByteArrayOutputStream.) user-agent (get-in req [:headers "user-agent"]) chrome? (re-matches #".*Chrome.*" user-agent) - filename (str player-name " - " character-name " - " class-level ".pdf")] + filename (cond + (and (s/blank? player-name) (s/blank? character-name)) "character.pdf" + (s/blank? player-name) (str character-name " - " class-level ".pdf") + :else (str player-name " - " character-name " - " class-level ".pdf"))] ;; PDFBox 3.x: Loader/loadPDF accepts byte[], File, or RandomAccessRead — ;; NOT InputStream. Read the resource stream into a byte array first. diff --git a/src/cljc/orcpub/dnd/e5/views_2.cljc b/src/cljc/orcpub/dnd/e5/views_2.cljc index e7d4b3453..5243f717a 100644 --- a/src/cljc/orcpub/dnd/e5/views_2.cljc +++ b/src/cljc/orcpub/dnd/e5/views_2.cljc @@ -1,7 +1,8 @@ (ns orcpub.dnd.e5.views-2 (:require [orcpub.route-map :as routes] [clojure.string :as s] - [orcpub.fork.branding :as branding])) + [orcpub.fork.branding :as branding] + [orcpub.fork.splash :as splash])) (defn style [style] #?(:cljs style) @@ -16,13 +17,16 @@ [:img.svg-icon {:src (str "/image/" icon-name ".svg")}]) -(defn splash-page-button [title icon route & [handler]] +(defn splash-page-button + "Render a splash page button. If handler is provided, uses on-click; + otherwise resolves route (keyword = path-for, string = raw href)." + [title icon route & [handler]] [:a.splash-button (let [cfg {:style (style {:text-decoration :none :color "#f0a100"})}] (if handler (assoc cfg :on-click handler) - (assoc cfg :href (routes/path-for route)))) + (assoc cfg :href (if (string? route) route (routes/path-for route))))) [:div.splash-button-content {:style (style {:box-shadow "0 2px 6px 0 rgba(0, 0, 0, 0.5)" :margin "5px" @@ -38,14 +42,15 @@ [:div [:span.splash-button-title-prefix "D&D 5e "] [:span title]]]]]) +(def orange-style + {:color :orange}) + (defn legal-footer [] [:div.m-l-15.m-b-10.m-t-10.t-a-l [:span (str "\u00a9 " branding/copyright-year " " branding/copyright-holder)] - [:a.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"] - [:a.m-l-5 {:href "/privacy-policy" :target :_blank} "Privacy Policy"]]) - -(def orange-style - {:color :orange}) + (for [{:keys [label href]} splash/legal-footer-links] + ^{:key href} + [:a.m-l-5 {:href href :target :_blank} label])]) (defn splash-page [] [:div.app.h-full @@ -63,15 +68,10 @@ [:div {:style (style {:display :flex :justify-content :space-around})} - [:img.w-30-p - {:src branding/logo-path}]] - [:div - {:style (style {:text-align :center - :text-shadow "1px 2px 1px black" - :font-weight :bold - :font-size "14px" - :height "48px"})} - "Community edition"] + [:img {:class splash/logo-width-class + :src branding/logo-path}] + (when splash/edition-label + [:div.f-s-18.opacity-5.m-t-10 splash/edition-label])] [:div {:style (style {:display :flex @@ -86,6 +86,16 @@ "Character Builder for Newbs" "baby-face" routes/dnd-e5-newb-char-builder-route) + (splash-page-button + "Homebrew Content" + "beer-stein" + routes/dnd-e5-my-content-route)] + [:div + {:style (style + {:display :flex + :flex-wrap :wrap + :justify-content :center + :margin-top "10px"})} (splash-page-button "Spells" "spell-book" @@ -101,11 +111,13 @@ (splash-page-button "Combat Tracker" "sword-clash" - routes/dnd-e5-combat-tracker-page-route) - (splash-page-button - "Homebrew Content" - "beer-stein" - routes/dnd-e5-my-content-route) + routes/dnd-e5-combat-tracker-page-route)] + [:div + {:style (style + {:display :flex + :flex-wrap :wrap + :justify-content :center + :margin-top "10px"})} (splash-page-button "Encounter Builder" "minions" @@ -133,9 +145,14 @@ (splash-page-button "Background Builder" "ages" - routes/dnd-e5-background-builder-page-route)]]] - [:div.legal-footer-parent - {:style (style {:font-size "12px" - :color :white - :padding "10px"})} - ]]) + routes/dnd-e5-background-builder-page-route)] + (when (seq splash/generator-buttons) + [:div + {:style (style + {:display :flex + :flex-wrap :wrap + :justify-content :center + :margin-top "10px"})} + (for [{:keys [title icon route]} splash/generator-buttons] + ^{:key route} + (splash-page-button title icon route))])]]]) diff --git a/src/cljc/orcpub/fork/splash.cljc b/src/cljc/orcpub/fork/splash.cljc new file mode 100644 index 000000000..c4caca412 --- /dev/null +++ b/src/cljc/orcpub/fork/splash.cljc @@ -0,0 +1,30 @@ +(ns orcpub.fork.splash + "Fork-specific splash page and navigation configuration. + Public/community edition: minimal splash, no generators.") + +;; ─── Splash Page ──────────────────────────────────────────────────── + +(def logo-width-class + "CSS class controlling splash page logo width." + "w-30-p") + +(def edition-label + "Text shown below logo on splash page. nil = hidden." + "Community edition") + +;; ─── Legal Footer ─────────────────────────────────────────────────── + +(def legal-footer-links + "Links rendered in the legal footer (splash + content pages)." + [{:label "Terms of Use" :href "/terms-of-use"} + {:label "Privacy Policy" :href "/privacy-policy"}]) + +;; ─── Generators ───────────────────────────────────────────────────── + +(def generator-buttons + "Splash page generator buttons. Empty = section hidden." + []) + +(def header-generator-entries + "Header nav generator dropdown entries. Empty = tab hidden." + []) diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 2c83b759a..00f5ec48a 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -42,14 +42,17 @@ [orcpub.template :as template] [orcpub.dnd.e5.options :as opt] [orcpub.dnd.e5.events :as events] - [orcpub.ver :as v] + [orcpub.fork.integrations :as integrations] [orcpub.fork.branding :as branding] + [orcpub.fork.splash :as splash] [orcpub.fork.user-tier] - [orcpub.fork.integrations :as integrations] + [orcpub.ver :as v] [clojure.string :as s] [cljs.reader :as reader] [orcpub.user-agent :as user-agent] - [bidi.bidi :as bidi])) + [bidi.bidi :as bidi] + [camel-snake-kebab.core :as csk]) + #_(:require-macros [cljs.core.async.macros :refer [go]])) ;; the `amount` of "uses" an action may have before it warrants ;; using a dropdown instead of a list of checkboxes @@ -152,6 +155,9 @@ (def login-style {:color "#f0a100"}) +(def login-style-menu + {:background-color "rgba(0,0,0,0.4)"}) + (defn dispatch-logout [] (dispatch [:logout])) @@ -216,15 +222,14 @@ (when username {:on-mouse-over handle-user-menu :on-mouse-out hide-user-menu}) - [:div.flex.align-items-c - [:div.user-icon [svg-icon "orc-head" 40 ""]] + [:div.b-rad-5.flex.align-items-c.p-l-10.p-r-10.p-t-5.p-b-5.f-s-16 {:style login-style-menu } + [:div.user-icon [svg-icon "orc-head" 35 ""]] (if username [:span.f-w-b.t-a-r (when (not @(subscribe [:mobile?])) [:span.m-r-5 username])] [:span.pointer.flex.flex-column.align-items-end - [:span.orange.underline.f-w-b.m-l-5 - {:style login-style - :on-click dispatch-route-to-login} + [:span.white.f-w-b.m-l-5 + {:on-click dispatch-route-to-login} [:span "LOGIN"]]]) (when username [:i.fa.m-l-5.fa-caret-down])] @@ -296,6 +301,43 @@ name]) buttons))])])))) +(defn header-tab2 [] + (let [hovered? (r/atom false)] + (fn [title icon on-click disabled active device-type & buttons] + (let [mobile? (= :mobile device-type)] + [:div.f-w-b.f-s-14.t-a-c.header-tab.m-l-2.m-r-2.posn-rel + {:on-click (fn [e] + (if (seq buttons) + (swap! hovered? not) + (when (fn? on-click) (on-click e)))) + :on-mouse-over #(when-not mobile? (reset! hovered? true)) + :on-mouse-out #(when-not mobile? (reset! hovered? false)) + :style (if active active-style) + :class-name (str (if disabled "disabled" "pointer") + " " + (if (not mobile?) "w-110"))} + [:div.p-10 + {:class-name (if (not active) (if disabled "opacity-2" "opacity-6 hover-opacity-full"))} + (let [size (if mobile? 24 48)] (svg-icon icon size "")) + (if (not mobile?) + [:div.title.uppercase title])] + (if (and (seq buttons) + @hovered?) + [:div.uppercase.shadow + {:style (if mobile? mobile-header-menu-item-style header-menu-item-style)} + (doall + (map + (fn [{:keys [name route]}] + ^{:key name} + [:div.p-10.opacity-5.hover-opacity-full.a-white + {:on-click (fn [e] + (.stopPropagation e) + (reset! hovered? false)) + :style (let [current-route @(subscribe [:route])] + (when (or (= route current-route) + (= route (get current-route :handler))) active-style))} + [:a.no-text-decoration {:href route} name]]) + buttons))])])))) (def social-icon-style {:color :white @@ -336,7 +378,7 @@ :right 25}) (def search-input-parent-style - {:background-color "rgba(0,0,0,0.15)"}) + {:background-color "rgba(0,0,0,0.3)"}) ;; dead — zero callers #_(def transparent-search-input-style @@ -375,9 +417,8 @@ (defn route-to-my-encounters-page [] (dispatch [:route routes/dnd-e5-my-encounters-route])) -(def logo [:img.h-60.pointer - {:src branding/logo-path - :on-click route-to-default-route}]) +(def logo [:a {:href "/" } [:img.h-60.pointer + {:src branding/logo-path}]]) (defn app-header [] (let [device-type @(subscribe [:device-type]) @@ -488,6 +529,15 @@ :route routes/dnd-e5-combat-tracker-page-route} {:name "Encounter Builder" :route routes/dnd-e5-encounter-builder-page-route}] + (when (seq splash/header-generator-entries) + (into [header-tab2 + "generators" + "elven-castle" + "" + false + false + device-type] + splash/header-generator-entries)) [header-tab "My Content" "beer-stein" @@ -553,8 +603,9 @@ [:div.flex {:style registration-left-column-style} [:div.flex.justify-cont-s-a.align-items-c {:style registration-header-style} - [:img.h-55.pointer - {:src branding/logo-path + [:img.pointer + {:class branding/registration-logo-class + :src branding/logo-path :on-click route-to-default-page}]] [:div.flex-grow-1 content] [views-2/legal-footer]] @@ -648,7 +699,10 @@ :font-weight "600"} :class (when bad-email? "disabled opacity-5 hover-no-shadow") :on-click (when (not bad-email?) (make-event-handler :send-password-reset @params))} - "SUBMIT"]]]))))) + "SUBMIT"] + [:div.m-t-20 + [:span "Didn't receive reset email? " [:br] [:a.orange {:href "/help/im-not-getting-my-signup-password-reset-email/" :target "_blank"} "whitelist"] " our domain then try it again."]] + ]]))))) (defn password-reset-expired-page [] [send-password-reset-page "Your reset link has expired, you must complete the reset within 24 hours. Please use the form below to send another reset email."]) @@ -765,7 +819,9 @@ [:div [:span "We sent a verification email to "] [:span.f-w-b.red.f-s-18 @(subscribe [:temp-email])] - [:span ". You must verify to complete registration and the link we sent will only be valid for 24 hours."]])) + [:span ". You must verify to complete registration and the link we sent will only be valid for 24 hours."] + [:span " "] + [:span "Remember to check your spam folder."]])) (defn password-reset-sent [] (email-sent @@ -854,11 +910,12 @@ :border-width "1px" :border-bottom-width "3px"} :on-click #(dispatch [:registration-send-updates? (not send-updates?)])}] - [:span.m-l-5 (str "Yes! Send me updates about " branding/app-name ".")]] - [:div.m-t-30 + [:span.m-l-5 (str "Yes! Send me updates about " branding/app-name)]] + [:div.m-t-10 [:div.p-10 [:span "Already have an account?"] (login-link)] + [:div.m-t-10.m-b-20 [:span "After clicking JOIN A validation email will be sent to the above email address."]] [:button.form-button {:style {:height "40px" :width "174px" @@ -897,8 +954,7 @@ :text-shadow "1px 2px 1px rgba(0,0,0,0.37)" :margin-top "20px"}} "LOGIN"] - ;[:div.m-t-10 - ; [facebook-login-button]] + [:div.m-t-10] [:div.login-form-inputs [form-input {:title "Username or Email" :key :username @@ -916,6 +972,7 @@ login-message hide-login-message]]) [:div.m-t-10 + [:button.form-button {:style {:height "40px" :width "174px" @@ -924,15 +981,18 @@ :on-click #(dispatch [:login @params true])} "LOGIN"] [:div.m-t-20 - [:span "Don't have a login? "] + [:span "Don't have a login? "][:br][:br] [:span.orange.underline.pointer {:on-click route-to-register-page} "REGISTER NOW"]] [:div.m-t-20 - [:span "Forgot your password? "] + [:span "Forgot your password? "][:br][:br] [:span.orange.underline.pointer {:on-click route-to-reset-password-page} - "RESET PASSWORD"]]]]]))))) + "RESET PASSWORD"]] + + [:div.m-t-20 + [:span "Didn't receive validation the email? " [:br] [:a.orange {:href "/help/im-not-getting-my-signup-password-reset-email/" :target "_blank"} "Whitelist"] " our domain then reset your password." ]]]]]))))) (def loading-style {:position :fixed @@ -1169,7 +1229,7 @@ (defn spell-component [{:keys [name level school casting-time ritual range duration components description summary page source] :as spell} include-name? & [subheader-size]] [:div.m-l-10.l-h-19 [spell-summary name level school ritual include-name? subheader-size] - (spell-field "Casting Time" casting-time) + (spell-field "Casting Time" (str casting-time (if ritual " (ritual)" ""))) (spell-field "Range" range) (spell-field "Duration" duration) (let [{:keys [verbal somatic material material-component]} components] @@ -1252,7 +1312,7 @@ legendary-actions)] [:div.m-l-10.l-h-19 (when (not @(subscribe [:mobile?])) {:style two-columns-style}) - [:span.f-s-24.f-w-b name] + [:span.f-s-24.f-w-b.m-b-20 name] [:div.f-s-18.i.f-w-b (monsters/monster-subheader size type subtypes alignment)] (spell-field "Armor Class" (str armor-class (when armor-notes (str " (" armor-notes ")")))) (let [{:keys [mean die-count die modifier]} hit-points] @@ -1393,17 +1453,7 @@ (defn close-orcacle [] (dispatch [:close-orcacle])) -;; Used in legal footer below. template.cljc has a separate srd-link for character_builder. -(def srd-link - [:a.orange {:href "/SRD-OGL_V5.1.pdf" :target "_blank"} "the 5e SRD"]) - -(defn current-year [] - (.getFullYear (js/Date.))) -(defn raw-html - "Render raw HTML string inside Reagent." - [html] - [:div {:dangerouslySetInnerHTML #js {:__html html}}]) (defn orcacle [] (let [search-text @(subscribe [:search-text])] @@ -1433,6 +1483,22 @@ [:div.flex-grow-1 [search-results]]]])) +(def srd-link + [:a.orange {:href "/dnld/SRD-OGL_V5.1.pdf" :target "_blank"} "the 5e SRD-OGL 5.1"]) + +(defn current-year [] + (.getFullYear (js/Date.))) + +;; ------------------------------------------------------------------ +;; A helper that lets us embed a raw string of HTML inside Reagent. +;; (If you’re using reagent‑core ≥1.2, `:raw` is built‑in; otherwise +;; you can use the small shim below.) +;; ------------------------------------------------------------------ +;; Render *any* raw HTML string. +(defn raw-html [html] + ;; Note: no `:>` or `js/React.createElement`. + [:div {:dangerouslySetInnerHTML #js {:__html html}}]) + (defn content-page [title button-cfgs content & {:keys [hide-header-message? frame?]}] ;; Plain atom (not r/atom) mirrors the :orcacle-open? subscription value ;; for the scroll handler, which runs as a DOM event listener outside @@ -1447,6 +1513,7 @@ (if (>= scroll-top header-height) (set! (.-display (.-style sticky-header)) "block") (set! (.-display (.-style sticky-header)) "none")))))] + (r/create-class {:component-did-mount (fn [comp] ;; Read directly from app-db — lifecycle methods are @@ -1461,6 +1528,7 @@ (when-not frame? (js/window.addEventListener "scroll" on-scroll)) (js/window.scrollTo 0,0)) + :component-will-unmount (fn [comp] (when-not frame? (js/window.removeEventListener "scroll" on-scroll))) @@ -1469,7 +1537,8 @@ (let [srd-message-closed? @(subscribe [:srd-message-closed?]) orcacle-open? @(subscribe [:orcacle-open?]) theme @(subscribe [:theme]) - mobile? @(subscribe [:mobile?])] + mobile? @(subscribe [:mobile?]) + username? @(subscribe [:username])] (reset! orcacle-open?* orcacle-open?) [:div.app.min-h-full {:class theme @@ -1493,6 +1562,7 @@ hdr]]] [:div.flex.justify-cont-c.main-text-color [:div.content hdr]] + ;; Support banner (integrations-gated) [:div.m-l-20.m-r-20.f-w-b.f-s-18.container.m-b-10.main-text-color [integrations/support-banner @@ -1507,23 +1577,35 @@ [:div#app-main.container [:div.content.w-100-p content]] + [:div.main-text-color.flex.justify-cont-c [:div.content.f-w-n.f-s-12 ;; Content slot (integrations-gated) [integrations/content-slot @(subscribe [:user-tier])] + [:div.flex.justify-cont-s-b.align-items-c.flex-wrap.p-10 [:div [:div.m-b-5 "Icons made by Lorc, Caduceus, and Delapouite. Available on " [:a.orange {:href "http://game-icons.net"} "http://game-icons.net"]] [:div.m-b-5 "Artwork provided by the talented Sandra. Available on " [:a.orange {:href "https://www.deviantart.com/sandara" :target :_blank} "Deviantart"]]] [:div.m-l-10 - [:a.orange {:href "https://github.com/Orcpub/orcpub/issues" :target :_blank} "Feedback/Bug Reports"]] + [:div.m-b-5.justify-cont-c + (when-let [url (not-empty (:patreon branding/social-links))] + [:a.orange {:href url :target :_blank} "Support this site on Patreon"]) + (when (seq branding/help-url) + [:a.orange.m-l-5 {:href branding/help-url :target :_blank} "Help"]) + [:a.orange.m-l-5 {:href "https://github.com/Orcpub/orcpub/issues" :target :_blank} "Feedback/Bug Reports"]]] [:div.m-l-10.m-r-10.p-10 - [:a.orange {:href "/privacy-policy" :target :_blank} "Privacy Policy"] - [:a.orange.m-l-5 {:href "/terms-of-use" :target :_blank} "Terms of Use"]] + [:div.m-b-5 + (for [{:keys [label href]} splash/legal-footer-links] + ^{:key href} + [:a.orange.m-l-5 {:href href :target :_blank} label])]] [:div.legal-footer - [:p "© " (current-year) " " branding/copyright-holder] - [:p "This site is based on " srd-link " - Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © " (current-year) " Wizards. All Rights Reserved."] - [:p "This site is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] + [:p "© " (current-year) " " + (if (seq branding/copyright-url) + [:a.orange {:href branding/copyright-url :target :_blank} branding/copyright-holder] + branding/copyright-holder)] + [:p "This site is based on " srd-link " - Wizards of the Coast, Dungeons & Dragons, D&D, and their logos are trademarks of Wizards of the Coast LLC in the United States and other countries. © 2025 Wizards. All Rights Reserved."] + [:p branding/app-name " is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] [:p "Version " (v/version) " (" (v/date) ") " (v/description) " edition"]]] [debug-data]]]])]))}))) @@ -1538,7 +1620,8 @@ {:border-top "2px solid rgba(255,255,255,0.5)"}) #_(def thumbnail-style {:height "100px" - :max-width "200px"}) + :max-width "200px" + :border-radius "5px"}) (defn other-user-component [owner & [text-classes show-follow?]] (let [following-users @(subscribe [:following-users]) @@ -1707,8 +1790,8 @@ conj [:div] buttons))] - [:div {:class (if list? "m-t-0" "m-t-4")} - [:span.f-s-24.f-w-600 + [:div {:class-name (if list? "m-t-0" "m-t-4") } + [:span.f-s-24.f-w-600 {:class (csk/->camelCase (str title))} value]]]) (defn list-display-section [title image-name values] @@ -2531,7 +2614,7 @@ (some (complement s/blank?) descriptions)) [:div.m-t-20.t-a-l [:div.f-w-b.f-s-18 title] - [:div + [:div {:class (csk/->camelCase (str title))} (doall (map-indexed (fn [i description] @@ -2702,7 +2785,8 @@ @(subscribe [::char/notes id]) (set-notes-handler id) {:style notes-style - :class "input"}]]]]]]])) + :maxLength (:notes branding/field-limits) + :class-name "input"}]]]]]]])) (defn weapon-details-field [nm value] [:div.p-2 @@ -2844,10 +2928,10 @@ ^{:key (str key (:key shield))} [:tr.item.pointer {:on-click (toggle-details-expanded-handler expanded-details k)} - [:td.p-10.f-w-b (str (or (::mi/name armor) (:name armor) "unarmored") + [:td.p-10.f-w-b.armor (str (or (::mi/name armor) (:name armor) "unarmored") (when shield (str " + " (:name shield))))] (when (not mobile?) - [:td.p-10 (boolean-icon proficient?)]) + [:td.p-10.proficient (boolean-icon proficient?)]) [:td.p-10.w-100-p [:div (armor-details-section armor shield expanded?)]] @@ -3527,7 +3611,6 @@ (when @show-selections? [character-selections id])]]])))) - (def character-display-style {:padding "20px 5px" :background-color "rgba(0,0,0,0.15)"}) @@ -3623,13 +3706,14 @@ has-spells? (seq (char/spells-known built-char)) print-button-enabled (if (or (= print-character-sheet-style? nil) (= (str print-character-sheet-style?) "NaN")) - false true)] + false true) + ] [:div.flex.justify-cont-end [:div.p-20 - [:div.f-s-24.f-w-b.m-b-10 "Print Options"] + [:div.f-s-20.f-w-b.m-b-10 "PDF Options"] [:div.m-b-2 [:div.flex.m-b-10 - [:div.m-t-10 + [:div.m-t-5 [labeled-dropdown "Select Character sheet" {:items [{:title "Select" :value " "} @@ -3677,9 +3761,6 @@ [labeled-checkbox "Prepared" print-prepared-spells?]]]]) - [:span.orange.underline.pointer.uppercase.f-s-12 - {:on-click (make-event-handler ::char/hide-options)} - "Cancel"] [:button.form-button.p-10.m-l-5 {:style (print-button-style print-button-enabled) :on-click (export-pdf-handler built-char @@ -3691,13 +3772,30 @@ print-large-abilities? print-character-sheet-style? print-spell-card-dc-mod?)} - "Print"]]])) + "Create PDF"] + [:div.f-s-20.f-w-b.m-b-10.m-t-10 "Other PDFs"] + [:a.orange {:href "/dnld/5eActionsReferencePage.pdf" :target "_blank"} "5e Actions Reference"]] + [:span.orange.underline.pointer.uppercase.m-l-10.f-s-12 + {:on-click (make-event-handler ::char/hide-options)} + "Cancel"]])) (defn make-print-handler [id built-char] #(dispatch [::char/show-options [print-options id built-char]])) + +(defn abilities-spec [vals suffix bonus?] + (reduce-kv + (fn [m k v] + (let [new-k (if suffix + (keyword (str (name k) "-mod")) + k) + new-v (if bonus? (common/bonus-str v) v)] + (assoc m new-k new-v))) + {} + vals)) + (defn character-page [] (let [expanded? (r/atom false)] (fn [{:keys [id] :as arg}] @@ -3711,28 +3809,27 @@ built-character (subs/built-character character built-template) device-type @(subscribe [:device-type]) username @(subscribe [:username])] + (prn "FRAME?" frame?) [content-page (when (not frame?) "Character Page") - (remove - nil? - (into - (vec (integrations/share-links id @(subscribe [::char/character-name id]))) - (remove nil? - [[:div.m-l-5.hover-shadow.pointer - {:on-click #(swap! expanded? not)} - [:img.h-32 {:src "/image/world-anvil.jpeg"}]] - (when (and username - owner - (= owner username)) - {:title "Edit" - :icon "pencil" - :on-click (make-event-handler :edit-character character)}) - {:title "Print" - :icon "print" - :on-click (make-print-handler id built-character)} - (when (and username owner (not= owner username)) - [add-to-party-component id])]))) + (into + (vec (integrations/share-links id @(subscribe [::char/character-name id]))) + (remove nil? + [#_[:div.m-l-5.hover-shadow.pointer + {:on-click #(swap! expanded? not)} + [:img.h-32 {:src "/image/world-anvil.jpeg"}]] + (when (and username + owner + (= owner username)) + {:title "Edit" + :icon "pencil" + :on-click (make-event-handler :edit-character character)}) + {:title "Export" + :icon "download" + :on-click (make-print-handler id built-character)} + (when (and username owner (not= owner username)) + [add-to-party-component id])])) [:div.p-10.main-text-color (when @expanded? (let [url js/window.location.href] @@ -3812,20 +3909,13 @@ (defn input-builder-field [name value on-change attrs] [builder-field :input name value on-change attrs]) -;; dead — zero callers -#_(defn text-field [{:keys [value on-change]}] - [comps/input-field - :input - value - on-change - {:class "input"}]) - (defn textarea-field [{:keys [value on-change]}] [comps/input-field :textarea value on-change - {:class "input"}]) + {:class-name "input" + :maxLength (:notes branding/field-limits)}]) (defn number-field [{:keys [value on-change]}] [comps/input-field @@ -3835,7 +3925,8 @@ (on-change (when (re-matches #"\d+" v) (js/parseInt v)))) {:class "input" - :type :number}]) + :type :number + :maxLength (:number branding/field-limits)}]) (defn attunement-value [attunement key name] [:div @@ -4235,7 +4326,8 @@ (prop item) #(dispatch [prop-event prop %]) {:class "input h-40" - :type type}]]) + :type type + :maxLength (:text branding/field-limits)}]]) #_(defn item-input-field [title prop item & [class-names]] (builder-input-field title prop item ::mi/set-item-name class-names)) @@ -5216,7 +5308,7 @@ "Amount to Select" {:items (map value-to-item - (range 1 11)) + (range 1 31)) :value (get selection-cfg :choose) :on-change #(dispatch [value-change-event index (assoc selection-cfg :choose (js/parseInt %))])}]])])) @@ -7741,8 +7833,14 @@ [:div.m-t-5.red error])] :else - [:div + [:<> [:span current-email] + [:button.link-button.m-l-10 + {:on-click #(do (reset! editing? true) + (reset! new-email "") + (reset! confirm-email "") + (dispatch [:change-email-clear]))} + "Change"] (when pending-email [:div.m-t-5.f-s-14 "Pending: " pending-email " — check your email to verify the change. " @@ -7752,13 +7850,7 @@ {:on-click #(dispatch [:change-email pending-email])} "Resend"] (when error - [:span.m-l-5.red.f-s-14 error])]) - [:button.link-button.m-l-10 - {:on-click #(do (reset! editing? true) - (reset! new-email "") - (reset! confirm-email "") - (dispatch [:change-email-clear]))} - "Change"]])] + [:span.m-l-5.red.f-s-14 error])])])] ;; ─── Email Updates Toggle ───────────────────────────────── [:div.p-5 [:span.f-w-b "Email Updates: "] @@ -7986,20 +8078,22 @@ [:button.form-button.m-l-5 {:on-click (make-event-handler :route char-page-route)} "view"] - [:button.form-button.m-l-5 - {:on-click (export-pdf - built-char - id - plugin-data - {:print-character-sheet? true - :print-spell-cards? true - :print-prepared-spells? false - :print-character-sheet-style? 1 - :print-spell-card-dc-mod? true})} - "print"] + (when (or (not branding/restrict-print-to-owner?) (= username owner)) + [:button.form-button.m-l-5 + {:on-click (export-pdf + built-char + id + plugin-data + {:print-character-sheet? true + :print-spell-cards? true + :print-prepared-spells? false + :print-character-sheet-style? 1 + :print-spell-card-dc-mod? true})} + "print"]) (when (and (= username owner) (seq folders)) [:select.form-button.m-l-5.builder-option-dropdown - {:value (or current-folder-id "") + {:style {:width "auto" :align-self "stretch" :box-sizing "border-box"} + :value (or current-folder-id "") :on-change (fn [e] (let [val (.-value (.-target e))] (if (= val "") @@ -8265,10 +8359,19 @@ expanded-characters @(subscribe [:expanded-characters]) username @(subscribe [:username]) selected-ids @(subscribe [::char/selected]) - has-selected? @(subscribe [::char/has-selected?])] - [content-page + has-selected? @(subscribe [::char/has-selected?]) + user-tier @(subscribe [:user-tier])] + (integrations/track-character-list! (count characters) user-tier) + #_(prn (str (count characters) " characters - " user-tier)) +[content-page "Characters" - [{:title "New" + [#_(if (>= (count characters) 5) {:title "Free accounts are limited to 5 characters" + :icon "plus" + :class-name "cursor-disabled"} + {:title "New" + :icon "plus" + :on-click #(dispatch [:new-character])}) + {:title "New" :icon "plus" :on-click #(dispatch [:new-character])} {:title "Make Party" diff --git a/src/cljs/orcpub/fork/branding.cljs b/src/cljs/orcpub/fork/branding.cljs index dee644b31..3c41adc52 100644 --- a/src/cljs/orcpub/fork/branding.cljs +++ b/src/cljs/orcpub/fork/branding.cljs @@ -48,3 +48,19 @@ (def field-limits "Max-length constraints for form input fields." (:field-limits config {:notes 50000 :text 255 :number 7})) + +;; ─── Footer ──────────────────────────────────────────────────────── + +(def copyright-url + "URL for copyright holder name in footer. Empty = plain text." + (:copyright-url config "")) + +;; ─── UI Behavior ─────────────────────────────────────────────────── + +(def registration-logo-class + "CSS class for logo on registration/login page." + (:registration-logo-class config "h-55")) + +(def restrict-print-to-owner? + "Whether print button is restricted to character owner." + (:restrict-print-to-owner? config false)) From 16ae308ca72fdd85e8d2473ce60c93fe5f350d57 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Wed, 4 Mar 2026 02:08:22 +0000 Subject: [PATCH 40/50] refactor: move PDF sheet style list into fork/integrations --- src/cljs/orcpub/dnd/e5/views.cljs | 7 ++----- src/cljs/orcpub/fork/integrations.cljs | 10 ++++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 00f5ec48a..f3773403b 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -3716,11 +3716,8 @@ [:div.m-t-5 [labeled-dropdown "Select Character sheet" - {:items [{:title "Select" :value " "} - {:title "Original 5e Character sheet" :value 1} - {:title "Original 5e Character sheet - optional variant" :value 2} - {:title "Icewind Dale 5e Character sheet" :value 3} - {:title "Petersen Games - Cthulhu Mythos Sagas sheet" :value 4}] + {:items (into [{:title "Select" :value " "}] + (integrations/sheet-styles @(subscribe [:user-tier]))) :value print-character-sheet-style? :on-change (make-arg-event-handler ::char/set-print-character-sheet-style? js/parseInt)}]]] [integrations/pdf-options-slot @(subscribe [:user-tier])] diff --git a/src/cljs/orcpub/fork/integrations.cljs b/src/cljs/orcpub/fork/integrations.cljs index 0ace09912..74d784e4b 100644 --- a/src/cljs/orcpub/fork/integrations.cljs +++ b/src/cljs/orcpub/fork/integrations.cljs @@ -76,6 +76,16 @@ [_opts] nil) +;; ─── PDF Sheet Styles ─────────────────────────────────────── +;; Returns the list of available character sheet styles for the dropdown. +;; Fork overrides: return tier-gated styles for premium users. + +(defn sheet-styles + "Available character sheet styles. Returns the default sheet only. + Fork overrides: return additional styles gated by user tier." + [_user-tier] + [{:title "Original 5e Character sheet" :value 1}]) + ;; ─── PDF Options Slot ──────────────────────────────────────── ;; Hook for additional content below PDF sheet options. ;; Fork overrides: return hiccup for premium feature promos, etc. From e637ae6c2c9eac3fc39ae9581a5a5e1b15caedbe Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 5 Mar 2026 04:07:30 +0000 Subject: [PATCH 41/50] sync shared infrastructure from dmv/hotfix-integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked shared files from dmv/ — no merge commit, preserving clean merge path for breaking/ → dmv/ sync. Infrastructure: docker-setup.sh → run CLI redesign, Docker secrets, nginx dev config, transactor template, compose CI workflow updates. App: PDFBox 3.x migration, CSP extensibility, email error throttle, config.clj secrets support, session token path fix, cookie consent. Docs: DOCKER, ENVIRONMENT, ERROR_HANDLING, email-system, branding. Assets: 6 SVGs, favicon, SRD PDF → dnld/, 5e actions reference, build.bat, removed unreferenced logo variants. --- .env.example | 43 +- .gitattributes | 11 +- .github/workflows/docker-integration.yml | 10 +- .gitignore | 4 +- README.md | 9 +- build.bat | 9 + deploy/nginx-dev.conf | 65 ++ deploy/nginx.conf.template | 8 +- deploy/start.sh | 41 +- dev/user.clj | 2 +- docker-migrate.sh | 3 +- docker-user.sh | 18 +- docker/scripts/manage-user.clj | 10 +- docker/transactor.properties.template | 35 +- docs/BRANDING-AND-INTEGRATIONS.md | 130 ++- docs/DOCKER.md | 448 ++++++++- docs/ENVIRONMENT.md | 4 + docs/ERROR_HANDLING.md | 49 + docs/email-system.md | 24 +- env/dev/env/index.cljs | 2 +- project.clj | 4 +- .../public/dnld/5eActionsReferencePage.pdf | Bin 0 -> 186281 bytes resources/public/{ => dnld}/SRD-OGL_V5.1.pdf | Bin resources/public/favicon.ico | Bin 10862 -> 22382 bytes resources/public/image/discussion.svg | 1 + resources/public/image/elven-castle.svg | 1 + resources/public/image/giant-squid.svg | 1 + resources/public/image/login-side.jpg | Bin 66296 -> 67516 bytes resources/public/image/monk-face.svg | 1 + resources/public/image/orcpub-box-logo.png | Bin 47613 -> 0 bytes .../image/orcpub-card-logo - original.png | Bin 3937 -> 0 bytes resources/public/image/stone-tablet.svg | 1 + resources/public/image/wanted-reward.svg | 1 + resources/public/js/cookies.js | 6 +- docker-setup.sh => run | 937 +++++++++++------- scripts/common.sh | 5 +- src/clj/orcpub/config.clj | 39 +- src/clj/orcpub/csp.clj | 6 +- src/clj/orcpub/db/schema.clj | 11 +- src/clj/orcpub/email.clj | 5 +- src/clj/orcpub/index.clj | 17 +- src/clj/orcpub/pdf.clj | 3 +- src/clj/orcpub/security.clj | 4 +- src/clj/orcpub/styles/core.clj | 27 +- src/cljc/orcpub/constants.cljc | 2 +- src/cljc/orcpub/dnd/e5/template.cljc | 2 +- src/cljc/orcpub/dnd/e5/templates/ua_base.cljc | 4 +- src/cljc/orcpub/pdf_spec.cljc | 36 + src/cljc/orcpub/route_map.cljc | 2 +- src/cljs/orcpub/character_builder.cljs | 43 +- src/cljs/orcpub/dnd/e5/db.cljs | 4 +- src/cljs/orcpub/dnd/e5/events.cljs | 458 +++++---- src/cljs/orcpub/ver.cljc | 4 +- test/docker/README.md | 8 +- test/docker/reset-test.sh | 35 + test/docker/test-upgrade.sh | 4 +- 56 files changed, 1802 insertions(+), 795 deletions(-) create mode 100644 build.bat create mode 100644 deploy/nginx-dev.conf create mode 100644 resources/public/dnld/5eActionsReferencePage.pdf rename resources/public/{ => dnld}/SRD-OGL_V5.1.pdf (100%) create mode 100644 resources/public/image/discussion.svg create mode 100644 resources/public/image/elven-castle.svg create mode 100644 resources/public/image/giant-squid.svg create mode 100644 resources/public/image/monk-face.svg delete mode 100644 resources/public/image/orcpub-box-logo.png delete mode 100644 resources/public/image/orcpub-card-logo - original.png create mode 100644 resources/public/image/stone-tablet.svg create mode 100644 resources/public/image/wanted-reward.svg rename docker-setup.sh => run (73%) diff --git a/.env.example b/.env.example index 5bf0a5988..b9a284f13 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ============================================================================ -# Docker Environment Configuration +# Dungeon Master's Vault — Docker Environment Configuration # # Copy this file to .env and update the values: # cp .env.example .env @@ -13,9 +13,10 @@ PORT=8890 TZ=America/Chicago # Docker image names. Set these if you tag your own builds -# so compose/swarm finds them. Defaults: orcpub-app, orcpub-datomic -# ORCPUB_IMAGE=orcpub-app:latest -# DATOMIC_IMAGE=orcpub-datomic:latest +# (e.g., docker build -t dmv:2.6.0.0 .) so compose/swarm finds them. +# Defaults: orcpub-app, orcpub-datomic +# ORCPUB_IMAGE=dmv:2.6.0.0 +# DATOMIC_IMAGE=orcpub-datomic # --- Datomic Database --- # Datomic Pro with dev storage protocol (required for Java 21 support) @@ -83,24 +84,24 @@ EMAIL_TLS=FALSE # --- Branding (optional) --- # Override app identity for forks. All have sensible defaults in fork/branding.clj. -# APP_NAME=My D&D Toolkit -# APP_URL=https://example.com -# APP_LOGO_PATH= -# APP_OG_IMAGE= -# APP_COPYRIGHT_HOLDER= -# APP_COPYRIGHT_YEAR= -# APP_EMAIL_SENDER_NAME= -# APP_PAGE_TITLE= +# APP_NAME=Dungeon Master's Vault +# APP_URL=https://www.dungeonmastersvault.com +# APP_LOGO_PATH=/image/dmv-logo.svg +# APP_OG_IMAGE=/image/dmv-box-logo.png +# APP_COPYRIGHT_HOLDER=Dungeon Master's Vault +# APP_COPYRIGHT_YEAR=2026 +# APP_EMAIL_SENDER_NAME=Dungeon Master's Vault Team +# APP_PAGE_TITLE=Dungeon Master's Vault # APP_TAGLINE=A D&D 5e character builder and resource compendium -# APP_SUPPORT_EMAIL=support@example.com -# APP_HELP_URL= +# APP_SUPPORT_EMAIL=thDM@dungeonmastersvault.com +# APP_HELP_URL=https://www.dungeonmastersvault.com/help/ # --- Social Links (optional) --- # Shown in app header. Leave empty to hide a link. -# APP_SOCIAL_PATREON= -# APP_SOCIAL_FACEBOOK= +# APP_SOCIAL_PATREON=https://www.patreon.com/DungeonMastersVault +# APP_SOCIAL_FACEBOOK=https://www.facebook.com/groups/252484128656613/ # APP_SOCIAL_BLUESKY= -# APP_SOCIAL_TWITTER= +# APP_SOCIAL_TWITTER=https://twitter.com/thdmv # APP_SOCIAL_REDDIT= # APP_SOCIAL_DISCORD= @@ -108,10 +109,10 @@ EMAIL_TLS=FALSE # Third-party integrations. Leave empty to disable. # Server-side (fork/integrations.clj) loads SDK scripts; client-side (fork/integrations.cljs) # provides in-app hooks. CSP domains are auto-derived from these values. -# MATOMO_URL= -# MATOMO_SITE_ID= -# ADSENSE_CLIENT= -# ADSENSE_SLOT= +# MATOMO_URL=https://analytics.dungeonmastersvault.com +# MATOMO_SITE_ID=1 +# ADSENSE_CLIENT=ca-pub-3202063096003962 +# ADSENSE_SLOT=4970831358 # --- Initial Admin User (optional) --- # Set these then run: ./docker-user.sh init diff --git a/.gitattributes b/.gitattributes index 54a10968c..bc326d5ab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,14 @@ # Fork override files — keep each branch's version during merges. -# Public/breaking has stubs, DMV has real implementations. +# DMV has real implementations, public/breaking has stubs. src/clj/orcpub/fork/** merge=ours src/cljc/orcpub/fork/** merge=ours src/cljs/orcpub/fork/** merge=ours + +# DMV branding assets — keep each branch's version during merges. +# These only exist on dmv/ and should never appear on public branches. +resources/public/image/*DM* merge=ours +resources/public/image/patron_button* merge=ours +resources/public/js/dungeonmastersvault* merge=ours +resources/public/ads.txt merge=ours +deploy/error/** merge=ours +deploy/maintenance/** merge=ours diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 35210e17e..49f226952 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -14,7 +14,7 @@ on: paths: - 'docker/**' - 'docker-compose*.yaml' - - 'docker-setup.sh' + - 'run' - 'docker-user.sh' - 'deploy/**' - '.github/workflows/docker-integration.yml' @@ -65,11 +65,11 @@ jobs: - name: Lint shell scripts run: | sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - shellcheck docker-setup.sh docker-user.sh + shellcheck run docker-user.sh - - name: Run docker-setup.sh --auto + - name: Run run --auto run: | - ./docker-setup.sh --auto + ./run --auto echo "--- Generated .env (secrets redacted) ---" sed 's/=.*/=***/' .env @@ -91,7 +91,7 @@ jobs: ORIG_SIG=$(grep '^SIGNATURE=' .env | cut -d= -f2) # Re-run with --force --auto (should regenerate) - ./docker-setup.sh --auto --force + ./run --auto --force # Verify .env was regenerated (new passwords, since --auto generates fresh ones) NEW_ADMIN=$(grep '^ADMIN_PASSWORD=' .env | cut -d= -f2) diff --git a/.gitignore b/.gitignore index 1b4a3d7ec..2bdb84276 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# Ignore environment file (secrets/config) +# Ignore environment file and Docker secret files (passwords) .env .env.secrets.backup /secrets/ @@ -73,7 +73,7 @@ cljs-test-runner-out # Agentic/AI tool files — belong in dotfiles or agents/ branch, not code branches .claude/ -# NewRelic (contains license key — should never be on public branches) +# NewRelic (DMV-specific, contains license key) newrelic* # Transactor properties (may contain hardcoded passwords) diff --git a/README.md b/README.md index 1ff5e1ab4..04d216652 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,7 @@ For running your own production instance: ```bash git clone https://github.com/orcpub/orcpub.git && cd orcpub -./docker-setup.sh # generates .env, SSL certs, directories -docker compose up -d # pull images and start +./run # setup, build, and start (interactive) ./docker-user.sh init # create admin from .env settings ``` @@ -305,8 +304,8 @@ For self-hosting a production instance. ```bash git clone https://github.com/orcpub/orcpub.git && cd orcpub -# Interactive setup — generates .env, SSL certs, and directories -./docker-setup.sh +# Full pipeline — setup, build, and start (interactive) +./run # Pull pre-built images and start docker compose up -d @@ -386,7 +385,7 @@ Place your `.orcbrew` file at `./deploy/homebrew/homebrew.orcbrew` — it loads |--------|---------| | `scripts/migrate-db.sh` | Migrate data from Datomic Free to Pro (bare metal) | | `docker-migrate.sh` | Migrate data from Datomic Free to Pro (Docker) | -| `docker-setup.sh` | Generate `.env`, SSL certs, and directories | +| `run` | Setup, build, and deploy — full pipeline or individual steps | | `docker-user.sh` | Create, verify, and list users in the database | --- diff --git a/build.bat b/build.bat new file mode 100644 index 000000000..52b9e103b --- /dev/null +++ b/build.bat @@ -0,0 +1,9 @@ +rem Windows build script + +CALL lein clean +rem # Step 1: Compile CLJS +CALL lein fig:prod +rem # Step 2: AOT compile (with uberjar-package to skip clean) +CALL lein with-profile uberjar,uberjar-package compile +rem # Step 3: Package jar (uberjar-package prevents re-cleaning) +CALL lein with-profile uberjar,uberjar-package uberjar \ No newline at end of file diff --git a/deploy/nginx-dev.conf b/deploy/nginx-dev.conf new file mode 100644 index 000000000..67440fe76 --- /dev/null +++ b/deploy/nginx-dev.conf @@ -0,0 +1,65 @@ +server { + listen 80; + listen 443 ssl; + server_name localhost; + + ssl_certificate /etc/nginx/snakeoil.crt; + ssl_certificate_key /etc/nginx/snakeoil.key; + #charset koi8-r; + #access_log /var/log/nginx/host.access.log main; + + location / { + client_max_body_size 10m; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://orcpub-dev:8890; + } + + location /generator/ { + client_max_body_size 10m; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_pass http://dndgenerator-dev:80; + } + + + #error_page 404 /404.html; + + # redirect server error pages to the static page /50x.html + # + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + location = /bg.jpg { + root /usr/share/nginx/html; + } + location /homebrew.orcbrew { + root /usr/share/nginx/html/homebrew; + } + + # proxy the PHP scripts to Apache listening on 127.0.0.1:80 + # + #location ~ \.php$ { + # proxy_pass http://127.0.0.1; + #} + + # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 + # + #location ~ \.php$ { + # root html; + # fastcgi_pass 127.0.0.1:9000; + # fastcgi_index index.php; + # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; + # include fastcgi_params; + #} + + # deny access to .htaccess files, if Apache's document root + # concurs with nginx's one + # + #location ~ /\.ht { + # deny all; + #} +} \ No newline at end of file diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index 7d887474e..6a9532dc6 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -13,11 +13,17 @@ server { #charset koi8-r; #access_log /var/log/nginx/host.access.log main; + # Resolve upstream at request time (not startup) so nginx survives + # Swarm's lack of depends_on — the app may not be in DNS yet. + # Docker's embedded DNS is at 127.0.0.11. + resolver 127.0.0.11 valid=30s; + set $upstream_app http://orcpub:${ORCPUB_PORT}; + location / { proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; - proxy_pass http://orcpub:${ORCPUB_PORT}; + proxy_pass $upstream_app; } #error_page 404 /404.html; diff --git a/deploy/start.sh b/deploy/start.sh index 997810e9f..805bc9762 100644 --- a/deploy/start.sh +++ b/deploy/start.sh @@ -5,9 +5,14 @@ # Substitutes secrets into transactor.properties.template and launches the # transactor. Uses pure bash sed (no envsubst/gettext — Alpine doesn't have it). # -# Required env vars: ADMIN_PASSWORD, DATOMIC_PASSWORD -# Optional env vars: ALT_HOST, ENCRYPT_CHANNEL, -# ADMIN_PASSWORD_OLD, DATOMIC_PASSWORD_OLD +# Secret resolution order (per variable): +# 1. Docker secret file at /run/secrets/<name> (Swarm / compose secrets) +# 2. Environment variable (compose environment / .env) +# 3. (none — required vars exit 1 if missing) +# +# Required: ADMIN_PASSWORD, DATOMIC_PASSWORD +# Optional: ALT_HOST, ENCRYPT_CHANNEL, +# ADMIN_PASSWORD_OLD, DATOMIC_PASSWORD_OLD # =========================================================================== set -euo pipefail @@ -15,17 +20,39 @@ set -euo pipefail TEMPLATE="/datomic/transactor.properties.template" OUTPUT="/datomic/transactor.properties" +# --- Read Docker secrets (if mounted) ---------------------------------------- +# Docker secrets are files at /run/secrets/<name>. When present, the secret +# file OVERRIDES any env var — this matches the resolution order documented +# above and in config.clj. If no secret file exists, the env var is used as-is. + +read_secret() { + local var_name="$1" + local secret_file="/run/secrets/${2:-$(echo "$var_name" | tr '[:upper:]' '[:lower:]')}" + if [ -f "$secret_file" ]; then + local val + val=$(<"$secret_file") + # Strip trailing whitespace: \r (Windows), \n (common in secret files) + val="${val%$'\r\n'}" + val="${val%$'\n'}" + val="${val%$'\r'}" + export "$var_name=$val" + fi +} + +read_secret ADMIN_PASSWORD +read_secret DATOMIC_PASSWORD +read_secret ADMIN_PASSWORD_OLD +read_secret DATOMIC_PASSWORD_OLD + # --- Validate required secrets ------------------------------------------------ if [ -z "${ADMIN_PASSWORD:-}" ]; then - echo "ERROR: ADMIN_PASSWORD not set." - echo "See https://docs.datomic.com/on-prem/configuring-embedded.html#sec-2-1" + echo "ERROR: ADMIN_PASSWORD not set (checked env var and /run/secrets/admin_password)." exit 1 fi if [ -z "${DATOMIC_PASSWORD:-}" ]; then - echo "ERROR: DATOMIC_PASSWORD not set." - echo "See https://docs.datomic.com/on-prem/configuring-embedded.html#sec-2-1" + echo "ERROR: DATOMIC_PASSWORD not set (checked env var and /run/secrets/datomic_password)." exit 1 fi diff --git a/dev/user.clj b/dev/user.clj index 82ba7926e..2fba3ceb2 100644 --- a/dev/user.clj +++ b/dev/user.clj @@ -295,4 +295,4 @@ (System/exit 1))) ;; Datomic peer metrics thread is non-daemon and prevents clean JVM exit. ;; Force exit after successful command completion. - (System/exit 0)) + (System/exit 0)) \ No newline at end of file diff --git a/docker-migrate.sh b/docker-migrate.sh index d056f58bc..dd758cc69 100755 --- a/docker-migrate.sh +++ b/docker-migrate.sh @@ -97,8 +97,9 @@ load_env() { local env_file="${SCRIPT_DIR}/.env" if [[ -f "$env_file" ]]; then # Read specific variables we need rather than exporting everything + # tr -d '\r' handles Windows line endings in .env # shellcheck disable=SC1090 - DATOMIC_PASSWORD="${DATOMIC_PASSWORD:-$(. "$env_file" && echo "${DATOMIC_PASSWORD:-}")}" + DATOMIC_PASSWORD="${DATOMIC_PASSWORD:-$(. <(tr -d '\r' < "$env_file") && echo "${DATOMIC_PASSWORD:-}")}" fi } diff --git a/docker-user.sh b/docker-user.sh index ae3d80b8f..832ac7e6e 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -80,7 +80,7 @@ USAGE find_container() { local container="" - # Try docker-compose/docker compose service name first + # Try docker compose service name first (works for compose, not Swarm) if command -v docker-compose &>/dev/null; then container=$(docker-compose ps -q orcpub 2>/dev/null || true) fi @@ -88,14 +88,19 @@ find_container() { container=$(docker compose ps -q orcpub 2>/dev/null || true) fi + # Try Swarm: look for running task of orcpub_orcpub service + if [ -z "$container" ]; then + container=$(docker ps -q --filter "label=com.docker.swarm.service.name=orcpub_orcpub" 2>/dev/null | head -1 || true) + fi + # Fallback: search by image name if [ -z "$container" ]; then - container=$(docker ps -q --filter "ancestor=orcpub/orcpub:latest" 2>/dev/null | head -1 || true) + container=$(docker ps -q --filter "ancestor=orcpub-app:latest" 2>/dev/null | head -1 || true) fi - # Fallback: search by container name pattern + # Fallback: search by container name pattern (matches both compose and swarm) if [ -z "$container" ]; then - container=$(docker ps -q --filter "name=orcpub" 2>/dev/null | head -1 || true) + container=$(docker ps -q --filter "name=orcpub" --filter "name=orcpub_orcpub" 2>/dev/null | head -1 || true) fi echo "$container" @@ -150,9 +155,10 @@ wait_for_ready() { warn "No Docker healthcheck found; polling HTTP on container port..." printf "Waiting for app" while [ $waited -lt $max_wait ]; do - # BusyBox wget (Alpine): only -q and --spider are supported + # BusyBox wget (Alpine): only -q and --spider are supported. + # Use /health endpoint (lightweight, returns 200 when DB is connected). if docker exec "$container" wget -q --spider \ - "http://localhost:${PORT:-8890}/" 2>/dev/null; then + "http://127.0.0.1:${PORT:-8890}/health" 2>/dev/null; then echo "" return 0 fi diff --git a/docker/scripts/manage-user.clj b/docker/scripts/manage-user.clj index 19cd1dde5..319f6a86b 100644 --- a/docker/scripts/manage-user.clj +++ b/docker/scripts/manage-user.clj @@ -16,8 +16,14 @@ [clojure.string :as s])) (def datomic-url - (or (System/getenv "DATOMIC_URL") - "datomic:dev://datomic:4334/orcpub?password=datomic")) + (let [base-url (or (System/getenv "DATOMIC_URL") + "datomic:dev://datomic:4334/orcpub") + password (System/getenv "DATOMIC_PASSWORD")] + (if (and password (not (.contains base-url "password="))) + (str base-url "?password=" password) + (if (.contains base-url "password=") + base-url + (str base-url "?password=datomic"))))) (defn get-conn [] (try diff --git a/docker/transactor.properties.template b/docker/transactor.properties.template index 41349f9d5..4c52ad82f 100644 --- a/docker/transactor.properties.template +++ b/docker/transactor.properties.template @@ -17,28 +17,25 @@ protocol=dev # --- Host & Networking -------------------------------------------------------- -# host= is what the transactor ADVERTISES to peers, not what it binds to. -# Peers connect via the URI hostname, receive this advertised host, then use -# it for subsequent connections. +# host= controls BOTH what the transactor binds to AND what it advertises +# to peers via heartbeats. Datomic has no separate bind/advertise setting. # -# Why "datomic" (the Docker Compose service name) and not "0.0.0.0": +# COMPOSE (default): host=datomic +# DNS resolves to the container's own IP → bind works, advertise gives +# peers the correct service name for reconnection. # -# host=0.0.0.0 works on single-host Docker Compose by accident — peers -# never actually use the advertised address (they reuse the URI hostname). -# In Swarm with multi-node overlay, a peer on node A would try to connect -# to 0.0.0.0:4334 locally, hitting itself instead of the transactor on -# node B. host=<service-name> works in both modes because Docker DNS -# resolves it correctly across the overlay network. +# SWARM: host=0.0.0.0, alt-host=datomic +# "datomic" resolves to a VIP in overlay networks — can't bind to that. +# 0.0.0.0 binds to all interfaces; alt-host gives peers the service name +# for reconnection. docker-setup.sh --swarm switches this automatically. # -# If host=datomic fails: your containers aren't on a shared Docker network. -# docker compose creates one by default (projectname_default). Standalone -# docker run needs --network. Host networking mode has no DNS at all. +# To switch manually: comment one host= line and uncomment the other. host=datomic +#host=0.0.0.0 port=4334 # Alternative hostname for peer fallback connections. -# Default 127.0.0.1 (container loopback). For Swarm with overlay networks, -# set ALT_HOST to the service name or external hostname. +# Compose: 127.0.0.1 (loopback). Swarm: datomic (Docker DNS). alt-host=${ALT_HOST:-127.0.0.1} # --- Storage Access ----------------------------------------------------------- @@ -59,8 +56,12 @@ encrypt-channel=${ENCRYPT_CHANNEL:-true} # deploy/start.sh refuses to start if ADMIN_PASSWORD or DATOMIC_PASSWORD # are not set. # -# Peers must connect with the same DATOMIC_PASSWORD in their URI: -# datomic:dev://datomic:4334/orcpub?password=<DATOMIC_PASSWORD> +# storage-admin-password — locked into the H2 database on first startup. +# Changing it later crashes the transactor ("Unable to connect to embedded +# storage"). Data is NOT lost — set it back or use ADMIN_PASSWORD_OLD to rotate. +# +# storage-datomic-password — peer connection password. Shared by transactor and +# app. Changing it disconnects the app. Use DATOMIC_PASSWORD_OLD to rotate. storage-admin-password=${ADMIN_PASSWORD} storage-datomic-password=${DATOMIC_PASSWORD} diff --git a/docs/BRANDING-AND-INTEGRATIONS.md b/docs/BRANDING-AND-INTEGRATIONS.md index 55b3d65ac..5bb32a4fa 100644 --- a/docs/BRANDING-AND-INTEGRATIONS.md +++ b/docs/BRANDING-AND-INTEGRATIONS.md @@ -1,10 +1,10 @@ -# Branding & Integrations +# Branding, Integrations & Configuration -How to customize the app's identity and add third-party integrations. +How the app handles fork-specific customization (logos, names, analytics, ads, tier gating) without touching shared code. -## Branding +## What Changed -All branding values are configured via environment variables in `.env`. Defaults are set in `src/clj/orcpub/fork/branding.clj`. +The app used to have DMV-specific values hardcoded throughout shared files — views, events, email templates, privacy pages. Every merge between public and production required manually resolving dozens of conflicts in the same large files. Now all fork-specific behavior lives in **6 small override files**. Shared files call the same functions on both branches — they just get different results. @@ -83,103 +83,93 @@ All values have defaults in `fork/branding.clj`. Set env vars in `.env` to overr ### App Identity -| Env Var | Default | Where it shows up | -|---------|---------|-------------------| -| `APP_NAME` | OrcPub | Page titles, emails, privacy policy, OG tags | -| `APP_LOGO_PATH` | /image/orcpub-logo.svg | Header, splash page, privacy page | -| `APP_OG_IMAGE` | /image/orcpub-logo.png | Social sharing preview | -| `APP_TAGLINE` | D&D 5e character builder... | OG meta tags | -| `APP_PAGE_TITLE` | OrcPub: D&D 5e... | Browser tab title | +| Env Var | Default (public) | Default (production) | Where it shows up | +|---------|-----------------|---------------------|-------------------| +| `APP_NAME` | OrcPub | Dungeon Master's Vault | Page titles, emails, privacy policy, OG tags | +| `APP_URL` | *(empty)* | https://www.dungeonmastersvault.com | Privacy policy domain references | +| `APP_LOGO_PATH` | /image/orcpub-logo.svg | /image/dmv-logo.svg | Header, splash page, privacy page | +| `APP_OG_IMAGE` | /image/orcpub-logo.png | /image/dmv-box-logo.png | Social sharing preview | +| `APP_TAGLINE` | Generic D&D 5e description | DMV-specific description | OG meta tags | +| `APP_PAGE_TITLE` | OrcPub: D&D 5e... | Dungeon Master's Vault: D&D 5e... | Browser tab title | ### Copyright & Contact -| Env Var | Default | Where it shows up | -|---------|---------|-------------------| -| `APP_COPYRIGHT_HOLDER` | OrcPub | Footer | -| `APP_COPYRIGHT_YEAR` | 2025 | Footer | -| `APP_SUPPORT_EMAIL` | *(empty = hidden)* | Privacy page, error messages | -| `APP_HELP_URL` | *(empty = hidden)* | Footer help link | +| Env Var | Default (public) | Default (production) | Where it shows up | +|---------|-----------------|---------------------|-------------------| +| `APP_COPYRIGHT_HOLDER` | OrcPub | Dungeon Master's Vault | Footer | +| `APP_COPYRIGHT_YEAR` | *(current year)* | *(current year)* | Footer | +| `APP_SUPPORT_EMAIL` | *(empty = hidden)* | thDM@dungeonmastersvault.com | Privacy page, error messages, events.cljs mailto | +| `APP_HELP_URL` | *(empty = hidden)* | https://www.dungeonmastersvault.com/help/ | Footer help link | ### Email -| Env Var | Default | Where it shows up | -|---------|---------|-------------------| -| `APP_EMAIL_SENDER_NAME` | OrcPub Team | "From" display name | -| `EMAIL_FROM_ADDRESS` | no-reply@orcpub.com | "From" address | +| Env Var | Default (public) | Default (production) | Where it shows up | +|---------|-----------------|---------------------|-------------------| +| `APP_EMAIL_SENDER_NAME` | OrcPub Team | Dungeon Master's Vault Team | "From" display name | +| `EMAIL_FROM_ADDRESS` | no-reply@orcpub.com | no-reply@dungeonmastersvault.com | "From" address | ### Social Links -Shown in the app header/footer when non-empty. Leave unset to hide. +Shown in the app header/footer when non-empty. Leave empty to hide. -| Env Var | Example | -|---------|---------| -| `APP_SOCIAL_PATREON` | `https://www.patreon.com/YourProject` | -| `APP_SOCIAL_FACEBOOK` | `https://www.facebook.com/groups/yourgroup/` | -| `APP_SOCIAL_TWITTER` | `https://twitter.com/yourhandle` | -| `APP_SOCIAL_REDDIT` | `https://reddit.com/r/yoursubreddit` | -| `APP_SOCIAL_DISCORD` | `https://discord.gg/your-invite` | +| Env Var | Default (public) | Default (production) | +|---------|-----------------|---------------------| +| `APP_SOCIAL_PATREON` | *(empty = hidden)* | Patreon URL | +| `APP_SOCIAL_FACEBOOK` | *(empty = hidden)* | Facebook group URL | +| `APP_SOCIAL_BLUESKY` | *(empty = hidden)* | *(empty)* | +| `APP_SOCIAL_TWITTER` | *(empty = hidden)* | Twitter URL | +| `APP_SOCIAL_REDDIT` | *(empty = hidden)* | *(empty)* | +| `APP_SOCIAL_DISCORD` | *(empty = hidden)* | *(empty)* | -When `APP_SOCIAL_PATREON` is set, a supporter button appears in the header. +When `APP_SOCIAL_PATREON` is set, the supporter button appears in the header. When empty, nothing renders. Same code on both branches. ### Field Limits -Input validation constraints for form fields. +Input validation constraints, configurable via env vars. | Env Var | Default | Used for | |---------|---------|----------| -| `APP_FIELD_LIMIT_NOTES` | 50000 | Character notes, backstory | -| `APP_FIELD_LIMIT_TEXT` | 255 | Name fields, short text | -| `APP_FIELD_LIMIT_NUMBER` | 7 | Numeric inputs | - ---- - -## Integrations - -Third-party services (analytics, ads) are managed through two files: - -- **`fork/integrations.clj`** (server-side) — injects `<script>` tags in `<head>`, exports CSP domain allowlists for `pedestal.clj` -- **`fork/integrations.cljs`** (client-side) — provides lifecycle hooks and UI components, reads config from `window.__INTEGRATIONS__` +| `APP_FIELD_LIMIT_NOTES` | 50000 | Character notes, backstory textareas | +| `APP_FIELD_LIMIT_TEXT` | 255 | Name fields, short text inputs | +| `APP_FIELD_LIMIT_NUMBER` | 7 | Numeric input fields | ### Analytics & Ads Server-side (`fork/integrations.clj`) injects SDK scripts in `<head>` and exports CSP domain allowlists for `pedestal.clj`. Client-side (`fork/integrations.cljs`) handles in-app behavior, reading ad client/slot IDs from the `window.__INTEGRATIONS__` config bridge. -| Env Var | Default | What it enables | -|---------|---------|-----------------| -| `MATOMO_URL` | *(empty = disabled)* | Matomo analytics tracking | +| Env Var | Default (public) | Default (production) | +|---------|-----------------|---------------------| +| `MATOMO_URL` | *(empty = disabled)* | Analytics server URL | | `MATOMO_SITE_ID` | *(empty = disabled)* | Matomo site ID | -| `ADSENSE_CLIENT` | *(empty = disabled)* | Google AdSense | - -### Integration Hooks +| `ADSENSE_CLIENT` | *(empty = disabled)* | AdSense publisher ID | +| `ADSENSE_SLOT` | *(empty = disabled)* | AdSense ad slot ID | -The app calls these functions at specific points. By default they're no-ops — override them in `fork/integrations.cljs` to add custom behavior. +--- -**Lifecycle hooks** (called from events/views): +## Integration Hooks (fork/integrations.cljs) -| Function | When it's called | -|----------|-----------------| -| `track-page-view!` | Every route change | -| `on-app-mount!` | App root component mount | -| `track-character-list!` | Character list render | +These are the functions that shared files call. Public repo returns stubs/nil, production returns real UI. -**UI hooks** (return hiccup or nil): +### Lifecycle (called from events/views, no return value) -| Function | Where it renders | -|----------|-----------------| -| `content-slot` | Content page body (2 slots) | -| `supporter-link` | App header | -| `support-banner` | Content page top | -| `pdf-options-slot` | Below PDF sheet options | -| `share-links` | Character page + builder | -| `share-link-www` | Character list items | +| Function | Called from | What it does (production) | +|----------|-----------|--------------------------| +| `track-page-view!` | events.cljs `:route` handler | Matomo page view tracking | +| `on-app-mount!` | views.cljs `content-page` mount | Matomo user identification + ad slot reload | +| `track-character-list!` | views.cljs character list render | Matomo custom variable for character count | ---- +### UI Hooks (return hiccup or nil) -## Adding a New Integration Hook +| Function | Called from | What it renders (production) | +|----------|-----------|------------------------------| +| `content-slot` | views.cljs content page (2 slots) | AdSense banner (default-tier only) | +| `supporter-link` | views.cljs app header | Tier badge (patrons) or Patreon button (default) | +| `support-banner` | views.cljs content page | Dismissable donation CTA (default-tier only) | +| `pdf-options-slot` | views.cljs PDF options panel | Sheet upsell (default-tier only) | +| `share-links` | views.cljs + character_builder.cljs | Email + www share links | +| `share-link-www` | views.cljs character list | Single www link | -1. Add the stub function in `fork/integrations.cljs` (empty body or `nil` return) -2. Wire the call site in the appropriate shared file (views.cljs, etc.) -3. Implement the real behavior in the stub body +**Public repo returns:** `nil` for content-slot, support-banner, pdf-options-slot. Basic Patreon button for supporter-link (when URL configured). Single email link for share-links. --- diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 663237f2b..135eab9c7 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -1,7 +1,221 @@ # Docker Reference Consolidated reference for OrcPub's Docker infrastructure: three services, -two compose files, and the configuration patterns that connect them. +one compose file, and the configuration patterns that connect them. + +**Contents** + +- [Platform Notes](#platform-notes) — Linux, macOS, Windows +- [Quick Start](#quick-start) — setup, build, launch, first user + - [Upgrading an Existing Installation](#upgrading-an-existing-installation) + - [Key Environment Variables](#key-environment-variables) +- [Architecture](#architecture) — services, networking, boot order +- [Compose File](#compose-file) — image naming, registry overrides +- [Transactor Configuration](#transactor-configuration-option-c-hybrid-template) +- [host=datomic Rationale](#hostdatomic-rationale) +- [Jetty Binding](#jetty-binding) +- [Healthcheck Strategy](#healthcheck-strategy) — app and transactor probes +- [Production Memory Tuning](#production-memory-tuning) +- [Volumes](#volumes) +- [Docker Swarm Deployment](#docker-swarm-deployment) — ALT_HOST, scaling, secrets +- [File Inventory](#file-inventory) +- [Security](#security) +- [Troubleshooting](#troubleshooting) — env var conflicts, protocol errors, restarts + +--- + +### Platform Notes + +The setup and management scripts (`docker-setup.sh`, `docker-user.sh`, +`docker-migrate.sh`) are bash scripts. They work on: + +- **Linux** — natively +- **macOS** — natively (Terminal.app, iTerm, etc.) +- **Windows** — run from **Git Bash** (ships with Git for Windows) or + **WSL** (Windows Subsystem for Linux, required by Docker Desktop anyway) + +All scripts handle Windows line endings (`\r\n`) in `.env` and config files +defensively. If you edit `.env` in Notepad or another Windows editor, it +will still work. + +`docker compose` itself works on all three platforms without special steps. + +--- + +## Quick Start — New Install + +You need to run **3 commands**. You don't need to edit any files. + +```sh +# 1. Setup — generates passwords, creates directories, makes SSL certs +./docker-setup.sh --auto + +# 2. Build and launch (first build takes ~10 minutes, then seconds) +docker compose up --build -d + +# 3. Create a user (once all 3 services show "healthy" in docker compose ps) +./docker-user.sh create myuser me@example.com MyPassword1 +``` + +Open **https://localhost** (self-signed cert — your browser will warn, that's +normal). + +That's it. Everything else is optional. + +<details> +<summary>Want to customize? (email, admin user, ports)</summary> + +Run the interactive version instead: + +```sh +./docker-setup.sh # prompts for each setting +``` + +Or edit `.env` after setup — it's a plain text file with comments explaining +every setting. See `.env.example` for the full list. + +</details> + +### What the setup script creates + +| What | Where | Purpose | +|------|-------|---------| +| `.env` | project root | All your passwords and settings | +| `data/` | project root | Database files (persists between restarts) | +| `logs/` | project root | Transactor log files | +| `backups/` | project root | Database backup destination | +| `deploy/snakeoil.*` | deploy/ | Self-signed SSL certificate + key | + +### What you actually run day-to-day + +| Task | Command | +|------|---------| +| Start everything | `docker compose up -d` | +| Stop everything | `docker compose down` | +| Rebuild after code changes | `docker compose up --build -d` | +| Check status | `docker compose ps` | +| View logs | `docker compose logs orcpub --tail 50` | +| Create a user | `./docker-user.sh create <user> <email> <pass>` | +| List users | `./docker-user.sh list` | + +--- + +## Upgrading an Existing Install + +**You edit: nothing.** The upgrade script checks your `.env` and fixes +anything that's out of date. + +```sh +# 1. Pull latest code +git pull + +# 2. Let the upgrade script check and fix your .env +./docker-setup.sh --upgrade + +# 3. Rebuild and restart +docker compose up --build -d +``` + +The upgrade script: +- **Backs up** your `.env` before changing anything +- **Detects** old patterns (password in URL, missing variables) +- **Fixes** them automatically +- **Warns** about things that need your attention (like Free→Pro migration) +- **Does nothing** if your `.env` is already fine + +Your `data/`, `logs/`, and SSL certs are never touched. + +<details> +<summary>What does --upgrade actually check?</summary> + +| Issue | What it does | +|-------|--------------| +| `?password=` in DATOMIC_URL | Extracts password to DATOMIC_PASSWORD, cleans URL | +| Missing DATOMIC_PASSWORD | Adds it (generates random if `--auto` also passed) | +| Missing SIGNATURE | Adds it (warns that sessions will be invalidated) | +| Missing ADMIN_PASSWORD | Adds it | +| `datomic:free://` in URL | Changes to `datomic:dev://` (Datomic Pro). Warns about data migration if needed | +| `localhost` in URL | Changes to `datomic` (Docker service name). Warns if you run outside Docker | + +If you prefer to edit files by hand, just read the output — it tells you +exactly what to change and why. + +</details> + +### Don't use `.env`? (Export vars directly) + +If you set passwords as shell environment variables instead of using `.env`, +the upgrade script can't check your setup. But nothing breaks — the app +reads env vars the same way it always did. + +If you want to start using `.env`: + +```sh +./docker-setup.sh --auto # creates .env with generated passwords +``` + +Then edit the generated `.env` to use your existing passwords instead of +the random ones. + +### Optional: Docker secrets + +Move passwords out of `.env` so they aren't all sitting in one file: + +```sh +# Single server (creates secret files on disk) +./docker-setup.sh --secrets + +# Swarm cluster (stores secrets encrypted in the cluster) +./docker-setup.sh --swarm +``` + +Both read your existing passwords from `.env` or shell env vars — you +don't re-enter anything. If you're not sure which to pick, run `--secrets` +and it will ask if you're using Swarm. + +The script creates `docker-compose.secrets.yaml` and adds `COMPOSE_FILE` +to your `.env` so compose merges both files automatically. No manual +edits needed. Secret files take priority over `.env` — you can leave +`.env` passwords in place or remove them. + +### Password rotation + +```sh +# 1. In .env, add the OLD vars with your current password: +# ADMIN_PASSWORD_OLD=current-password +# DATOMIC_PASSWORD_OLD=current-password + +# 2. Change the main vars to the new password: +# ADMIN_PASSWORD=new-password +# DATOMIC_PASSWORD=new-password + +# 3. Restart +docker compose down && docker compose up -d + +# 4. After everything is working, remove the _OLD vars from .env +``` + +### Key Environment Variables + +These are the variables you'll actually touch. Full reference in +[ENVIRONMENT.md](ENVIRONMENT.md). + +| Variable | Required | Default | What it does | +|----------|----------|---------|--------------| +| `DATOMIC_PASSWORD` | Yes | — | App-to-transactor auth. The app appends `?password=` to `DATOMIC_URL` at startup. | +| `ADMIN_PASSWORD` | Yes | — | Transactor admin/monitoring auth. | +| `SIGNATURE` | Yes | — | JWT signing secret. All login and API calls fail without it. | +| `DATOMIC_URL` | Yes | `datomic:dev://datomic:4334/orcpub` | Database connection URI. No `?password=` — the app adds it from `DATOMIC_PASSWORD`. | +| `PORT` | No | `8890` | App server port. Nginx and healthcheck adapt automatically. | +| `ALT_HOST` | No | `127.0.0.1` | Transactor peer fallback host. Change to `datomic` for Swarm. | +| `EMAIL_SERVER_URL` | No | *(empty)* | SMTP server. Leave empty to disable email (registration still works, just no verification emails). | +| `CSP_POLICY` | No | `strict` | Content Security Policy: `strict`, `permissive`, or `none`. | +| `DEV_MODE` | No | *(empty)* | Set to `true` for CSP Report-Only mode (allows Figwheel hot-reload). | +| `LOAD_HOMEBREW_URL` | No | *(empty)* | URL to fetch `.orcbrew` plugins on first page load. | + +`docker-setup.sh` generates `DATOMIC_PASSWORD`, `ADMIN_PASSWORD`, and +`SIGNATURE` automatically. You only need to edit `.env` if you want email or +custom branding. ## Architecture @@ -160,18 +374,116 @@ Rules of thumb (from Datomic capacity planning docs): | `./deploy/nginx.conf.template` | web | Nginx config template (`envsubst` at startup) | | `./deploy/snakeoil.*` | web | Self-signed SSL certificates | -## Swarm Migration Notes +## Docker Swarm Deployment + +The same `docker-compose.yaml` works for Swarm with two `.env` changes and +an optional `deploy:` section. + +### What changes from single-host + +| Setting | Single-host (default) | Swarm | +|---------|-----------------------|-------| +| `ALT_HOST` | `127.0.0.1` | `datomic` | +| Network | bridge (auto) | overlay (auto with `docker stack deploy`) | +| Secrets | `.env` file (or `file:` secrets) | `.env` file, `file:` secrets, or `external` secrets | -The current configuration is Swarm-ready with minimal changes: +`host=datomic` in the transactor config already resolves via Docker DNS on +both bridge and overlay networks — no template change needed. -- `host=datomic` already works with overlay network DNS -- Set `ALT_HOST=datomic` in `.env` (change from default `127.0.0.1`) -- Add a `deploy:` section to each service for replica count and placement - constraints (~5-10 lines per service) -- Consider using Docker secrets instead of environment variables for - `DATOMIC_PASSWORD` and `ADMIN_PASSWORD` -- No separate compose file needed — the same file works with added deploy - config +### Steps + +```sh +# 1. Initialize Swarm (once per cluster) +docker swarm init + +# 2. Edit .env — change ALT_HOST for multi-node overlay DNS +# ALT_HOST=datomic +# (everything else stays the same) + +# 3. (Optional) Move passwords into Swarm secrets +./docker-setup.sh --swarm +# This creates docker-compose.secrets.yaml and wires COMPOSE_FILE in .env + +# 4. Build images (Swarm doesn't build — it needs pre-built images) +docker compose build + +# 5. Deploy the stack +docker stack deploy -c docker-compose.yaml -c docker-compose.secrets.yaml orcpub + +# 6. Check service status +docker stack services orcpub +docker service logs orcpub_orcpub --follow +``` + +### Scaling notes + +- **datomic**: Must be exactly 1 replica (Datomic transactor is a singleton). +- **orcpub**: Can scale to multiple replicas if they share the same transactor. + Each replica connects to `datomic:4334`. +- **web**: Can scale freely. Each replica proxies to any `orcpub` replica via + Swarm's built-in load balancing. + +### Docker Secrets + +Docker secrets mount passwords as files at `/run/secrets/<name>` inside +the container instead of passing them as environment variables. Both the +transactor (`deploy/start.sh`) and the app (`config.clj`) already check +`/run/secrets/` first, then fall back to environment variables — no code +changes needed. + +**Secret files always win over env vars.** If both exist, the file is used. + +#### File-based secrets (single server, no Swarm) + +The setup script handles everything: + +```sh +./docker-setup.sh --secrets +``` + +This reads your passwords from `.env` (or shell env vars if you export +directly), creates a `secrets/` directory with one file per password, +generates `docker-compose.secrets.yaml`, and adds `COMPOSE_FILE` to +your `.env` so compose merges both files automatically. + +Under the hood it creates: +- `secrets/datomic_password` +- `secrets/admin_password` +- `secrets/signature` +- `docker-compose.secrets.yaml` + +Each file has `chmod 600` (only your user can read it). This is still a +file on your hard drive — not encrypted — but each password is isolated +with strict permissions instead of all sitting together in `.env`. + +#### Swarm secrets (cluster) + +If you're running Docker Swarm, passwords are stored encrypted inside +the cluster. When a container needs one, Swarm delivers it into memory — +the password is never saved to disk on the server running the container. + +```sh +./docker-setup.sh --swarm +``` + +This checks that your node is in Swarm mode, reads passwords from `.env` +(or shell env vars), runs `docker secret create` for each one, and +generates `docker-compose.secrets.yaml`. If you run `--secrets` instead, +it will ask if you're using Swarm and redirect you here automatically. + +Deploy with `docker stack deploy` instead of `docker compose up`. + +#### What changes when using secrets + +| Without secrets | With secrets | +|----------------|--------------| +| Passwords in `.env` (plaintext on disk) | Passwords in `/run/secrets/` (tmpfs in Swarm, file in compose) | +| `DATOMIC_PASSWORD=xxx` in env | `DATOMIC_PASSWORD` env var optional (ignored when secret exists) | +| All config in one `.env` file | Secrets separated from non-sensitive config | + +**Use `printf`, not `echo`** when creating secrets — `echo` appends a newline +that becomes part of the password. Both `start.sh` and `config.clj` strip +trailing newlines defensively, but `printf` avoids the issue entirely. ## File Inventory @@ -179,21 +491,125 @@ The current configuration is Swarm-ready with minimal changes: |------|---------| | `docker/Dockerfile` | Multi-target: `datomic-dist` (downloader), `transactor`, `app-builder`, `app` | | `docker/transactor.properties.template` | Complete transactor config (Option C hybrid template) | -| `deploy/start.sh` | Transactor startup: secret substitution + exec | +| `deploy/start.sh` | Transactor startup: Docker secrets → env var fallback → template substitution → exec | | `deploy/nginx.conf.template` | Nginx reverse proxy template (`envsubst` resolves `${ORCPUB_PORT}`) | | `deploy/snakeoil.sh` | Self-signed SSL certificate generator | | `docker-compose.yaml` | Compose file (pull or build-from-source) | -| `docker-setup.sh` | Interactive setup: generates `.env`, dirs, SSL certs | +| `docker-compose.secrets.yaml` | Generated by `--secrets`/`--swarm` — merges secrets into compose | +| `docker-setup.sh` | Interactive setup: generates `.env`, dirs, SSL certs, secrets | | `.env.example` | Environment variable reference with defaults | ## Security -Both containers run as non-root users (`datomic` and `app`). Secrets are -handled with `chmod 600` file permissions, sed escaping for special characters -in passwords, and `.dockerignore` exclusion of `.env` from the build context. +Both containers run as non-root users (`datomic` and `app`). Passwords support +Docker secrets (`/run/secrets/` files) as an alternative to environment variables — +secret files take priority over env vars when both exist. Additional hardening +includes `chmod 600` file permissions, sed escaping for special characters in +passwords, and `.dockerignore` exclusion of `.env` from the build context. For full reasoning behind each security decision, see `DOCKER-SECURITY.md`. +## Troubleshooting + +### "Connection refused: localhost:4335" + +The app is trying to connect to Datomic at `localhost` instead of the +`datomic` service. This happens when a shell environment variable +overrides the `.env` value. + +**Check what compose sees:** + +```sh +docker compose config | grep DATOMIC_URL +``` + +If it shows `localhost` instead of `datomic`, something in your shell is +setting `DATOMIC_URL`. Common sources: + +- **Codespaces**: `containerEnv` in `devcontainer.json` sets it for local dev +- **`.bashrc` / `.profile`**: Previous `export DATOMIC_URL=...` +- **Other dotfiles**: Any shell init script that exports the variable + +**Fix:** + +```sh +# Check if it's set in your shell +echo $DATOMIC_URL + +# Clear it for this session +unset DATOMIC_URL + +# Or override inline (one-shot, doesn't persist): +source .env && docker compose up -d +``` + +**Why this happens:** Docker Compose resolves `${VAR:-default}` from +your shell environment first, then falls back to the `.env` file. +If your shell already has `DATOMIC_URL` set, compose uses that value +and ignores `.env` entirely. See the comment block at the top of +`docker-compose.yaml` for details. + +### "Unsupported protocol :dev" + +The app jar was built with Datomic Free (which only supports `datomic:free://`), +but the connection URL uses `datomic:dev://` (Datomic Pro). This means the +Docker image wasn't built from source — it was pulled from Docker Hub, where +only old Datomic Free images exist. + +**Fix:** Rebuild from source: + +```sh +docker compose up --build -d +``` + +The `--build` flag is important. Without it, compose reuses existing images +or pulls from a registry. Building from source installs the Datomic Pro peer +jar, which supports the `dev://` protocol. + +### App container keeps restarting + +The compose file has `restart: always`, so a crashed app retries indefinitely. +Check why it's failing: + +```sh +docker compose logs orcpub --tail 50 +``` + +Common causes: +- Wrong `DATOMIC_URL` (see above) +- Transactor not ready yet (wait for `datomic` to show "healthy") +- Missing `SIGNATURE` (set it in `.env` or `docker-setup.sh` generates one) + +### Build takes too long / hangs + +First build downloads Datomic Pro (~400MB) and compiles a Clojure uberjar. +This normally takes ~10 minutes. + +If it hangs during `lein uberjar`, the JVM may be running out of memory. +Check your Docker memory limit: + +```sh +docker info --format '{{.MemTotal}}' +``` + +The build needs at least 4GB. Increase Docker's memory allocation in +Docker Desktop settings, or set `MAVEN_OPTS=-Xmx2g` in the Dockerfile's +build args. + +### Healthcheck failing + +```sh +docker compose ps # shows health status +docker inspect --format='{{json .State.Health}}' orcpub-orcpub-1 +``` + +- **datomic**: Checks if port 4334 is listening. If unhealthy, check + `docker compose logs datomic` for password or storage errors. +- **orcpub**: Hits `http://127.0.0.1:8890/health`. Takes ~2 minutes after + container start. If it never becomes healthy, check the app logs. +- **web**: Depends on orcpub being healthy first. Won't start until orcpub + passes its healthcheck. + ## See Also - `DOCKER-SECURITY.md` — Security hardening decisions with reasoning diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index c7367affa..99ebff0eb 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -93,6 +93,10 @@ See `docker/transactor.properties.template` for the full transactor configuratio | `FIGWHEEL_PORT` | `3449` | Figwheel WebSocket port for frontend hot-reload. Read by `scripts/common.sh`. | | `FIGWHEEL_CONNECT_URL` | *(auto-detected)* | Figwheel WebSocket URL override for remote environments (Gitpod, tunnels). Auto-detected for GitHub Codespaces. Example: `wss://my-remote-host:3449/figwheel-connect` | +### Branding, Social Links & Integrations + +See [BRANDING-AND-INTEGRATIONS.md](BRANDING-AND-INTEGRATIONS.md) for the full reference covering `APP_*`, `APP_SOCIAL_*`, `MATOMO_*`, and `ADSENSE_*` variables. + ## Files That Read Environment | File | Variables Used | diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index afed80ba4..9e6b13dd8 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -216,6 +216,55 @@ All API-calling re-frame subscriptions use the `handle-api-response` HOF from `e This prevents the class of bug where a bare `case` with no default clause crashes on unexpected HTTP statuses. +## Error Notification Emails + +Unhandled exceptions in the Pedestal interceptor chain trigger error notification emails +to the address configured in `EMAIL_ERRORS_TO`. The `send-error-email` function in +`email.clj` produces actionable, security-conscious notifications. + +### Email Format + +**Subject:** `[AppName] ExceptionClass: message @ METHOD /uri` + +**Body sections:** +1. **Request** — scrubbed request map (no credentials, cookies, body params, or Datomic objects) +2. **Exception** — full cause chain with `orcpub.*` stack frames (infrastructure frames suppressed with count) +3. **Exception Data** — `ex-data` map for `ExceptionInfo` exceptions +4. **Interceptor Context** — Pedestal metadata when the exception is a wrapped interceptor error + +### Security: Request Scrubbing + +The following are stripped from the request before emailing: + +- `:json-params`, `:transit-params`, `:form-params` (may contain passwords) +- `:body` (raw input stream) +- `:db`, `:conn` (live Datomic objects) +- `:servlet-request`, `:servlet-response`, `:servlet`, `:url-for` +- `:identity`, `:async-supported?`, `:character-encoding`, `:protocol`, `:path-params`, `:content-length` +- Headers filtered to safe set: `user-agent`, `referer`, `content-type`, `accept-language`, + `cf-ipcountry`, `x-forwarded-for`, `x-real-ip`, `cf-ray`, `sec-fetch-site`, `sec-fetch-mode`, + `x-forwarded-host`, `x-forwarded-proto` + +### Flood Throttling + +One email per unique error fingerprint per 5 minutes. Fingerprint = root cause class + +deepest `orcpub.*` stack frame (or first 60 chars of root message if no app frames). +Duplicate emails log `INFO: Suppressed duplicate error email` to stdout. + +### Stack Trace Filtering + +- Filters to `orcpub.*` frames only +- Falls back to deepest non-infrastructure frame when no app frames exist +- Infrastructure prefixes suppressed: `org.eclipse.jetty.`, `io.pedestal.`, `clojure.lang.`, + `java.lang.Thread`, `sun.reflect.`, `java.util.concurrent.`, `clojure.core$` +- Walks the full `.getCause()` chain + +### Pedestal Wrapper Detection + +When `ex-data` contains both `:exception` and `:interceptor` keys, the real exception +is extracted from `:exception` and the remaining metadata is shown in a separate +"Interceptor Context" section. + ## Future Improvements Potential enhancements to consider: diff --git a/docs/email-system.md b/docs/email-system.md index 579dc331f..a6b39384d 100644 --- a/docs/email-system.md +++ b/docs/email-system.md @@ -98,13 +98,27 @@ User attributes related to email and verification (`src/clj/orcpub/db/schema.clj ### 4. Error Notification -**Trigger:** Called from exception handlers (e.g., Pedestal error interceptor) +**Trigger:** Called from `service-error-handler` interceptor in `routes.clj` on unhandled exceptions -- Sends a plaintext email with the request context and exception data -- Only sends if `EMAIL_ERRORS_TO` is set -- Uses `email/send-error-email` +- Only sends if `EMAIL_ERRORS_TO` env var is set +- Sends plaintext email with scrubbed request, filtered stack trace, and cause chain +- Throttled: one email per unique error fingerprint per 5 minutes +- Detects Pedestal-wrapped exceptions (extracts real exception from `ex-data :exception`) -**Files:** `email.clj:send-error-email` +**Subject format:** `[AppName] ExceptionClass: message @ METHOD /uri` + +**Request scrubbing:** Strips credentials (`:json-params`, `:transit-params`, `:form-params`), session +data (`:identity`, `:body`), Datomic objects (`:db`, `:conn`), servlet internals, and cookie headers. +Only safe headers are included (user-agent, referer, content-type, CF headers, etc.). + +**Stack trace filtering:** Shows only `orcpub.*` frames. Falls back to deepest non-infrastructure +frame when no app frames exist. Infrastructure prefixes (Jetty, Pedestal, clojure.lang, Thread) +are suppressed with a count. + +**Throttle fingerprint:** Root cause class + deepest `orcpub.*` frame method (or first 60 chars +of root message). Suppressed duplicates are logged to stdout. + +**Files:** `email.clj:send-error-email`, `routes.clj:service-error-handler` ## Rate Limiting (Email Change) diff --git a/env/dev/env/index.cljs b/env/dev/env/index.cljs index 885bd1089..1fe47d535 100644 --- a/env/dev/env/index.cljs +++ b/env/dev/env/index.cljs @@ -5,6 +5,6 @@ (set! js/window.goog js/undefined) (-> (js/require "figwheel-bridge") - (.withModules #js {"react" (js/require "react"), "react-native" (js/require "react-native"), "expo" (js/require "expo"), "./assets/icons/app.png" (js/require "../../assets/icons/app.png"), "./assets/icons/loading.png" (js/require "../../assets/icons/loading.png"), "./assets/images/cljs.png" (js/require "../../assets/images/cljs.png"), "./assets/images/dmv-logo.svg" (js/require "../../assets/images/dmv-logo.svg"), "./assets/images/dmv-logo.png" (js/require "../../assets/images/dmv-logo.png")} + (.withModules #js {"react" (js/require "react"), "react-native" (js/require "react-native"), "expo" (js/require "expo"), "./assets/icons/app.png" (js/require "../../assets/icons/app.png"), "./assets/icons/loading.png" (js/require "../../assets/icons/loading.png"), "./assets/images/cljs.png" (js/require "../../assets/images/cljs.png"), "./assets/images/dmv-logo.svg" (js/require "../../assets/images/dmv-logo.svg"), "./assets/images/orcpub-logo.png" (js/require "../../assets/images/orcpub-logo.png")} ) (.start "main")) diff --git a/project.clj b/project.clj index 3f28a4d4b..f60454c33 100644 --- a/project.clj +++ b/project.clj @@ -23,7 +23,8 @@ :dependencies [[org.clojure/clojure "1.12.4"] [org.clojure/test.check "1.1.1"] [org.clojure/clojurescript "1.12.134"] - [org.clojure/core.async "1.8.741"] + [org.clojure/core.async "1.8.741"] + [org.postgresql/postgresql "42.7.3"] ;; React 18 + Reagent 2.0 (Concurrent Mode) [cljsjs/react "18.3.1-1"] [cljsjs/react-dom "18.3.1-1"] @@ -236,6 +237,7 @@ :pretty-print false}}}}} ;; Dev-only deps, source paths, and compiler overlays (devtools, re-frame-10x). :dev-config {:dependencies [[binaryage/devtools "1.0.7"] + [nrepl "1.3.0"] [cider/piggieback "0.5.3"] [day8.re-frame/re-frame-10x "1.11.0" :exclusions [zprint rewrite-clj]] ] diff --git a/resources/public/dnld/5eActionsReferencePage.pdf b/resources/public/dnld/5eActionsReferencePage.pdf new file mode 100644 index 0000000000000000000000000000000000000000..33e9b3d4a8c9ffbef49c8a7056372aa450f2ef1c GIT binary patch literal 186281 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%0Ap%O6yy@CFLT4k6^LVw<( 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�K|9|xt^}LjxvUs7Gc?a>6_oE#H1RWVxkBh0 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 literal 0 HcmV?d00001 diff --git a/resources/public/SRD-OGL_V5.1.pdf b/resources/public/dnld/SRD-OGL_V5.1.pdf similarity index 100% rename from resources/public/SRD-OGL_V5.1.pdf rename to resources/public/dnld/SRD-OGL_V5.1.pdf diff --git a/resources/public/favicon.ico b/resources/public/favicon.ico index c3dc72ea8b0ae63daea0046a53c9312a8047f423..6b71e6b3497bc37c0a402b63e44ddc24942f0097 100644 GIT binary patch literal 22382 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 C;NXk^ literal 10862 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 B7oPwC diff --git a/resources/public/image/discussion.svg b/resources/public/image/discussion.svg new file mode 100644 index 000000000..871964b77 --- /dev/null +++ b/resources/public/image/discussion.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#fff" d="M165.262 25.154c-38.376 0-73.092 6.462-97.408 16.405-12.159 4.97-21.669 10.834-27.706 16.67-6.036 5.835-8.459 11.144-8.459 16.218 0 5.075 2.423 10.384 8.46 16.219 6.036 5.835 15.546 11.699 27.705 16.67 24.316 9.942 59.032 16.404 97.408 16.404.162 0 .32-.006.482-.006l-38.95 108.504 88.065-112.265c18.283-2.87 34.592-7.232 47.81-12.637 12.16-4.971 21.671-10.835 27.708-16.67 6.037-5.836 8.459-11.144 8.459-16.219 0-5.074-2.422-10.383-8.46-16.219-6.036-5.835-15.548-11.698-27.706-16.67-24.316-9.942-59.032-16.404-97.408-16.404zm183.797 94.815c-38.377 0-73.092 6.462-97.409 16.404-12.158 4.971-21.668 10.835-27.705 16.67-6.036 5.835-8.459 11.144-8.459 16.219 0 5.074 2.423 10.385 8.46 16.22 6.036 5.836 15.546 11.697 27.704 16.668 3.106 1.27 6.387 2.481 9.819 3.631l82.965 105.764-34.2-95.274c12.3 1.47 25.327 2.284 38.825 2.284 38.376 0 73.091-6.462 97.408-16.405 12.158-4.97 21.67-10.832 27.707-16.668 6.036-5.835 8.459-11.146 8.459-16.22 0-5.075-2.423-10.384-8.46-16.219-6.036-5.835-15.548-11.699-27.706-16.67-24.317-9.942-59.032-16.404-97.408-16.404zM96 249c-25.37 0-47 23.91-47 55s21.63 55 47 55 47-23.91 47-55-21.63-55-47-55zm320 0c-25.37 0-47 23.91-47 55s21.63 55 47 55 47-23.91 47-55-21.63-55-47-55zM58.166 363.348c-7.084 8.321-13.03 19.258-17.738 31.812-10.33 27.544-14.433 62.236-15.131 91.84h141.406c-.698-29.604-4.802-64.296-15.13-91.84-4.709-12.554-10.655-23.49-17.739-31.812C123.246 371.9 110.235 377 96 377c-14.235 0-27.246-5.1-37.834-13.652zm320 0c-7.084 8.321-13.03 19.258-17.738 31.812-10.33 27.544-14.433 62.236-15.131 91.84h141.406c-.698-29.604-4.802-64.296-15.13-91.84-4.709-12.554-10.655-23.49-17.739-31.812C443.246 371.9 430.235 377 416 377c-14.235 0-27.246-5.1-37.834-13.652z"/></svg> \ No newline at end of file diff --git a/resources/public/image/elven-castle.svg b/resources/public/image/elven-castle.svg new file mode 100644 index 000000000..418496d8e --- /dev/null +++ b/resources/public/image/elven-castle.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#fff" d="M256.3 23.4c-7.8 18.13-16.2 30.32-21.2 40.66-6.4 13.47-8.5 23.95 1.3 48.24h39.9c10.4-24.38 8.3-34.69 1.7-48.15-5.1-10.33-13.8-22.55-21.7-40.75zM208 130.2l10 18h76l10-18h-96zM68.23 147.4c-4.47 9.2-8.9 16.1-11.51 21.5-3.89 8.1-5.04 14 .31 28h22.41c5.49-14 4.34-19.8.42-28-2.64-5.4-7.11-12.3-11.63-21.5zm375.47 0c-4.4 9.1-8.9 16-11.5 21.5-3.8 8.1-5 14 .3 28H455c5.4-14 4.3-19.8.4-28-2.7-5.4-7.1-12.3-11.7-21.5zM239.6 161c6.1 157.8-26.3 305.5-32.7 333h24c.7-5.1 1.8-12 3.3-19.1 1.5-6.9 3.3-13.9 6-19.9 1.3-2.9 2.8-5.7 5.1-8.3 2.3-2.5 6.1-5.2 10.9-5.2 5.1 0 8.6 3 10.7 5.5 2.1 2.5 3.4 5.2 4.7 8.1 2.5 5.9 4.4 12.8 6 19.6 1.7 7.4 2.9 14.1 3.7 19.3h24.2c-6.4-27.5-38.7-175.2-32.6-333h-33.3zM28.02 212.2l10 18h60l9.98-18H28.02zm377.98 0l10 18h60l10-18h-80zM60.11 245.1c3.39 99-15.63 190.6-21.38 215.9h44.01c2.5-9.2 4.97-22.4 6.88-40.8C82.34 378.3 74 313.5 76.34 245.1H60.11zm375.49 0c2.3 68.3-6 133-13.2 174.9 1.7 18.5 4.1 31.7 6.4 41h44.5c-5.8-25.3-24.8-116.9-21.4-215.9h-16.3zm-215.4 24.7c-22.3 5.2-56.4 10.1-109.2 13 .1 21.7-1.6 36-4.7 45.3h109.1c1.9-18.6 3.6-38.2 4.8-58.3zm72.1 0c1.2 20.1 2.9 39.7 4.8 58.3h110.7c-3.4-9.2-5.9-23.2-6.4-45.3-52.7-2.9-86.8-7.8-109.1-13zM111 348.2c-.5 69.8-6.6 106-13.34 125.5-.83 2.4-1.68 4.6-2.54 6.5h96.48c5.4-25.6 14.6-73.2 21.5-132H111zm188.3 0c7 58.8 16.1 106.4 21.5 132h95.4c-.8-2-1.6-4.2-2.4-6.6-6.4-19.5-12.1-55.7-12.6-125.4H299.3z"/></svg> \ No newline at end of file diff --git a/resources/public/image/giant-squid.svg b/resources/public/image/giant-squid.svg new file mode 100644 index 000000000..c231b46d3 --- /dev/null +++ b/resources/public/image/giant-squid.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#fff" d="M231.64 60.34c-12.222 25.912-25.13 60.62-35.005 94.2-9.876 33.577-16.674 66.33-17.137 87.16-.366 5.434-.428 16.186 3.441 19.769.354.044 2.042-.023 5.036-1.52C206.12 250.876 231 247 256 247c25 0 49.879 3.876 68.025 12.95 2.994 1.496 4.682 1.563 5.036 1.519 3.16-4.29 3.503-15.532 3.441-19.77-.463-20.83-7.26-53.582-17.137-87.16-9.876-33.578-22.783-68.287-35.006-94.2-3.156-7.181-14.372-37.082-24.359-36.929-11.033.17-20.534 28.866-24.36 36.93zm-19.35-1.01c-22.495 17.595-50.172 42.38-59.099 65.879 3.844 14.31 13.763 23.683 23.69 32.91 10.069-33.526 22.243-69.391 35.408-98.789zm87.458.084c14.486 34.704 26.414 66.864 35.371 98.705 8.94-9.37 22.031-19.506 23.69-32.91-14.587-30.414-35.718-47.832-59.061-65.795zM59.141 132.908c-1.58 16.937 1.915 40.013 22.1 65.059 7.353-2.053 13.734-5.085 18.849-8.805 4.686 7.938 11.747 16.622 13.142 26.002 2.483 20.992-16.236 50.36-32.93 78.406-8.346 14.023-16 27.735-18.952 41.672-2.952 13.937.299 29.29 12.925 39.701 10.218 8.425 23.768 7.778 35.793 3.82 12.025-3.956 23.95-11.202 34.889-19.029 7.06-5.05 13.69-10.38 19.453-15.293 2.153.612 4.427 1.26 6.518 1.852-16.292 19.786-28.73 40.123-41.442 56.566-15.118 19.557-29.787 33.155-51.869 37.018-14.315 2.504-38.035-6.199-59.617-17.904v20.634c20.471 10.629 42.631 18.514 62.719 15 27.966-4.891 46.849-22.837 63.008-43.74 15.094-19.525 28.567-42.016 46.302-62.271 1.846.498 3.654.98 5.422 1.445-1.64 8.025-3.884 18.264-6.758 29.475-6.4 24.973-16.2 54.243-26.617 68.955C152.87 464.473 134.68 480.039 115.7 494h29.633c12.473-10.292 23.685-21.183 31.436-32.129 13.547-19.132 22.75-49.092 29.36-74.887 2.87-11.197 5.128-21.447 6.808-29.619 4.463 1.019 8.713 1.911 12.763 2.655-1.643 24.417-5.836 78.135-12.521 106.425-1.844 7.804-4.818 17.395-8.285 27.555h19.058c2.725-8.389 5.086-16.396 6.744-23.414 7.277-30.79 11.324-83.063 12.938-108.104 4.323.371 8.553.564 12.777.551 1.95 43.763 2.743 87.742 5.139 130.967h18.027c-2.096-43.95-3.693-88.095-5.195-132.139 3.673-.452 7.44-1.024 11.38-1.738.38 5.529.842 11.73 1.497 18.799 1.818 19.612 4.637 42.788 9.638 59.08 5.076 16.534 13.858 36.316 23.649 55.998h20.203c-11.208-21.998-21.476-44.444-26.644-61.281-4.1-13.357-7.15-36.333-8.922-55.46a765.6 765.6 0 0 1-1.637-20.935 575.888 575.888 0 0 0 10.267-2.572c3.057 9.82 8.226 25.34 15.202 42.2 11.633 28.113 27.316 59.935 49.884 75.462 22.168 15.252 56.768 18.44 86.559 19.238 10.42.28 20.164.147 28.543-.132v-18.006c-8.18.281-17.8.42-28.06.144-28.52-.764-61.584-5.576-76.838-16.072-15.656-10.771-32.309-40.577-43.456-67.516-6.583-15.911-11.488-30.572-14.445-40.023 1.963-.545 4.33-1.225 6.36-1.793 4.641 9.226 11.72 22.387 20.34 35.209 7.687 11.437 16.456 22.577 26.468 30.44 10.012 7.861 23.045 12.843 35.332 7.3 6.99-3.153 12.479-8.084 15.666-14.094 3.187-6.01 4.078-12.672 3.756-19.267-.644-13.19-5.758-26.93-11.342-41.164-5.584-14.234-11.796-28.855-15.426-42.14-3.63-13.287-4.442-24.843-1.15-33.306 9.78-15.892 23.432-29.033 36.545-38.726 5.337 4.231 12.244 7.662 20.318 9.916 20.185-25.045 23.679-48.122 22.1-65.059-24.536 7.73-42.168 26.434-54.592 41.766-16.116 12.277-34.131 27.545-41.146 45.576-5.508 14.157-3.544 29.547.562 44.576 4.106 15.03 10.589 30.097 16.031 43.97 5.443 13.875 9.69 26.662 10.12 35.468.214 4.402-.43 7.604-1.678 9.957-6.948 10.457-17.25 6.263-23.979 1.07-7.397-5.808-15.532-15.74-22.646-26.324-14.228-21.168-24.639-44.565-24.639-44.565l-.273.122-8.707-17.413C333.255 319.258 323.253 325 312 325c-18.119 0-33-14.881-33-33 0-9.453 4.055-18.02 10.502-24.057C279.092 266.037 267.532 265 256 265c-9.084 0-18.178.652-26.72 1.857C236.43 272.928 241 281.965 241 292c0 18.119-14.881 33-33 33-14.421 0-26.782-9.43-31.23-22.414-8.236 18.35-29.464 33.224-42.288 42.51-10.107 7.231-20.993 13.59-30.04 16.568-9.049 2.977-14.835 2.594-18.717-.607-7.893-6.508-8.85-12.247-6.766-22.084 2.083-9.837 8.706-22.58 16.812-36.198 16.213-27.236 39.1-57.896 35.336-89.724-1.284-10.863-7.458-20.852-12.984-29.346-13.686-20.938-36.79-44.905-58.982-50.797zM208 277c-8.39 0-15 6.61-15 15s6.61 15 15 15 15-6.61 15-15-6.61-15-15-15zm104 0c-8.39 0-15 6.61-15 15s6.61 15 15 15 15-6.61 15-15-6.61-15-15-15z"/></svg> \ No newline at end of file diff --git a/resources/public/image/login-side.jpg b/resources/public/image/login-side.jpg index 3ea03361dabd33bae2f3ba8a3e626ee671260455..b37c6202b67a83f86a48006fada1dfadb26c805d 100644 GIT binary patch delta 57284 zcmeFYRZtw?*M>WThv4q+7Tj%ccemiKLvX@CaCevB8XN|f;2PW^IKeHrp8U_ncXjU1 z&9AFg?ON4u-|XsIy`S~2eh7u@jE76WgTd@eBuGQ!<Pc=%5ai&Y;NTWy7f9@61`Z`2 zs=#2xB=ZC3F;!uKQ~(%EAS@>EeFFdofNvy)Q3XO;@1h?3A7nyx8#c-^=_fz8;MHR( zhkT{(O@6LW51V`u_i~}E{PPoS_`|h(&M@-ieW#`-T-$kut(a_l@%@_>MEQjIQ?<T> zGQZip#ig-WN#h&G?qeyWSNk1c^gI5x^n^TRY>zi=JX6!so-(hxr_1Sk=3CTv)i+Fw zC6N$F@_m(eeH4~p@*P6Aexj-E$a$>%HEdFJ%<R1{_&)Ef+}Z7IX+}P^eeP)7-tNi8 z4;m=8%|e{}4lu09Ub31Kt=KZx8Rv2eP#i(S0oa$5@9VzocR;p{-vI_Hd2$2#+>Ipi zS76uj`dC(OXc+kf+Nb*A`|6grhbE3EzYB;^F01LR-&E3SLyWw`nj+8<;~^qGd{&aK zw8L+s^Nxs2TfllpkW|r9BCH&qM`KFCoI5i4uhP18T6!)K-3RI4z01{!f=L$I#9t*2 zIy*6D5fhRNZMhxV|GZD1$g1Y6Y_}uA9fmzsRVNWb;o{S?{G42AToAlDDu0gS&CNrq zS>62DYQM(0=NJsV1NgeCHyYi&0*?PQB^H#}pW5>Xt_9sj*0`uU?|d8=lP8V0%K7E} z{hOHX*t4t}bfe$StMRFus6dBaeQKwPiV5bKhm%w_yLQ@^_txmM^}+mw`XxW={kK1) zlTV-5e2@P&k|EW!xZT7<j%y6_SCa1_Q}XF}b4}f2kLd9hGMj~)?k&DO%FgX&_VO?S z+5%BrB(mbXHvL3#xr-i)!q0gYeb!h3Dl=zcaeQ73Dy()vH2FOi1HOR>4=H2Br}!(& z_L?ze?j5l*yM4GTI>dUYNkrRZ*q7SB?iL*#1xnUHf7D=e-<%Jaez}kT^sFC-goJFt zSJTYB&n%4{=W-F&d}`e=y@R%g?fe<fK01FQ&wL&WQ*k)y^VmZ-d3dV=tuJ!6uC=!l z+sr}!KC0eAvTu%!E{}1Y(XAX;sV&GXfx>OWM|$_~0RDHt)8<8?jg{!QzI^=5vsoh@ zTF0$J&VC#_(lqSz@SR}5w%~V2QL=kvT_A6uIih(r%whzBrEFf#$jcIwRPHQFD|W5O z%Uja)(L?INI&=F};{ru@AD`P}@YCkFj#aVaDS`bS?EDwhivdr?qskUbnYH7YMQ%li zyxyJmxyystA2B!Mc7hMi#l>tT&CpKMp~Ve?A(?40kAjL~esWh7+jM@&A%JvLA55pQ zLvieR=^_Orl8mu?b|SY0-dvDXSpyO8dy;#cZ)Z0ALTd_(^nB7d3W+ul?mFKbM5AA& zr_`6)@fF)*jyFV)Lxa6m!3MK@UkRRtNUfS?UOFmo#b$l&uHQOuPIGUzcX*G3+=<}n z?be7_>3R%3hap0TbMcUO07~^U*Vf;~!cT9Ikp-^E@7u>>_l#VP?V{4HI~{iIod)Lu zy4-;qcLJ&0&z>!w$GniT6^)U}siPF@-JMxdr+blt<%6%ONu*Z4U~kS|@!kOn-~X_@ z1ER$zN4m%2^r}vG+8Ro`vYCI&95|J9R5#9Tyy4H<RJ;Qa+HxQ!#q8(JXJ%hBSzVvi zIV?;r+%IfqUH&*jUJX2jNi4@|L+m~llwHv2^8@g87v5(NPKQf7Hnq<^oGYc9hTBTM z%V%d9P5spf_=<<|kz|ou(ny>}Rj9|u@wVO7<cYZ^>(I$m*%27nl>RL8J+CdAnYC{I z1G}kr;@DKEl7kcE$jH-rjf6y%YwEt}qnxX|mcx)GuT(q#JPed{rn;kK)~#N4jqswr zn%L2gv_sZK^h9q{Sx^(!z2F%GNUyxQ{788Ydqk#EcSWAF&}F010$UPSq?qf$qc`oh zJdWgU>%9B2&ujE#H2DrFE_nwW;dv<!^i?!(-Ku0;`4B>M5>1Sp>hSUEf0(_lwtU5d ztU1VS2Mml=U$|elTb0drc*Ebee^uCg5-VQps)lBZ{WPeW7#MeojX#M$PSe{6fGoWI z9p4BrzKwc{6RGjo6UfBlGc#-OT%A1IbZ1$6!=Akr;VJw4`I(K&ZeXa4h-BB|BH(hE zXPwK|g4`N%cd^=z(uq0mZg=Y<n!a*o<-^le&D*YTg~BPO^bU|bt9>PZVrDU_7#TfM zX(_W&PD!@7I;-w5G-JI>*M6*ZY3{icyG&g<W(3BG17Z`8w;K143^gKoZJh>{xSr;! z9nL;^-17t3&+0C`dc3ahD|yV;?E@?V;foViBK;v>S$o>v0Xmgyi4L{(7oK;kpgOb< zVvndN)@9HML@df|C7}V`Cy~vA3f1G?BV51l%fo*ajqTp&hDQC)zov`n3L5S36G3L| zCx$6Ei`|Vro3S6>1wH3iv*eg148a&Tmy}E*z=mbF#Q$2;(-l-ztSYzuY+N!~37tAz z2Z}<r7P;0*Pu<159!s)^38a$eJP#2p0ZWG)>>kVo({2un=hs5ouMRzbSC2E83dLvP zxPgVdqHJuW5>d=fO0h&U6LxLqFZU2*YE72yvfeu_&Ocm2m}s#R-XVyR^wb<}UbmgZ zOf|#ma&~sMY2o<i?W!{lUA$aJ)hCRVckPgDggY^bO*XaTLE@m`32*c>=7cdL&z5y> zMz<s?Kn+G75q&LowybmuG2WBg54O(Hq53{$7s>^Z37aQfb*JUGBC$CZNG+*ItUJNX z8L>k}Ryuxj+}QZ&+f<dYVwp#Wfsu{4jdPgLncinZ17*FX0X@T}v+ZgJ>CDd$)b$XK zm3IJM*&w5vRNfOFNj^aQn8zDW>z?C!#>^pv)a$!b-0EU{$+@}jQNuc-$q8?L>?<)3 zMJwtB>yq=-=3UX6V#TP3>RrP1h@j+Kpv#>aBSL$1{-|FjDxGJ2oKSAdo8VC6^GETC z#Ddd#fJwpo{A9l1)J&ao^O<PE>mLZ$#t~$R`HB28=FMQ?sZ8?Y+2kh_2ZLj|>GO?- zp^p2^Bgc0@jmtYAxZdjK6eXrJsII-V37_|YeW`9KU+f59fvL2L<*SkdYR&q?oO_R- z>Ti8Q!J#)BMS@C^TndiSq_g4sHo1*>?aA_3M{L6mEYVcR(%QwFLHQCl*b@>5yi#v& z9&S7HQ;jQ@oq1CTUd7iLv$ktGT|J=@kDt7t@5Kli2eVP0yV;U(GnF(C9i{zc;<*(( zL38fgek?UpL{TUGG#RIldUyS$S@>k9Qp+L9k*sVb3!e4-5AkQu4T#{H2>D-JrMS{R zwo?`=A=i+<scP2Hgg?g@O3jc<o}`U6i)Ux^FWYW2FcuMpzpS@g9h(T<>U%p9jOH4~ zA<c=$|F$4n`;@48S9uD4GJet_AbzXqX)bF*@FNQE^z_IyU}GBb;wxX5CBB%4DXF5- ztnl3-q`C!M52NHV852lreDuE1jZ_kp3MHl)s%N7;|5uClo=32DXd&2hPEI7fyIzcO zbNRs!Km5>Aah#j$H@Cu``Cw-9WxksAqEWXZlSQ2ScVY*9lw*(=llcN_phz6iB{g<* z`0?e;mgdok=*Q}*lV03Na~-MK%`&O*_h0cvFCn0hN(wQlSjYts9iJ~cL_c2=#cmCg z%GkyQ>Q2^N?R_Fk3Tq*r#<IW_@8;<Z5&EQ=0hl%7Ce;_}jJCTCKXA^nXuVJu|Go2* zN#4}&f1iu?s3RY5_mevA%fGf_!N?AE_KlR#H17T^ugeRwyKYEQ2pH)}rRZmR*x)VX zeQ~LW>@S*oCVg1&dEVPJuCB8gMS?h%Dkiz&%fc6#Z``EM!$}Ya_6;jPCP-N;)P#ou zhUDVRNl!en&0_>nZN_6!ba4UX6yp%yRh)2-{)OlC9RN3V`Tc+ib<`njDP1_2tTC?G zyZ-yuVs0+->mrJwviCG>)kggky&>UobBF$`N8<O{MnONiK6o^GGmDntYw~}U^X?r$ zsfdA=BWdv0f(8=XZ<!O#VQl4R4Dxnfqj!i7AUlhN%)LJAI9udRP=aWlqx0m?y$v4j zhy|TlzT(yQ1`Qn`@>F}>Wz{)Pl^#4V8|?51$7gHR;X&GUR6*U_`7{|sRjtz5uY7ez zBwr9$LD%4#gZhC}DpeQq=WE|jo<jF{N=IDp0F5%LehAJNugc0O5N;DYnctgMblf&3 z)B`;6?6eqB*#JrTi%R}T3-fZ&={L~<dhYqcAX0Pk+7F$2U;1rffZm~j{gnO1UTO-^ zhxSZj7DMCngQKck9yQ4JyAS4G_9xP|<ur3FVB?|C&)q|zmwo;2+6r{|Si}vA;gs(u zOp;am8jwEouI*AXIMC~B(b|~`2EE=no}12@60*s<aFo$OHnE)p5qeuZd@bbe^O1|o zEsHk!oj*zRbucY-cGpnMZGY0Kr17;!*?8z#sNSIn_l!_Q==>AL3E2mCg7NqVG1VJw zuA9%LjW^NM6?-C&p1Ws3N3A-3m*YgFFFg+PR}i?7;9CPreh_hN){5tr(WX|s^6<Br zrt>$gfLJvFuek|>{*BE+qISjlT#L-MI9CV5nqox3VZX1NMNeXD+MlJr29deH9uGKJ zQ>mP_`(`_k7da=N|2y2Ib;!OdF8|P9aKM31lZ@7}YXzic1*AJ<L1e~SmM-1_s5Xa? zS;!Aro{9NbVVMH)bN7~XITD|MYkC4^@9*!^3m#K>0kYco>-R{4=&ZPI?s18=Q5F{Q z!-E!=Ef3i$<B<I0b$MIIzgzWZes;k9lEFYd!-<TI^Eb)ddSkdu^3GW?^p~WG%5he| zDcr2AHTf-svw4VJ_xy`_HQWX_{IAWn-;kwWHO}8})HvB*DLk@z;p0jA1Ck+=x?uJZ z=L5y24B?kllr;QV5K*i|V}S*9&~Ih#YL)bK!(xO)wft%I@vTUtit{JjH;UlO)t_sL zGnIDK3)NHQal3ZBIu}ohpEwc>9wP5L9_V_HWh1qQ0v`|xv^Uj0p*v?ql1Au|pr9&{ zFb3(p1KwBO&+zY(Q*l&S<NyG`|Kk5HSO7o)1^~Eue}Dg9p#01A{^bDR8(bqgZat7g z{$GOde{ulK(7$^o|8iu2Bfte<1F!=)16Tp>|5i(Y)&Cw$t{1~jR>uE`@IM*MP_hVq z7;q?g0RJ60)Hpy;03^aF|DWt%sQ!!me{l%_r2Sj|u{4Be;6IlBkEQ=(>Hk>zKbHQF zrT=5;|5*Azmi~{W|6}R@So%Mf{*R^qe`M(o|HsmeqpX>5AWjZeb`Dk!9<Be^4;JL) zWM$_QWM`+M@HTT5RJQtJW@+}tOv>zwl?W9l`~R0XxrkGnf}4XwhKpa4myd^sPg;V5 zgP)&EN<vDKorjN;U6MzdKbe5*n1Y9khnJll#KR}U&cnsQC&16e&(A9*!6(JZ!6yOY z7D%Sz=7QnlXw>B{2U19bIC<FlIk-UrGE&@<Qam7@e^O~akd&kphZH9le<L|B4&FZ! z=T;g3LO6Fed`PyOj?>lKulZT3%yo&ckCGZH>x|$Lb3&H#hsK(gWIC{w5|GrB_9ODS zN0|~*f5LSyr#f!PuQX6GH7+LHT7)3$EbLwJJLxF9(YR${YeF$t;qu{XR96KY;KeUq zhKaSuX;N3}Dyi@j<B5_#qex&oh_LyH`7kmQKyYccxX!I9LKpqVOKguVbU!hY#t&ZK zjZ;R9VvWGU&UY?V7}sO+6<~_spOKewQAQw=>3>iVv2)NEW;OM+U6<qA3wYsPVhM6t zw0g7noX0&6POAH@68@dq$j<zu#JSF~vhQA?V~JMcBs#AKm{wi=*CE3!c4Db#>+fb! zEQqUX;e6Jy=i#3Bb}=9NVoCB`<f!#&^Cp(~dU<zDk<7GBbjVVTC3C0SlG)!0qs-dm zWR$O(f2jd_EhFC6>@+Q?33EnDr<flKE|QR#cha<0%W&AdQlhG|9xNObWEX4pmT-9S zR8D1>Hb{_@>_V8_O9oDC2}n0<9wU=<>>yAeZ_5GoCdd9{)2D=19Ma4FtQ0Qy0@{kR zOv<R>0x$Z+Qsrp-5?}1t%B48jQ@NpqP2HqiG~U|xX)Y^MTYK~=e)d~sOy#OrW@FyI zD&Lzyod)@BsIbCfuTCZni-o2U)48~Lr4wgdN8Yj>+z-1At+`r9aS(%N;_|n>97qO= zfkg3Hs5TC=rB3P@YiVl=jpAqzdKnE4f}&H|@{#Ak@#DN3uC+zoqa}#mbfpiG7yBT7 zLbi?7sU+Z>vQf$Rl`uP6*)W})&S9CoMnj&`hopmdKW;B)j2LVy1T{j-m0?i<;c_mF zYAQ7teiqGW{$WGE4O50U61eR=0THEEb;L_f02>5gt2|On5KBu-VzAk%WMU<BkrHUZ zxas;!O{?XN3GOI=;=WM2?%Ein$$3&IJ$jb1BIE%$5}QTjj?*>Ho&M0=(pDuTN}Z7Z zr2(cU2o;KTQ3<@d4d#PB$Y2Ghz~6Z>`skWSBf8JQg|b-L_7?RsL!H=bAOqO31r|f% zYv-ddCAMekpoeYq2t|2I3Z2wL&VMf;8;(B__BAS_u)kQ1Hp^)Uc7#P3Hzq>~7m>zB z6TCVp?^z;LAuoZO!bK$<i9u4{7Gk^DF~^2wUb^_x?`%5_*0!V*5tP)dWvHJWphdPq zMqBb0Jr}F&@4EWWlTRgvFq`{2bNzZ4U&;tU-BTZQEzsQ-dA&X)&5#qCgRfA@y~G8| zXVLg-w2Cun{WdcW!@wx)bsMc$w*Vy}H3^M4Bm-kB{?cq0H`)4LL}PU?uKe*uzA`BT zG6GEuC3;MCGRfGd`k0{YPLutBD(RZNt=$h8jCmzLj^0e>Rw}z8^*8{vB(Qh=m=#w- zgim~i|3dkRCZt#ZBQ^<l#V%XPTgr%Vk5dsH7q){exSz$LjtFhzX}h$-(sqf+mTu$e zg}pRA#A3<C5ij*Pko+jI32S~h@V>7u?of>|tEonQlq6At*AjQN3qe|aan+p@!3R$L z;eI5O2Ci@Rk6b;(beu3x{n=)iF@e(&4t~kRwkO3c`rg#*B*qm!&9cTYyCNr()|z$6 zz1xTiR-L9)P&Ax&J!rpD0zIINX*31cXvvX0nK1|>jS<HoWs(_8Z8Zi|gWV2d?iJ<7 z?JINcCQyrRsM9BCRKuJm2{{;%7H+PSOOda4&j4q#_HjcZF~14AB+#-V$vJ25wpSt9 zwGH6fbs{=HvSSnjh;Q#kHE`%eJje4f48E}skoSDhazdBFArpW%VqFUM*srg_>3N?` zmKH#h$F1=>LjUZX<^4OS+%5zThvYg)Emzej1Bn(v?&@(r#$aDEgwlct^%tB$NJGsH z&p5zbfnXkTktF#oY}rGkzR6t%WIh2X{c|}MvlhnPD+RrZnAhmB-Q&+L0^mCSuxw3g zDz@;nn9Tq*CCj06`aq|YC0%SmR?CU&2PUK<>G*Uunf3-<zYAQgaw|Wq_@S&yzzF9s zPDLVj_&;MOn~i*eZ954n)M&wCUh-F?cGmX1BjbmxF#+`$OxE_CYL=K9U6j<;MoAn@ z%K9+~Wq8$!M}>5<E!+5ja>aUEs?o5tuwZ!_)NoK(s=vms0j)fdVMkjgR;~J-{N%H4 zsngE6YO%4UB?{r-*_eyZBt>+t#AZ=*9ja`4;vnYH@Hh*S`_WDdAXlt+U}cb&x0O_5 zi8e(2#;ZoE!AN2W*Z8mlHRX8+nGxHf>~ALQ+mceuUIHN;k(OJMcm*jJ&Pdt}k!~yw zGG^<@9JBU~IIcNBg#EH`t@TR;0O8MFgAoI0^otPD4h1xv&sl!WEEz+4W&#;Z`t_=M z#$JPqqk<Tv2@Wg65F{6jsj#O|s101rWrt|2=BH5%VTdjc9}cCy&xxiUb`aTokNw?@ zcDtJdMDqFUuH4W|kW{22f`G$fXQ{5y!;(Z);VOSI2S6^YFfp7)UrIKQh*xa29f;wh zDx%y8`x#NWrgtS2Jl+G<6^5>nXyvZ17ON3Q@@nIfJraD=ACq$+_Tl+3J{My-{~ZEM zW-o=YgXFv{prr%n9OZ8H)R#R7)60JPwLXGxe6$w^lI2Wf_9mBj-6GIXV#~h;c<a^R z66Y<evu;dgOq-fhE6nsOPZ=5P^O&98TYmg#EZ&i$1s_Y@wu|(b!_uo2uezBMt2rM- zVmxNGKHu1n-<MK;7J@Jmf|AtnJsQ#jN-DL#yDK~6T{z^>dYhAsw9t1x-+_iiC7{z& z?{Ow4+&E%fD6=-l!m3a@Rx8gJy>7bI)z*#u5>Tw7<c|aK*R{=GWG|$ClU({1h3sd| zy^DFUhNW^;wW`>wKy5~NXqT$z#01CAeeMej<qi9}JMXd`M9DpOvnyCoA`khUxAFle zWc!9ki{*2qSc9OIzj8{$N7~S~<k4_dN4TV>2*WTPp;JF|s??cLH~M>fz!m=pAkZ+& zFGMw#S>=J6LPG+^Cr_tBOhylewYAzZ*%zamKPlARv=<QmCy}NB*~x}M3}0oy^a?K# z`s8R|P_iC-lqjGjjdeyus{-loXXa$7V9R@w_+Z>Hk~B~t+VJuS7Oq;Ncl`W5FOd7F zk=ZLHVn3*p)CuCg9Hmsyi{{^5>Q;_KEyfZ!v=vNDq@`fW6&7~Zmq+d}r%NOj&nU*M zoDSY}%&p)4Y=HyO-=+F1j=q_gQ_ea3k+Dd?;$Qh7lr~Hq#k4BxY=j`XOA*hp#_?ih zIq>9w2ano{zcMDnWLb5P{|(Qm?rbUffp;w0#$T+sC9Gm;+CSt}#ijzdI$6t({9|_@ zT$=1jb1+K*MuYot9(q4Y*&A0@ifiyuZl2r*hiWH+p;Wq5Lu-%2;b4jNk`q%4Z-3Hg zZrRdFSSvCLS9;kLDh!!FcTU6z^b%kQTmH!+x0e_T54uQZjc9e+jrjH_+1s>PSC)0D zMZvss<|j=<#I7VwNFZRN5=<9%gy(7VTkoZE-i45@S;FZIR9hOb_LVR?TXc<2mK480 z-$!R?xt6J+Y&qsjnf*airpdX^X#dwBBAEh+6@L_p(ioiDtQ}-gGyW#}3+oO%(h#Sm zgs#uNp#VBRVUhEn@}G>wa8}Koc+*I8;0`V;zcg(*hpIFirXEYnUIpUsee6|*@$(6+ zp2u#qns92UIBIC&45Q2_f%@K|+dD+zlo98daIDqkW*VXxz6TcSvXuZ^RD7ybLMt_e z@;#Y_5yJA1aIp~MU%FJIrESz*Dw1I<26*R38JaC&`UDgyaLHVb;BCAHo%vlk$ooP9 zsBF@WKgKcjA-mTGI0W!L$9Z&gV%=uS2gs5cpQJ{n8GTUH8zotPWK>DL8|R0bAb!wb z*WmmhB2NlNP#VC=v(If2C9Q`_FgZ{B#ucdu#2OFz6a`s<q{sSmn&Js3viDVg!r)aO zjjtOAfRgNN2r$DCtq;F8e3@4?NGotD(^K>jcwJXiL-!n&;I#{hb-rp7?K%+AIMM6c zgX(Oxu}%Z0QC5}%0sK^Z@+?k!w^nwH(&par@Z&>2%TvorDUmI)Tg1VhmGv{|%Hc}k zyqv^LzL0~~IU~3GRxa~mKs9ElPK~lZ0t^8m^H(##gA5uDJR^PP6i&6OSqOkoiW{2p za84ZYX7b?OksV~Rq&7@mhIRW|6dY25Uu>IIS`Dr@=)1<J&_WHL3#QW>Ob8OrOD=p< z{4CFuD|1HKOsc`U{_>aPpj)Yx@z8)i>7}iZ0)ppRP?C>e2e78`8ndGEX^be|KUR&O zVqy)FuhN{&R0?J<l!P@g4O(NWer!Yzs7w=@19JN`6tnKn7HCPTsSZXKCg7lR5d$|% zWsY)rZmld0PG7H$92DrLPdmT^Q&uJ3P7-7`o@22ir=wbxL7GWhs~M}A3>yKb@$jte z5I(hhhP6+LD}e_M`r;bQSAJGBjS{|rE}MXl_Nvqvz@xL|ZCwnzwz4n=1|RJRx{$eU zIf{Y^Ca=}PHd7XZ8m52V>f#Yui0uajl)qVi-0j=N=HgDnN;&4Es5A;iyO4<OV#-#` z>7{-$oK}f}ZA|4_IbBo1hrIziyqbD9i2aU26^Q&NKdGLYWxl~)TuaOkxvnQ*6003z z#<sD>l@n=&nYF+#vCOc8w#1k@qZW(R=jGwOlVo^m#rX3SM>)hbwjud!t>KabWF+kx zo3&8o79|NCV3PL28ceMeqI<Mf1MV#;hr>2)K@TV}bxSu6LCm2PMy)3!C_Q`?B65kK zZb+KDzxQV_zTb#u@$3rad7Mmqwx4#Wnu1Y9>4$fXxz*ICZ7_A1@c4{>hsXY3E`!6d zn0m+4Xz1_Pzlij>=X<bZF}cnf^dvnFW=VwndKm`A=@``?eElrTvN#U<*clX~rvnJy zUizjgyH#X_jgL{mGApuCE4?#`5PemPh}2r-n4lCb&y&cs(+YVt`76#iu{c7j;@P?f z#6HoN;(caCpZW?OSQ;Ce*=fK-_-E{IT)f3XCz#bfe4unUjGsW>!ohCL7ISwhj(SI} z)jNeRWsmx7xCZH1ff6mBNpQj@rVKo<MqFtv_%wX)2<+@yck(1q`9FbOAqwK~K$7}_ ze6L#fj2FvBpi(OCYEeJhK9bMbSusflhHRLUT!@;a;Bwr@iKKyxZO|OOro|EDeJMwX z182`J57I|ABd;0IMuGJwma+T6P758g4wgtKeBd_$Mol?#7@VcN#5E_QRs-xpftYXu zie*gdSP{;%R^fiGK@zGCgf3jJonlsA4ol9TV0!<Oj}Cj8eTrQ?ZfFPr0jaPO%kzmj zQ?E8VJ}GR1$OPb4JJ4fmt%oL{PSc{2Nyn;*-1@cF5sA5%<}4aZyC9V0#RkY~ZJT<7 zZ7L%jlpHg@jD4{NLoCg<9#KIm%nr4XSyN{1_>q{3Ca9Y~GX6Ci610IecibLQfmk^! z`~#O7aeH&wl0gdZr{&yQXGP&)sok_;Ir2#Gce0XrgPa8wf3SOqw}t#J6{ZApVnVk) zGM<N8FY235Pzt~>-&2zUr7D@CK{gg~z-?R3h*!<d;GBI(E1ZM8d9!{vQ=2%_A_lpk zHgI%6vC`>-yKiDFB&+y_jL?$f3Wtz2?ja+{v)~1slMx}r32&Lc1%rVn`fOPc6}w8K zH87#In77L%1+s6Z@S=(Bx?aWk*2={<PNY2+xTj#;n|68s(~xPKwaFPjolm5b6M?3j zDOQLyte*)}xm5_WtzW*mcZ=yj>;uJY?k^w>$F6JXt*q%hq}Fk=R#6r2;(a<hgg*Zl z^6!N~E%InbCj*X(V^lQW(DAa7oeDzgVdT}}rfsU?#8m4+CnD|zVg^co6&=0RcDtD; z!>Ib7a?QNWa&5vEyC%N{7(%e1`<Yw%7;YAKZ!zt(!6XZ54r^Ya>%-UqBX<J+4D9Lj zIggFma<k0p5GqOlLN?08&#@fG{wjPnpqf|3L>aF4QEbT%C*z(l2G)ulk$Mnvd0$RN z{YV-DP>XD=u(fVWYOM%NyW}?4Po;p%wzwlB4U&d+g=Y^N!BmM`7%%tC7g04pan7a^ z6!~=BFFUhnm}w!}H6cZ?GAvD9E$UOVI1$dJ0z0z{Ne<dPDs|Xp0?RdP3V*4f|1gOs zOba@kRP()`5bj7DImPf=a{WnmrgKS%Nk-?HCUVcsvl-A$XesSjRkG+U1Jfl(XgV;E z!hGCWZuC7vz|k!~MP%@vo&WVujgmN=!B6nS7T<teiQ865v+W-9Gz{ZrjfDDzoXMy= zD{uZYq=7^&nu%720Zy9}`WqHA>n$M;@3(j019q8vdZ6Aanc_83AZ&7zL4Wy3z!=l> zmNv+T`P0fCqjDV<tDOuF6^Ih2EDoEQm_sJIyka&}7idX(J0j~J596d_tb(gKa4x7z zOJd8V@Tu7$6Ju~>I_qpZ6LW~jiYkz+X59E1@`YgSedd_4g!&WR+A_(Ol;SdB+s&6m zX++G;VHzskL1}{{2cnrkt23j7V55e@5X{h{p<GUXU^u2`Ca|=xsEcoGZ;Xj8F!n8` zCL1U5gYjJ{H*`)WKWi?al~tn)^n3A_tn{9GrS5>$@;%E(%(f1Ps*Gjbc*|4fOd3sN z$Qs&T(vD=X(JZ%1gtZLTLI!(uxTeQ*SE3v&S0S&P0y9;(c?}$jcJD<LG85i|I1|!U zucT77?>BTN&Ato*x74>ErA_>^AEbq^s_Uqe$KVY*fU$TFQW~+%yN#7YIni%p-{c?7 z5=h2vbQ|A{eFDC64xT%whc4_B&xtj3L1gTBG6(jJe+7{Eb69mERnQ+!2&e?RGLxq2 zCX+T~;(-_!Jmje&kf8f5GfDlh3Fnz=a@MU$pPQ83ISIr~EfuH;E!c6)5#!^q<Nd@X z<ojDdX(6~NIBQheHC^@EW461)Fr|3F;HFS}M&-Zc!tK1`%*y^1^<o&8V{SVQkY5@S z-GxZcwDECP5kaHHN#TBxBrYvbMGwvWE{B#l27Q{qFAsIIHTp!^TI7yD=4jQw{1kV( zTcgEP(xPeQskS>yTd1Qo>qtwj`Ta<V5iL0-oU8vWcn%2|M2pDX?6l_VF;HMbQ)c|) zru#m7`r$*t`cgl?8d=?_I!0(g1w^DlOOFuVcH!HN06Qu>?JrNBlQrO8LQu*G!Ca}u zNXwuDSXgy4=wBO^cMV3X)LLC#f}K!JPVt<iYn&OwoFnT&mvxZYf?}YWik)X*Rz_w| z0{vtnGTR;)<C7(-<DPczBO-KQ0;{Z_=Q=*NHLZq)MEHHCU&bj>*Ho888#2<%`%srf zi<@mp0o;uSv9WDkAwv3zlnZxY=iqKdT<KuOu5%vd{V84~hv8dhIe#51YW+a2Yaq=_ z6$_9sC3F0-IAwf|l0H=^>F&ooPBTzvBXo6^g=z*%_iGqHC)6O5!*O{zA+N9&qSm53 z@?st6LE1o159**GI6G;YgXF%!i6KqK5x5RdR?3Xw-U)#5{+bs1ZAq-2DctvkdzDJF z!Q$!!4ii?86pJv=F^+t&#HDHV!r2F{qZC_QSlL?pwv2aMN^lRP6YXVGH068nR}N)r z+xuDuf|&76lP}9l$#C3iv62{2nY(2}c~`(P523d3^u8pig{c8pkR}S;%i%~npocb1 zM4LBB9bN;*kHJG@{y8XZ)Gx!EHJH!ubLXG}kPNQbCjT2lkgB9qg>cF<_lN?hPTI}} z%pgvPX=MxltFpdXSw#gZPB*PlzNhRtA>a<CYBaayWJxK-3E&Z7J_}3<WfMPBzaD8& zJnts7dzN)<LF62Q$jcOCG20}D(^q4EZMd2Y+WY$s;F_3(gC2@o<AOK3^>ah`+oIvN zpZBTr*K>Zg;07=)_tx;^u!)!T<6$3EQakrM8pvyfQ7o{EBO3i{dpoKznn*}nytJ~8 z#Mts0A%0wg(G<Z}%+~CQTV?;9jbY_B&2W-xJwi#$ay2v#Sv8Ggpol_(r;3!QoyoZg zO;T)$05JK+ZzphkQ1Eboaw4UvhM-4frUsM9oYFEWW*KOCi2Sl#c2Ki0zGgb!wTBkA zvKpy|@z#)fjikELADu-(31)Mzg@sBNbF%Q#c0TaWvZAsacgZ}m6A&^GDkd13En6Oi zV_n+K!D!<_wqhfmz}$(n(y4kLrN}l;FhA_3&U>{>GpMpWk6lCCK_WjzuC*2wf=A?Q z9ren6i{?+DxGnGVXN^r#Xw*9*DsGvyM80w`VTqY`#$gsC&>$z@K;ZHcCTntEb58PN z^I7016@!l@1(q^%svH!M;Lz<pchiC)<eN@^hw}>go~Iv;(!h}jWS)I_wJBGm;gdX0 zflpF+p+Ny3S>ebs$V%28X*N^lh%Vq<q~u8PUcrva>|CvBv_w>Be_>;uk7<On=k1gA zm9$TyBw^N8UbdE@!}1Bw=<VvT(J80~T*C5tha!HY+mDm`MNnc7EODpchN9v}_SN)O z#~?vyy)YFPGxP{?q`-CgR8^Z)25d9tw#E`csw9!9bt@VS^B5YP^6NoRfKe1~!`J<B z;}12{$H{X$Bo<|T0X`}|a;U+=9I@v&%TA|-pKs)L<LK78zX<$j@M7!LmDUUxzbpg} zw~fUKZKM|LceOEgO#4|D0SGc_&-Icv<S!s;RP&LWIIP@*1~eKO|IT14(mM0o+9T*j za8-`>_Cn^k_N}Z6+Y(Pp1%qnIqb6l$NkF&)TGL_YO!fuZ)XS43(}Ns_9l`sdOgFYD zX)`r;j28PCdj_GiL@Py$h!R_`LCT`;$=2Q!;VB~0y7S)rKPicqPD#Mff-afbZhgp1 z*uQz{_k|O%h~7o(lqkJdE%4_cBHhE+s~y>oF;}E1OGwNLE}Rs!8NgF8%^VX*(UY`X zbmS;?%(0N{Yt`YEqMQ3zHI2*YS_k&D^)Q|S8q3yj^^YuNm5+Ai<>#=vruf~D>5N+P zsN&8y#$3|ZFML*xnRJ<gOm%DP)<qCD{}AT`2~6Vrb7!K@ylyo!WdLDe@PkZ&E!`O6 zL^?AHm)vA8c~}oihbl3vKC1AzdPkHDD_o%NnQp~oF>EN;ddi4+wXgSnL3Me_EkQXk zS~yl^D{eF3mU~d5Ng>>PO%qnCq&+{T=4jg6RE?CVhl#W*N=jR*fy#AH;uf;IM&0Pi zuhGdyqx=-tS{yQ`^q2K5+MQUITBJg(J0$Bx<jAOujgs<UicAxG?<Sj6W$_Nv!+Cux zCE)bIx@ic?=A9b-ao;5@j~_4PyGJQw34+j0So}ClHGfs>_dIwHM1SpCHO4!B+=@yn z7WNPvfjB7`RkCIEjxKw$5(mf{k~SiImFD$m7`${OOeS4JvGUitf<nY8+>*;yb(PlC zs0z>g5njVCS9s-rGqkpLHpWw|od_5NNr71=%GxhrvmdDinW5-P3a~~(7Jj^#t^X$T zPtu|1<XMiqHLe02=eHSu8W6P2ZB>RjCR-SvD86tHDy%qB;ZP*08sR_+l&C67IF#$( z0UU<o@Xjfja6@@nu_tSsmn!t;GYRak(sV6kA^kD4aa!j%2g3wAWGZ+*3&4r;6+)+j z*e%Z})BfOEBSPD#(h=hZWA3*3gUok;4<)Xr*o5B!P2Bt*njK<Zt%;?a4(AL*RqECK zL>ISR8HdkHOi3lY5DH}Zi)*!IC<BlnR1*dCAP!4Yc`S(z8)OJrf<Xz?h674M5t6YK zEfUwUgGdsQ2qRvL5^0_EV^Fn)<U~&+T?U%H2X?}`sZkW|kh;j_lJyqgyX-I?N_p1l zbkpgP{u;UMd{VD(Y-xll8d`0mNJO+%rPO+vVHz^aG4S<bl|Y`ZwDYn_k6Pk*hjOUE zf^*aZ*wVjanvRUTzj-T~aysi?^qb}nRNp-M$H7R<sRC4^YvUw1X}L?4r3K?rS`pw& zD{#tGN$lk97I~j7cFv*%3QA&@%ErlP2r5K=gcW~X5v8L;w*DQwbFv+_oKY^9PaaM~ zkQ!>zgPi42g9VYqK;AR})$<eM(3vlP3uW?-y<*tbyMf(A0)4cw=D8bER9GPK@fv}W z-M=rLI)UR9zM(^1XLGycLMe@@S>#I)EJpZX!t?YjgvH_Gm42j&v)p19NgCjt1#y>w zG>wo;I6ig(VSP!Dl<>9XYZb?vxoCe?xq&A~2_wY)01!XxCpaVkscM*tB=ws;v-I5W ztx512$ewA!?>jK%fO~KELkfPvik_l7b-<m3_osi9Op`zyS^BBM-dv9s)+PnB+h!NX z$Sya-uoi#Et4uR9k#`4E!5O%s2PUMb`bs$E0ZXhcrBMlQ@9(B<KjVAu%02Z(;U{sv zh>BD_7NiWxKr}n}+uWo-U6O98RC#PAx7kzdGiD$@4YK3l=g{t$1iUyVYf(8OaHeQY zh!N)V)9qU8WI(wb{wEa>N;@Y}zKha;kN~9-XjGP3sZu|1B_rw<s#c5u-(iVndC6VR zRIanqDP#p6Q?S78qb=<viAc0+2#E{o3%>FihX7WBgv#YPVb){i&e8_(=;R3L6Eot1 zp_WJpg*xsKEZ0joH@uxaWw0rwU!z8sNZl-QhO>3`5;c3ht|=1ECdb-QBiX`mq%ed2 z40HWvw_>z49Rpz~+VVHOjF8RoEPG{_N@F8-Lq9_cS?8SPQ%+Mt_5?uC?_t|@dzl9i z1Qeu6q5EK&p)t!_aT1|cIL4@%3?DUy*`u)ICGqqE=hZv5`96INJb6=iUHMaM+Mw8) zTsE)~CRI(bv9g)%>}MtG>4eDCds_wyk#>T%Cszh?YK8J<bhuHQ+kU;oh^K?Cl!>mG z#BG@8&QTY-7Jv@lz!=FBa}!D*<f6qw@@XT4%QCJrJN4o+m9JDimjhw({=(<n-Fg0P zgW<zb-vY1GU=4XG5$m<mexpfU+fiTR<u8~>M*}Mqn^zf+jtAO<+C_XYCBS~npNSsg z(~yxPp=>Af)879qmPCghv!IYzrYh1<yY3adU>rHlVHF6*S&%kPku$;@Y>gD8#T<?e z1*u#BpLSyCTSwv?EnZuc^_$G}=i{@AOy2aZj|$cZRqX$M?-Zq;%;>l3@6l|V^uNY9 zkonhHMvdDY=HL&>21tz3n?dyfp`%ox6}XRE3HS|Rx#Q#^n7a|UlkmvqyYy6EA_zt_ zj6re7T;*V82JoR=^9bcDr^q#AOAlkG*mIL6oYmoMeJ@aZ9Cv?2Oebhh`*@4U<sveH zWI1<+{n28A^HX_RwW?cUPR5WpuE+I##?Yjb`gXHJMp6QsS_YC|Ed4~n>0;dLabbrk z&WI&ETWia_+M<IU^;Km~0_o@q(k~!Sd45HCsT_`WgOj1Eq1fp7c(69)yILL}X{5B{ zvS|dtk9Pn`7g`TX<fP;SEM7~Vz*$kMuC*HAi3`C{A?K<+CG90vciWLljvZd<C#`Ki zgd`!so<GaaxKeO;po|or3!kj82(|e#t&6oycl)y6ZX5DGbNC$_Ju=$QCPzWbWGpR( z&^ReI+^Ozzwpo>|&6ELRI53A=Aai~|EF{2IYFdRgk4NE>Jd8~RLuZSX;)AcOc#_%t z93_m=NDM)6-{jSwLfSreb#v+QJySUSd>jOCtX&Q`L{leMk_XE-B>UW%&aUZWcT(_S z&iSV$i5OhxR{SA~;V5bhlCe;*UW6aWP^4i~?VG<7uyv4BnoWjWp?&w1gSWG?60ti4 zYn6BSynYUe!t7qr*D`0^R^uIMz2?;&sxr|_6AJlUA+-!99nvu@Al8~Ik^7XN1$u5a z{k(rYvKL8h{o=MQ)R=2HrbCq5I{zgS(>T=V)3QrZ{RD|YFsz0<=QcJzPA%w~x2R(5 zciU5Sr?PxBdSp1{+y0}61dlSo&5&$Byv(;>#EQ0OrfT(4Fy&We{tGI)%%18vj1^W) zwr%kOoXDS!F%*fGC^6+J(Z=M*LxN$kIwJEC;o}_B!8=V)%n4LWNksZ-O{5Lm@z!nf zD8E#FVKXsf5t6hX+3CXRFv_ODA2#W6I8_;aRGd7IYmiqUln*0oX@<d=<4mZ6G5sVa z1@8OVxO0n>I6o9*W?txm2M*>wSr3OUO6qtgRjZI1Z0<IFzgOT5OReV8&nDUWN<VwB zlS0Es+}`JXk&m4uRb+?Vo|rhqe=j`8Zq~5ka<-cgj*{cK?)de(;upmnM@&Ls>%x{k zie?~a!z3^sQpVx^oLv;zFFSk7>~3g##E7g6FaN~peMLBIsYn?l-^2H1KYsiUin+kT ziq`;~R`Z%ns}aTuk!2WuO_oVtq^16)ggy3z`m3O*{|g<aZbB3vP9=JSyK(slwV-=F zF;c}#M}>swNYeK)aD58ie7CJtxLrcQ{ZSeWGV*s=h?>mSwoVs$RF}gV86Ef1LKQtm zD)o;27uaUE9c8ii*~71R9T#;12Y-dG{ph)sfaO9iiLog)El@kcJE~06NOOfwD0K1Y zL}eY5kA9dn4oeX`Dao%|gW;5J9tqFa7YlCobl@G3RQ4$>Y3grQSypIWF22mX|7}%c zXS~Zk<bgcp$3XC>5$mP7Z#-}6Kk4+*ZwR|fs*VJWI1byDB!uMR@m*F(+YdT8xHiTE zF~yDSiFi|C72W}6(zb;qk@sUEN>C>npo`cNPbQDh(I+di45vhYj4zXE!bVg%<}To2 z9z^-vcuHyV0LwmBY>MLZt2zq40b%eK(e!~V<jCNeER)ueAS)*vOE3pXdR1KeRdrc} zo$!mpC~F0KoHT6YA-I94T5VWe`{>(>V;xsn{a^y~=sNq8tAO~jGPeu6y`WTQwLyK$ zeFE=s|9;B;0Nu)mj4r-$QM=h@y>Ak4v0JzfUfg1!b1vO%RfgxBQ+<#sEfXTsxjUXT zq&?wxqEKEL)WYPn*Q*S=ysGt^Ln(tn&9`a}swcqCY&g^!1`kUKKSG!ZY*cFdaXHFi zaFD%IE<kck-{aW8@^Rh2r?lvick%ac87Sw_M|u3Uij!{Vn}^mqWMQFqz{iTov_@zC zl>Ko1IhI*s#j-YzV0GTp{`CXAqT#_Yh>`Nci$Lx$vawy2Js+m;mEqqlxg;vvF&WdC zWV)g4@jM2!a&Ifk#gN4qbNml)sUKdGguyT-9SA)+BYoS)w;Cn4+lHQoeY6s)A9i1p zSBnUL4U%h6Vxg_k@Ge2`(GQKul{lD>J?{i!w`ah`%03P8w07y3zW3My4%+qxkctb{ zRl?TObv*BXCWlAXX@_fk)k8w}*Y?@peXne9`~x%!;RPZxlDd>EO=xFXdYB&%|Ei8h zM@L_<jk~!9U<<Ke2B~mV<WG>Aa1bK9vyWut9Vq|Y#o0HoCPbYtW0G{uu@T?8bJ_kj ztr6YBRzO=#lYes5qSR!mRl(wn0(nb`JiL0llYK@Kc1Vkzu{Dh6wZpWBwxhP)G>tx1 zALPFSevcg&7l4+&wQ6kc9f;56!O|6F&%?gY9e>u6((96x4gn`0{eofd%4;3f5Q?^| zKFAauBhrPga3N61`LRl1o85*}G{xy~o0Mi!#yiO39m!_CtQF`PE}r%OfM|h>s>Cb_ zJ%u$h^ZR_@q|?(k9Sp*=o5`y;by%se-{NgMkkK7i<z(UPg(mv|dqS@$ONw2cbx@A} zA=+Gtgr0_OY95~toDBy9I%CjrTXXMog;rKIxsy~&k%^=D$D#8fcM?n(Jley;;DoF3 zrs?WsOWpx15EW+_qEqh#WXsEz#Xp5)`ZZq;kyG3)>5oPY2uGCAlxY#y2$ukZD7<%1 z0omPxi6D*9)0EMu22|?5um@#sKS%p$rr_Zr#-fuh6c+zI+$h?UznSpChrBTob=zci z_4rO5Hy=K-jBae7f7o+mXoWnjGyMm&g5=)j_)@~2P?;ccjlG!&vb;F?@MVwK*K=Ro zxrlo&>6EeQS^tAHr4LLSLG=$SLRv``pctA0Ef&aFL3~T-ej-QRb|eh;;%fY%q9&U` z?&nY*?gu^!(uxQc%TNzyx>wG_w1%7StjFzZhI%!*@;L8+qV+zNu`Zw5Zc<*y`b3O3 zy3XaR+$kC53Iii)$iSeVyr#LH7RlJuH`mR0FLIQDo=}EH!Qmr$OQ_tA9ahqk_eQuY z7asQLuxsrR-!FoPuL@r7qprsL+R*M`bFCM)ZbBWS#MI5w&ofRT-u7MUlJ2+E49Zeb zAi@YFVgVkfLs7k0bBEW@_d1E+H)NkxyH^^2mLUlHh_Jr{AfDUSzg)hNRnUv=5Qm~; z$<$Ngw5aZ>Vke0dunx!;v&Y4dj^0AB;s17yJfXfW%%wEOqPSt+m9_?CKUfjS;sk+d z#Wghd74>hb@m3e^0)RQ5@GYe%Zxwj1IE*6r6Vmzu1<y)d-7!NP*2UH>uKG0EWhOEy z&)32@xlxEWklXn3>3+&AZNx9Q25|#>Lxj|kAwEmL`es*(GJ-tbo=tX}WgNe(YAy^- zes{`KX}LdmZPxg8T~XOaZmOIA7$43@MzFo@%0I>TKA-H<IC+8t);DOiBmCCc{kF-n z%V8n>yAWI>FH?VTWH5xvtRie03vAEsXgiTi4r5L`g`hy?vyHl;_4?F_Mc*jIQ*uY0 z^R0@=F^dT=hn9o6pSu23Felo_@X<93)E9(_&AP_!30+(97V>xTNCUM4wXY`q@7LSh zmenkw>_*C;cntD4GP@@c%tyj0m^Ob8l31C=&%1hxaB>X$Rj{aPb+vt<N$O?Xf_G;E zfbAJ^Agmw5l2|jtmoEi;94wv!ktX!qcwXF@h+7BY8ma&d=YD+tsgz~$5qs#gSEO~7 z3p)z;a8Ws0r7m!Z@QDEoPy0@?Sxm%zH|M#WG`vVK&|?>+hj<cTL?|wfXkn^m{rbyg zi<FvuP4vLI(9Q_w)Oh*L<86G^FXBZ@Rv_FQ#IbjY5b1R9C}OAZ*49)@JhfVoWcn`~ zMEZ93`XViYwfX=Zq+nWkkl@tfoP9e2Y}bm$nm-E})cU@wsnW*rZ_X{U`!Nw)8I1hp z<I=_G8URnEk76BsPXOnXS}t6{xYt3F%Oh_CFevlSk<{lN_0Oz=e)kWGKQ{`szq=oW z3|uNkx5dd_4Q2i=5J=5xDnc&Ot<kL-9dN3GW2DSFIHog!Z(}?}ckB;|&hB?$V>AC^ zLEk^_5?$>I+m$A+Gg3>h|6|ADc}~&Lx>)k@mt#|wUXsnHQnAw<QWZO3KN%SGI5lbh zo6tH>I*dnyN<v)g{(i{ECeOc1tRGJgQa?=c=^@XjFu;1ylRC=IXwY2$;=H1>uLsU9 zO;vHJi<+mHXex_GOyDtVO4WYtaIwL6wzmU~dU)VK$_>SpzDhok(?&3?Y=0ShKJ0HO z10L)Z5!y-(CQX~UILGMA(H9yH{k4kyI_I)K>8L!h(_q0RnfC6;TM_HSHT}|S$i!sd zr}Vb|K@4w;_Qd&m2v>mR5icfBcp7!Nan@t_s%SqK`w@*-a%{5LUlR}7*MkD^oY8mf z<%K<ZND;3v47CkLwk4i{X}b9QC)`lh3A0JRrYOXdF<Pb!)Nf?j|0Y-Brd-d!CNh;J zQV#P;zR5Wwv4{{)Cx0jIPKpq02y^ZWu3vI+vXF!W*LcOI;b9sM`+)6)P~Pd+MrJq! z{!DygnwS!hpT0=wssY?yJ;bx;*KYx-Kf`TKF$}XF@ITUSIy+BB;3BNBQcsC3NLS!% ziaa=h1iOU9GJ@6|v3xK|$4^bugsf2sV_pH7jvU|m!16W1QzD&aq&k+#5OWe2r4fRk z`;A&<AZo?tZ`LW*tou9~LS$_&=rZ+88w_n7VLLl2+L&#tT@Bk<1ahlbwq#VW$Q92h zf|zMAKg;(l#n4rDQCX=d^h%S$x4D=7_T-D@im1~R-G8=d)+16ZDa+$BvwXI>3lF0x z#?{NU6pz0nK5cPJ6cPZOLPFYnSoYL->1p_oq*hjcf8JohyW(!_5ifEnW6s$HT0uGJ zlV`;~`KN!i0I$}R5Qb?#ux?$P4|A`+T3Yg(AJ|>#>>o-{snrw5t<(g9ftn1+4>@#S zM}hJ|>I#<5D;l^tf6GAWzy?<RAPOs~^HeGT_mNR*O8H3k{C;9Ggb*GP83^YH?ln02 zz*Hggu^HYj4W=)}X*&sYSwTk6N`pW`%N{N_pDV6M@Q{}Q0!6BP*`z<YF2C7^eYH?- z*bZ|iMyRj~@k!G*+n?f&mio56{aI<Y@Q3$yx{YPB?7<c?$4f4E5+a`z44;~ye}npI z(R|UPxEIB(Dy7H(gz0-RO?=p`8jxq;&MaPQxCwZe{@KTHqV6bccI-MWg5mSshzPjt z{{eGAjK68;9qWoAx;gR4{{TsWYF<c=_8FqMnp@_#QpF@BkUEkwIQ2XFdACmpuTo8- zc_T!*ltHH~##yRLGXSKH#O0IJOtBcD5o_a1T^{OKFZAgkWIRb5)}Mdn42;C2HYtY) z-jY3YRfg{5Ev>k>jdrQ~Oi${|@nhi|vB@w_$(D_!!vHhhOTr~zQszPXC_iO?%4~$n zVgn5P#Pe<bv38bW%o=0^_ZDhs0`@QB$1}9JBN24rX39yX5{hS%gJc_Keb8~hPjrnA zYx}6}EWr5oE_x4hn7My4aEoUcThf!uaYLm^p`>afx<Inr1Mrzph75n%E^Cf^pA(zm z`f2~x2c_sj))R4bL)|jf6~4@1{{T0>a}&wF&qEk(x?QX5HkPophMGBC&;IpX0lg3G zjK2;W^-GghNsd8wf*{guX1bO>SJR3_5A2MqQIN>+kENC&!YqIDMrltR_bq>LLlpRV zo{BP@gK7<+?`reP`i;bJTc|LT$>lo%Pke|RA(fNKIJVR@=qBL=>WOYCB(Kr_SxJnG zJ|M-s$*D(od|KD{u^-#%Q<)f}!Z&J}wmWqsQpl+%!sG0o!!lwTilkQ6vM@Ua-{Gd` zB4j~N2&OeWd?kN$CbT3R%!#nmA-lb2^nDY$cJUaHg_NchY%v57dTtH^f|@|-a}^KT z8CeZgMPf6-B^&IXqaJOs0HOp)=Bf5ur?JR0q%^=zt{hd@VO%lUkY-<%34AqKAq&dY zlC!-!FzG{@?$slO%hD6X^`D7%8}=Eb#~qy6Npi^G0ZM-iuz9fx7ZS251F7ki@C(_J zh`Q(xT5pydNa9mv9JwC(8$6kX%`xaO0IEgTkajij7-?;5NX@wdow7nKNmFVXliRjj z7&jH8O6rkBWR;B(6jYOj?_tdpIhUdNTgdj-mWy+#f4wj97>>b5zHW5d7>My?=IS*X z!c9YyCVhW1Pb`k8plz@{MrNHREr@X=sL8k})Q&V?fB-sEGb0fZvq2>Db!axA+kYHM zAy79rvZ9|%RflS37Hs5laxvlunp1JB>Wbs{JdlrzY}Zd$m#EpzhMO7cf_;1%!EqBq z8QGb7@9mVGVLK7Cf8{H9o+6WenzhpJS?$Q39cX|4qU}$7$H$j~R}o>!mLC#6v%+3| z=3Dy*zmoe(2F6l;D55_PFXj}?fJ}|ZF%S?G4W!zxzj1SHku;C%Q}^73eNS+3!vF@t z%O=%H1*}5m@JGdhFT_EuXfr65nzc_b>x-t^$R?CKy$V||fazRt;xj1AIuARM=f0h% zlXHJ5_>OGA08@fZtHo*}<Wy{%wnXlZPfhYJpQp>Bdiq<W+Ogy(h>)`d@lbx&YxROr zmj3`tm#3mOOhx7Q18B(KS|^NSiRW7GIQ+90fOF{G#Cbpe*NtmFYM$%hkKyDf5PSDI z&*Ib8<=xLOK$U6bAe+^f;wP&g9654vY&w4;5Ut9!<?~A40}^@z;5;(8N4>Dg2=`Yh z3A9%t84X&UPFMh7WK*p&YuQ+W-@A%F96;Kh`9y$hg3Ft^K_IPHvgCj%B%u=GFs!3x z-lMoUcv$fztinKf8c06Pd*zhI)h;Y9RuMhBm6!^$_>XKv!cH`ay+@JDkKR_T^UHt7 zCZ)nN;eyp`06H4sR)7cR;fkw6H>SWJhrr+=Qv)JQlxh|OW*J*0mXfq{1V3b&efW{G zLnqPvuOzz4*xp9)t;&~KX;48G1RU4q)d<GH+&NgW6V2(m4uff;S_^nUE_Zo5vCSrI z=OMD^W5r1RAia5!PT5$MHW-DnN(p};Hcl7SiJ+q?EW7samcl!=l7n`TIOCZdA%H}X z%%GD~?U<*Vcr7T~-vy;%P*1iNEtK%-gcTv)od_rB89SyRZp%EkuK8Qb+H{v1rQ8<! zixNW2z&9WAMsBnmF^M(DCsnPC%z)(-tIR)~o>!jgIIVBQiYd*AA!>Va$?AXMmm#_= zble!m?7u?RwAr-iuI(pFrE$n`aNJLZR}7vI-<mQ<#FojI_^Q`{<fR;%5nRfTY63Ym zenj6kKd$-9R9SwnX>MM-29JHuP4g2bvUA&$my1UTZdm2Serj({ZA#)uOtmX%8&T>0 zSB7KC!Q8h-Q9ObIZ!la~0~3GUOFYKExQCRkwEki`V4{4PnjILG{{WtK2=y;g@g~#W zv`?%C3gfq59J}(sj%-GH=+>j=8=JN_GPsA3;uU_${{UO(kr=4eDa~O{!%nf5<)WJL zEt*v4Z1t-8pUU1Ua#FF;hZmZLsefT-BHCT7a9qc@SKK$cNHS1~JduAX0EC}bzPN^2 zEn*SMNA67p9-ahb#3=5(RyJT4x@Mr)A6?ZEm(+7e&syemWB{PzN++6>q8Jy{J6G(C zkg~*tw)XYqc+~8rfOTH$-!d^{-KQkBb*8s*ZFB0v?12yNq(W8He8y!EBq~9uNXKwl zQvPade^O{(=se<{mDzt<Tp;LZ{pvQ%FXZf8SN_Gx{M4!c(B>(963NA?#k#kKX{Bdj z$=k#M&zdQv8yZnhOro1@Rx*cr(*X(!$z4o^D_#P>en>Gak4=z6q8o)Fs{a5VjWZbi zRNOWeg}Au4m*ZWXyY|ZjN(o9X$yW|GZo|hd0g99Qaw@-$&If-{k0et0X1dc|8m4O| z8=j?bP?sd`Ii=c^*3Bx-Z(rVhwaamW2JB`>L?Mxg$<qs4z7=ng9k;-XR7CX~ZRylw zM6HBE+mJyAkAVlK6O5yd(WzxK@Y|oMw>+X!17^@I=~@??3^uS@-AB!!K&vSGTMAP& zx}6aw;$4m`@v46;qSsfjyHL?M<=h^lHS@tZ^A1!e6UeWUKpLR+!;c;A#&X?Eup5)U zU&6qZ7mzl=sgsOYmsLW6)Q<R7c$ftq*;$3Ny__a3&{H6Bq)oI8$Kvbbks*>c3tEb| z*&CCQtK^<s(vLK1ceb_<7MB_{vME)#b511x04U9#i%)+ZE-9B6Q<&97=LgGPVbobA zyooOEf1R0$-@s<_I?PbyDc1g5B)26;Q}TwVVQAMkmVSgF(2&(XLiNm?dgx5-*DX1Y z<tx(lokK{NU1?X0h9goGnjdCNo?H;fIW<Qik0y1c{(Nbdaktm(U2YJCcSBHV-kGJ# z*@PX7n~#529;#a3{Nz>TqBJ@kfQ@*O9Mg)B-^>P227rUPYt5(+H-F6k0L&dl?j%h> ztbCPbxO4k*ntWagGd$?=iYId84NUrSO!bTJ%nu=Iu*&+FxSHex>&7|5C#Wm%pW3Bz zv5yu@0CHw>WRIF>ALieiFKters@OxQ#K3@NjB<b9xFe=fEffaewhZL-O49dJwzv`- zcT0VYmh>e`p9<uJVnuaN6o*ZdU3Lc7VzZY`)84{lUL`pm4u6<`E?16X2h_~K$ZVTw zzFgJh)5zD<$-s^RoY*MwIYvy;x{Gc+Cay`WzF@F|*V3`H2_$2RmQnauD)5OmOK*rO zfM9<*<=fA9EYfXLUJQHtW_~CD97)Q{7rB;6Tla>dyHlXdpwNUN*5TPcArkcWZH8oG zXvxhlNA$@BNHsN6)EvkSDu}1hl2g}UzdGPc|Iy@8DQ6V%>6&tAJ9KUH5>hd@KytXn zC2VpZRlz$1IAjzR8RjBz%4zVZC+NumvXXzv{IjUWH2G}i=ooQ5jdK$;Dpuc)N>@mN z>C)9qv7PwWp~=MUx(Mz-UdM2RdXba^rb;9*AgMrkRD(b|(+q;dqoKJ(t)<YcZa;S2 zi15f$U?~;-uc30tr{FuLUkO-gF{KdI*!3ASFGMVM8{oB9OKNV}WO232BD$yogDrmn ztd}2tm{6H%`hkHxH{5j@g`W^6!;y<7$(&k@CWXEddS)qwIRZ(At@)@iKgI2lFk~c* zeSY~<fRL%Jd`4H`!IvD7FnO~6fJ(8oI<+WHMCrmtQo4UNn8+p&!MmyGatBTsc*y-X zT(i7VBa{gT+E&=*GJy?(VK5b@LYaRmLt5lWWD)QoW*!+uHv*}t-?grvFV&OPyeuMt z2U5-6p4pLu7gn6pRMGs63^v14^CiJphWr8enUC!IW_XAq&cVW-QZ|p&h1GoMbhzHz zN7Cc7Qb_v44s3i0KKREV<N|yn&P9QFvsu(`tRCu0yu_E5F}NXnftO;A>y&?z9%$Ny zythLAM<MoQP$@(^8joz*z{)PZLeh1N+v`(Atwbl1Fz&6m%krCxOg^N-=oun0KHT;s zW+Y3IA<>cfH_AG(wYZYP+6$=l1UBsr*rK}oAM$dn`k37keJt?l<ash}L&?5v)o%39 z@deet)bz;yucz9;KX(WNzw>`LzGDq!U@ABMb8*thn<<*<T5Ywm*x10&a+}@Wo$3!w zvmE3k*>4d5Q0AXmc?w(mNLJ$EL$AeeL9-77l$}Ku&JC4f((Lb{`lxefeWf`w+l{Ih zp&lsN9wHBsWx@(ef9AXNypY@RR38HnDjP120Zn5qC6}^&ezy^k>jQuL=xIVd##tS< zVowta*6r5vNiEy=b15b_9UIu@ZZl3S#q7n|B!W{6>QsZjhtD#$Jeq2j+FYW`CCsDY zMyU7O;mnwd4m^#N9;a+#m1JhDw;vf#3gv;SNS~`HwDf;yuHQbnR~M<bhtmH5|I+2g zKT6=$cH|D-D04@4?g)QVxa4Fmd$+^#86bkA6CQ6~q#6v72nuo+XHXWbKmdE;3Y+Pc z`j-ZU00_i#aynBf$U$w0ES}2NIAR{1DtBUflbF(xZj5OOsZOmY5&=aY5XnF+ie9$S z#d5NxJsF1*4?uEUTQH<7${(GizYC>WChxXb2FyxPEV9Y~ld6Ag4qFjHtgKexYDIEL zv_-tfpBC@mwl)VeQ9O}O0uDsfVUAm3aggOT73_Ltz=i}kem*#xKv>3xpwJAchH*r} zav4AeZ+xd9Zh=IrviwTaKF2Ah#9HbM$;LtP9jkzaMJq|gIJU&^T!{$?t_<nD2p$;{ z86m_BUZ{Arc5Z)^z)FO5z5cmkLFdXd*|$<MqmCuAG9i`dn&q|W5ljA$aoHH`*^iSE z4baF7D1}GDqr>UUNjU%{o?F!o*PdrtfHwMbo+Gd&dggeC4lsM%Nsb~`dF##%J@YJ5 zaI&{FjSoWOsmVe%f$0xAxbt!~=z$rWF(zpVBnHg5;n{zxSrZ$5Uf$xUSBs6scLZf} zi3OBo4$Ny)^3+jZOK|`Zz~GXdje6!t%d?Jf?#89HMe_}kQ<}&)f#FV>o@IEg#SyHO z%hSqBZ~-%}-w>ybdS<Fxk~%vG>4ECav@IrRQV2zMBZ7lKzBz-JC6Zj+c=$ss8Un1r zd5#lMZ~K2O<GxTb6vzgytRd5<f_360wgfsJlP`hEMntt8lHFg-t`;lZJkkkRRvwKE zi}rx_VU|Y?vErnURn{k(cD_gS@1sJ{%i2Wm6Vt9=7}m#=2}`RNxYTC2wl`40>GM3K zx9h}?ymKNjt(s*k-KD#EArnAoDYO32Kgw}29aeub*(<)D5Q{X+@bdlP@HuRGQ(g5) zHMvBrw!)7jhOxhbd*)UM?$MS)l7CNvTmWjS0pGb(wr3X*a$kFGA!w($YR@8qQ?h}8 zl!&S<a~R&`knm7AH*T~)hGTJV<nV0&0RPkH>kCM<R4rbccxHm>-PNEjBMQVdRDx&^ zT%3Poz>f7et+=?QS&r2C;W9#LWumjzOP}2+`#vY-m6@{vIEhtLj6#dt_~k^l1u5aW zKfFme1>Ex3VX&G@ki4@nEDKlS-zGK;L05RxMq-rlSNjdvFzJzH$pe0l@CnbuaRK2d z9mkGJJlK?x&lV)~CZjS#Nl|7b_1h&(SH^$r7M-vVm35Vyim}-2xd{Y>Q?LpVdw#r( zY!s2RYHBIpG6cs~gB2+%Do!#94XH@QTXW&tDW2_+*%t-EH2@SjQ4OH0f`7LnlvucP z55j25Ymuaat8J>fkQ`Nim}E>-X_9b~XygZCYDZ!+ESUwDqa4C1&`W1A0g<|`d@_HD zhUk<=ujJ;9O!{jU3-K7q_=krRkcOi6lPTfa!XwJQW0%)mH&7%!X$-Bg2l-A{pAC*6 z&kkDjl)K6^nEc1)<6nv=*q+tfGIIX_cLuCS*CrX~Eq0))kA^lV0XvsHPtloJG22F5 zj7Hw*uxKMv*A7Efd~+^Qrig-5{VspURas-2c?@x`?i*?kdgT+y4#Bc9ERHQt%Xf47 z6JJKO`$oHdIgcnvbZ?B}d6qTi4LZ<ysV{6M7g}|YN15Uk3Zw0G%-k}Ug~EQK;bozc z$0mDh-dzbD#k}H0l=Bf2yD1r}n4eJWqbR2XQn!v+?jj-KLOF%pfO;IH#@l}k`;yN( zeq=R<p$)1hsR2r{28=86%dlw-fi5;o)M<%o9(}sKl^{oAwW6~fz|6SmC|PJZtxCtt zmzPq!Pe9csy^AgxVgOh~auaRp8qL%K+u;ob!%@1`TiX~w7E+lAm{mdKy$<X<WNj)J z6TxL5Mj;}aq!03r7{thXkVt<V*ruVWxSn*lUMtg$b~WfmXJW--+*#zUlDOVUrAMja zgL?Ho12TkZ#*rm`$|}TTC*pDo5&I&wr_AJlDu{~CTe&&8G&JM~WgW8@CPyP2Ej||z zeNfu)9-SyxwqjAca%*aT|J3I88lkr_BS-PusQ`2=$GOcEp?satl_r0aY8rLwe)^oY z*_V3z9IJ$eWP+n5?w4Y`;6-UbpdEKVJdlYz&}kU5dQg34@=CJ~Kz3YdiP<8(^A*F$ zL<fXm_3m=m;vG@Of|pgikxI0LuVIrI;*K(g7gB>zNIp3(0K`b9R=T4!W*NREJNtv! z<x<AjCa(^u1M-e@yCHvB826`4fq`Tu5;>0KDXQ0FnGp>Htx>l4N~+vGk7FjCqn z7?{B)+wRE7CBIb%Q-YGwlsf=^gO&reFd=JhNgg-|WD2A#16QHS$YcsSJA{3?3gvWJ z+Om=X!AVp|WGTsh$m%d!QI;134@J!g-A{%@hCz|qT`j~07NCES4}|#BDIv1ihnBTJ zui1olrNzI(-Xsjc)MKLMW0i#;F?oG%m+RK567mum`2Nrx{&?WcUX#gk;{GAc=5YC_ zXnNn7R^d6zeFBuO-@H+qZwHDou8s_6!y{}Frn}TG7|PXY8<W@UjLi`d4FuY%U&(C# zmXrZ>-dm^c-V1+T!x0<i)hwL?9`5g3yQsLiW)0Orp!_moF&!Grq<W#(OnwahqJRha zjGu|pn0R%)vX3g<Tw7Y-+WH*gy|!qpS|5vQ<r#T`dN*PgD5TTc>N;D)Ss4H!V{lCe zwn{S-M4)5Mmc%vd6}NK4LdaMGcVvG4<1unVB-@jYQUQM>xwO1#rV=@oM(H8kkawo} zY)I(9gVh(O=yyJx7mS5fYW4nQ8C>#>!zg3k(>ZNmww^27b6DgRa6e?6tV+c;7=Tw_ zn`gEv<PIbi_B}`6i~+VYDk-W*HIR}vqJVify7=U!j--(-t&MA%pXq%nIfw4<tt6ug z&@zsMW@djZToU)VxiX0hN-jyfxH7z;4j>x#0A^H24$eG{tJ^qPP(&HgN5aQoP6HsA z#HN-SMa}fpiFt2Uf7u7$F*0f?x;Xl>Q=_ews;VA!{`I}{2+qrs9fAbgFRnKDPFZCy z|JA?DO&iGn08?qwbho}gW6>anKJ?B<OFW#5(&vAKY2@uA#(1>|>^-w;;HiQu$n?TM zRL1&4CA=S6kb%eHje4I9t{nDeN=)=xfwucsu3Q6IT0v9dFrjb}xdZH<eweFFB!#QB z4%=WGs>YDldU#~GQEnuWUc<I3+PFVI3<N6hDljFmrxjkLfm+}yzlc9P32jtXy@o2d z)YgBUF|;*JtAUe?0r6Iz_-#Q|hfoQz+;+$qr-?GOz64bd%w=+eFq*HUa}7{|D(_sX zshX{|%!uGWRp{fs0ybm|B+&I0wA5g_ts#`~n0p<olx4?Zw;ZUwDWX#nI#8(<1a1oE zx>0k)pFjR`X>ViZ!E&oeFCq-5w;|MK`?G&1#R~pwtJRJfUwaz0^6s@XYSCFv!N6qV zjYbE)!!iz7-!~M(UC%=DzvPAe><M$MTzyR(0+xtAIv(tMW*%*D8wVo}kEvqA%U0i{ z?{w)K()4crtsLa|{gQJpjzj`Gngj)r=7Q8bw{@rLEoX3qv}{8W(D)n}vk40fa$<im zD6I@!MQIJ}t4nr~?b-V)S731Fj~3lm5$e0mXdg)-#T{61r&I5h>4iv!%|W<=wkzFZ zSkkokgvcbZZ$cJ3RZl?emQGQDB(=gakl9yIxAQKEG!LuTx@pTGX=IL!2tEuuWMMpW z`bx?(kJL6&zw@@B7E66TKVMHDVfcUU19cV2jU;b#x(=k)!(@zK`Htxgt);V%Q+iPJ z>(u4L4l%hrm<*FVg0*!9w_$V&NaK#@qpx}leIt@ho@yFH-#m|F2@JCm&0fUy$1Mty zF@v~6%b~nJsCf@1ZVao0X9R`=4#X)7IHO^;SZ+byxK2~mBNz$dSbb+l6GVRibS8v% z12GJUmhFt2K#>K!ai{<V2j*$La*Ts^*o~7LmQh&E0C7R{Iap*8V+(y`M^B2h%Wq~> z{{a8iBC-6e*RPpP+^Yhe!LK5<&SrYJN04{%)5=`Y+Nb5q=vr*E#UfpjDAG9Ir^70S zvxv4!Pr|}A4H@l${i7oD+n|4^Y|V~KaitndW?3W)a!pp8Dg3!?NDK-A$!0lVU`pkE z{6}+vS2<d5y8f&wwTmLU7~7}poNAWTm=JgC(<a5jqi-CFwsyj<<xco1HoaVv;(Rbt zZB<3VKMZHz0U-&Ux0gJ_br6~>Rxvl(J~;z8j%?{|#*eF6AN0F-j2(Z2k?`Q-iR83U z?v!dAC~~Pg3{?+ySx*+&jF1UO=$=*Ab-#+j+1@(uA=!TXt1hM(`Ig*xr7lR@zbN^W zM{_O3qk3!Alj0dz)k1uda7Z27hIZzxoqLX(;2~`h_2L+EUF*I)D{0o3dkoN(uH)?~ z%4HH80c3hVnROi|CJ}$RxU)(VZ6@>t^$nEI7?W%=7^Xj&9#KAM(saA4H6FBb$1OX0 zPfl4KHc^U1Hf6`h8%}hcOj&C0qD%rTN(H2bgW;!4jD?G|<1}fK073Wn`|}vN7rV+o zG5o6{Ym>=hlx;-?-LLYr(4XEX`ow*l-DtCcSN&H}n-St5B=CRgORo8Y^s72sqT9tC z=c`3gB!3GUA7O(o31O&?r-?YonX}OcuC|?|U0(TQg_BIx3hG)`pqqopd_(9tem_)r z`<e`ZBqY&^bzd-8=@6hQq<Yf>Q~(dON!ScE!xJSZh=`|umsSx>`lW=FdkJqEIOe5( zB<>DkYi4~X$;p4xLz^$V)*}7(mvD<Av|bUG-DF=19KwVMJWgbf@UfQB;PmH$7}XSm zBq2or?nY&J+l|^zJ&2^cPcay6?p=xB({K*k8f51!*uYR?TD8xms$D2iM3C_Mb2l7M z3x_f|lr&4Vwbo&lL&Zx`{{Y!!@<f;{2(-moIrWW7FZX{`s!E>%IvlF1Ax|=rMH`X9 z%~&2P-ZaaJX=VOf2sGD(0Czi{-Z_h#n>eFwt=iV^H$n^&o;C0}RoH9>woI=P9YQwM zG*j=83AC{KzgY`U-q($Pj!&a2fB)B;wZhaDI}Vi25JdHQ!z4bcMb4dZ3EVOgYwf;H z5%mT#md}4F)zJQol3=`yYI=4DYUauZcNRlrgUg~vouLRNjes&C=tquZ=h!skLivkE zTRDvO^)}Ddf0X2~<l@+{%L?2i!H5J7hqhl$ZM9hnkDeh^)`U{DI7+yJr{d}3iV-%7 zl9r8z<LsP)G{`FbNcE+1JZ$NFRt7nF8aj9$*qMLqyeP38W1@kT+Eo<z9sRMjBWI&| zH&GJkYZ3yj194(=6D(-6fkkVvi_IqL6Y;+i>GNC|<wcQsM6Qw$M#rNA*o@5_nkjT~ zFkox&;Z!xvtK8g$kE?$SJVae68@_<@n%b|HwDq+CU=aX9{{TOF{W;C*2{R?#U_OAE zNeX{BDBv=lpzri$Hb(8-*l(DBF759pH=1h;S<l;q{f0loGqCGO*oPY~m>x_v{{YF} zWVV&ntl88W^N(?v@zg>?xx<zj1F%Y3EBiNDWQpM<)8YhHnP@SWBEZ<@6RjforFnUp zM#7v<RVdRam#ld!R2RA&aIqe(rDAd7zp{UG2P|CNQeEba7wagO_UlWlZlHB5{g~sG zb52NKy}cV-XN_UJBsd=n{;K0R4q-5#dmH@lmgWy4eRj@LlUiJ)vCO?xovJfCvtlKZ zk8_Kz+8jZL+5S~)dY6~%HM?u5ZY}izS=r49cHOq`n-AqE!uW^Mcw7kSOw9xJdYXUN ze4TftTHDEEDZ0DS#YvEILa)_SANO8jW7bD0H?Wg@@ED679(R4`EkgQ3Wg<mjfr?G_ zW#Y~Lu^F?)ri^#`oC)VAWbeva%W2VmjA;xm8`Oe8ei<w8<CwbLIgv7RbeV)j7FTqQ zbA1Gnll#Q0p4A>Xu8%4%T=zDZ+Dm`xlB_84u~YInT!0O+EG=$U4L<rR{{VL^q9S_o z2DzLeWH@4U7;8-{qf0Up&GB>p04p>0x^X&M4di!lYrR4HvV1!*;!NyspX)CzH!4)0 zY_4%pBvc9wqd487ky!r#s!sV-woS4fC55w1@l(}qagSryD9A08Q)9!>BeH+DCHZyA znS@o{CQ2_>0hi`70hJ+|NZW<j`)UYf_oY9MO=$9e|Im>>roN{XT7lKGju>`&Gm~^a zqkb1pxo%vm8&D08OfiP(GuedO(n~%pJ_!<z4}tN^`mp8_4Ia!zsn0Z37VxP9+~!6} z0vd#ORlB)cg>-cekK4l|0ZD%)iC<g&Ug4wam8!O9K-h7lD5(aauiu4MGSKp-*ilM= z2LKV*0Z0hTTQdxW$*8Boa;Zw$fR0t=(;%d@P>z@qBpRXlVWo2tDM3~z;l`Dl`7+*X zeL7!KwUO&XwqoU#mdFM}5%|;|gwT(k*-Iyud5cbMKUuoAU_$ff5$u0evp*d8gH)qP zIIdlGJFZy8THab*B*2e_w(uh&QW<uI=4;(7IkA{}PI)z~zhm5|hGFH?#!gN=raf6z zpXPp)#KU<6v0MSsM<qWZGYWbcbmZiXSnA6(JyTBBtOezx616Je^-?@WLRd^r?8Z?p zNs$VvLM!}mfD^=&Yr20OhOK1wlPKWAs{a7At>Q4p&~1;^v3TA6jpm1`NpCPDsUSB% z%h=||GZPl!$~*>{XOgwWwbUY*zX&J&+{3kdS18L3n=#6Z)mBw*pAaLjVh&*uPH-Tx z-BV8%_k7fT>GxCN<CjG2(;>SY{J=?XJdLSYnzDrE2ZtQY&>nvrz_e<&#zHglZt{Dd z%55$g!Q&0g%8YxW<$bCIW=ES(XA@S*@2uk*il_pTLK~-0b6O;6*g%7C8T`TgxwD?e zT}w$$7t1S|Lj9Hl+c!Ebc@p81fvVAo4j}VQK5;B&&g!wt8Tbk6q-Oqs9!{hK@nxP{ z^KHe2%zA#OLXLmIW4S#5J|{Jrtrzh{*lG1!@X4E7-bSr<z=lYVC6Rv13RbnvRG@mh z0U^=mSGPvzoMNM#?;$=Q=GGCXSt(i%P^5M|2Xm9hxnnJYR)BI9@Hvo6Qmv~29F5_S zk`p}&UsTTt{j?+L9Wt!Ll0i-$V-dA>H1yD`46BEmJ5GP1^(I&yYr*0wf7Y(qN!geP z>u*(HPgDEF*yUxIZ_!n`CY6Ryh<g#fHreL?0RPc3RoaW3qrL$y*RF&bRP3kXVZnZ9 zCONAaj(<YZ26NzyFp;`$mYa)ZcY-<w?@pWKsckB}1zf1uftN+DNgk>gv{D}19J?D_ z()O)#=CyxIa=xHf`z?U-NLP~C;eK&$z8!KLUF^|z@)=igLNc-`H?pt{m>BF=tAnxY zi&UUO;SZ?Fsk^s9<AT{nMW}S!yRA+;NUE1SfkZoW?}?D6;crilDI$(I<e`yN2jj4) z%oEMDE88txRBeEzPgwFrr<kqMNn|$nF;qt=+#i1m<pWl6A15dB;$ehe{#N;-)=%83 zT*OY!riPy^&4#}mgO4pFf`|AQ%vPh9)%{fP9Y$C+a6MYi84ikbXnL}#EoUN7d`5<u zA(98H9tjSLdR^i{a290d4$6D4Y%__ilL?NjyYjf*Cz|yIh0oOeK2IO))SSlDeITA~ z_)mYR)72yalo8l;%pl~_ml}4-RjNVf4=u&4X_5s|^~iyEB<^YZC&wp-R9NJVk2jQA zneCj$YB;qWd<g5AvZCS(4@dHkn-uW1yM8D~6p-}?usMmB7gsA5Mq_gtUfUIomECs) z8t<6E(~w9ka`xZXvAVaAZ6r?{FS!)Vm`{I(LSAh$kEGf&%QjYCQv9*fV$^3?T2^=? zQ~vyxLKJ-RdhBw^lZrd>IJ#_t1B9}lG})z;7MCYyTV+Q5V=*-lBZvB}`b>cFd)N!W zi3>*6D<>yo?%3Y#<UFU$zDXBX4<)e?On|ybIybX>=HE@LG1#~|jWCwshODAW*<XJz z372Ij#AfOf5*F&D2sBalT8^uy+fQzeUI|AMt-jvbMqM;y+mAG($q#KLeyZ|3Laiv? zIaKkG$}<Z!hW*MSjmfAP96J_yHwZL}vZ@`pB~Q;SC50(2*6x3qfWmpx0<WdW$vY9Y z+vIWu2=g(Tb>`c08)a41EYnVjj-G#|e%b@@<`zR`zADzUnPhYP$`}vVfQCx^mYh#o z?T~B-(*FSe(lV7esUYkI`N;!i=&FMZ)-^$UbRu2tp(p8Km&8+Dq}MaNjPWVu6B_)E zTM)pTQB`QqB5B;5kw6P>ZWdV!4XP<jK&2%q)#OrRLA^I4FUA_?QsOtIYnFdoU8>ta z(zvBS?9|%038gZm&0>-z;{AMla)Y^Ui3Bf;iuR>(EN2yJ0z7au8wzZOwdsYr65Dru zWJa|tRE@zMcEClcj_j++I;7XP%+tcG(WMe<d_(EXe8PixHq3YeL2c=n;hHMm0$d)8 zm1N0Wn{mSVrq;l!(RKqcCTxGX_{AayQ^UJr-_?W)NmIR%Q%<^PB8wXx!0^lPlf(l~ zPo%sq58hsTvlI8{MaVi{W$8bZw(5sckdNLm8~ax<bv+!+4?!d^Zn=jt<pnd=xFa;u z*9vIEsN4*du_5Pvana(k)RR?>0JgaJW?sRLW@4R(8zgBWEA@BeyTpHf(Dv~eVco23 zpZRBA`h@N4vpuY|_`&IwWX1^DyB<nAvkf-)O4Vez5SA*c2P#u@n8X;lAc+^8{&=>s z`nQ!d2ppR?m0AjSpPy6m&9`S!jHT9{R=jYLUGH)8)NCwmEoD69jwDt-EEhG}&9VXf z*th6@l6=@b>cF-v?_YmYjTWEHn2|fV<t&3th4g#bW?HFQ20cOX8EKcO1|)3qU28_R zbezhZqd-}10HLp8T-;*HK@J9NK(T9U$)G@w`^ftf{c~>|fV#pd6T&%{oHQr)qdjs+ zq%*<I!BR^v`>p!@83O=R60mcR)@c;-am7woEJd(QOqhn60Th3by-&*_M%KdX1O4NB zV2~?e6KeG+-T8O<<#O005cC^MsG}2A(L%->ay&B*Q6#qs3QA+vEZ~|6T2aN7mOwi0 zcFQ~&6nKCve>d2DYBT`)TZ1R@$^K4tYBdq6fB(~Wm@m>Oni!knV0jVYoSXyE9hfr? zA(V9MUwq1Gwy1vq6coi&rCO;K+L<7NP<#|C6VPv#G@OYH@|h)P>%ZZb#4$Usx3qhX zTz<&en-Xb^igywuQ7m9=qw&f~wz{^;D<>}a89?xr8XEgz<gQ1j%L81aYRFJ9EIAA; zB8;`tto2z;aY!SXNFJH*^2+Bj8{*HjeHINm-D0*+-Y|dt<evT+i9D8fZ;T3U4WU*A z`T^75F{P8az8JgFN&@uC3338HA~+7&i!-}?RRO@sZWP-Q{K?kZ!%0g>jV8QqCZEVU z=6`46;+6D^g{_@NyWVZ5BelX9#cAK@&B-pdA?UxB*GU!J(G&a0(_!1;H93Q+<!)Xc zZ$Tv1hoOJXc$J4N2DZxVrQ8QEqoznnX$gO)THIQ+cQJ`$ZmZ$%mqRUbV<-YQdET$0 z&!*iQQ&xq6dudN`{GTzI(8<K&N*pOn1g*-V{h1HgJ&sP;GMyG}PS)?ywP5KzIYr0A zzEL_mmiSmS9)WM<yI(MQheEx%05*v))_9R#MxB2!c3w#wvXAh!_J9Hwd9iOedBR;o z^M-9M+D=wBR;-e!>FS^9m+Iko<vy~P`xc!PszN?Ut4%faT}s<cRw6g}q+!?$*J2$V zj#==FR`czG=)PN?2oF9#2P3g+p0vpbk&=rTPl`72{XjDLd_b$tYuQ2~4bIuJf%SWw zY43j)9j|F0Q<ZH{^5MLc{gyqmI~4DC2P8)%shr0k2xtd>nU*HdVu<?ENI1>|4$`j7 zGQlP!CLUhVZncZ^Yj1G86%3B@b_2J};LDqUTE-kkkT;187fov<RggChE7Y}VKs<25 zTte89MwZKI3^iF&NLhAeJx9+7Af$yfj@f^R2`h4Kl_*L2Fwh7K<coo<lQ0Kr@4vPR z9m=T&o+Y)K?S3nGSR|4jfSu`p$OWV!Xhh2o72tOdU8&Z&D=4@B)QH?&TwXgfpN6%k zO|zR30p1x}gyV_|bsfHVLKbzxs&kKkAHO6ROze~DmmwqcS7&|Ch|96X#vIb9qi270 zjXv8S!!DN0<Y|ywz_7xxr$P3q+~o#VS`3<&p@R5eZ^SlmVyDD+7_%T2*;br~L)#W0 zw2-@=D@u6aW(pvzmg`qQ!1SklK?pJ5W@Y3LDv6|RTJ|FXCeXx12`BcBYqgq@$T|9a z9ZNfs+9XlHQ{ON+$#VEYn1^~-1*U(Mqqa4v=rXxUKt-f`cz9(*+aOAvLX*hg-LMtF zhC5;1FEISjxwpI5Ei~k)dv)hC{Q3c#j?a$_3Ga2<vvQ_AV1_HV5wF7?572X9Wnegw z=FYz_pjXyjKt&@)V81=H3sikBP7&&QHCMmmn%<6SaULBpq%Ht-z!}q(7PNosTuE9$ z>pD%oqigkfD;pzohEcgz$wMiLv`zqqFL&iREf1~7*9Z2a8>#U*nH@-zk1RF1X`x=K z#S{YAdg$Ct)cB}!8Dcy3WeIsR{Toq-O^)Vg-e&<LUi<gRX!A!1k^UyFRs)tme+$Fy zG&`*?^G8yW;FI;Vdx+Tg5B+~LW1k?Fln$*pad756&uj9Ij}^QtdcYZ_6bHa&7M2}T zs>ypWPtMD(S1?$`Us1?n2XHdczCwGM;jxW^t&DJAT3k#5!=~G{$p^cU@PqW_f_Gpt zF|E&Y&u+}uQqOeNtgc=DZM`#P0m1-vW>>mTd1w1czw>)#(US=T7*T&5WS*7id~(if z9g+TBmicAAx`t0pEusxO4(7iI!JCi;82XhPo6DBft+f{dOY<m{Q(??>+b0QaOn7>? zt=`Ebtuuz_F#iBdcHDa2-q>+$iEf6_wSlMV4RG;^8IZ)TMR=CjupT)qqQxQ`6*-ns z%@vv8k@?2&)Ysb)kPm-?3<)XDm!@Aps!MZ;`7>H{Qp_kA{Iecfi!HIny>uHhr#gCU zZidCrW5{ehvcDcy-{3}{|J8^k6(k?DqvFnTo#KS2lY4E5Lrgf4U)sVL@hp?<Q1>`P z9H(Y6sG}|X(XpvgH_W(lY8;edcY;Nc55zhHgzkg7E=^K*C{}-pSEwEEaD;Fw!Kev1 ztgG>pO5|l0QQKr)dV||5Y-^a}Yt!Ep@byeBEyvXj!A2|My)wB<lf!I-$XZ+KI<dQz z1KC;)*|!DzW+t;Y3}}5t$kO7oW$DYv>?l9U0*ji9c3>(|T8q~Orj%u1l~L4V8CnXG z&H?3KowD8lSlWO0y^yH{k7{^e7=n0^tuqt5WTOBOWZ^uZ(N5VT08zwVcjudV<MS*x zaxeNk@q}4_${n+1q^=t7aI=q9rB+iP(h`OoeL=s-W=R_~1G_QulV9ojn$H!Sml4i5 ztp?mre8bLTDASRTV)UA^UX-W=*T=&(wMEP;g=xMj8x()Q&ey0l<=YaQR%pXefst1D z1?WD07z*0Yt3Z+$Sd=;auqlAbNB}Aea!9Ym-naxwPY_G>>u3yd%wZ3DNQM~)+9A7T z@tnDIX&_y*EiMyjrNbNbjR9|CnYhmk9d<*-H<|hCPJKhqHqtQ+sSslr?Ov72j!NS5 zb8teyO8<WVHsQ0;{GkQ1DxE`7aHYC1McjeynL0dV$X|C8MyEE#<O+qM7SZip-G*$h zdv)>TmR(1|$_f-i_PkD862O$cWkLumw_KPgMR`k1xxcu%(=CAY^+gfHq|g>qTp8v7 z1pfdFJVMC}w97?{%#p`^MO$e-DTh!VwA&d-@cVz%Mx>SBNZ!7qY&VH!Bv-8g>z2f9 z!DfvVa%zyPFCYUj1a%-(3^P8IgyYNZ*V>Y->OBaYkhJ}-!zjvXEaT0Z00|^2i8*XQ z+kC>@#k5iaDdTIY+P}M3RX!Yv114g2WF_e@|JI1W42?PMS`6n2+1+s=m=JzPBo552 zS>t~%6hv*kH^_s#j%7tk@mAddm*GSunfyjh0@#@n#BOK$#niJ8_hI-)%QEAzRBfh` zTjIGT3EdOF)0N77$lUI(`gNo6QSdc;WO&Lft-8|!x;?p}1O^|+2%gCjwRyo%S+*<J zAP()Ip)G*AmAR9|bRIb)>1Lj3S2yt8LvMd%13R!$3HXBw_snd77?N4zBpsg9VW-1s zZm~xKLR<%#V-!;7u4po3vhAS?2%aHDGQK8ZO1v2D*S;IIv^<q3t#Ja7P?CHt?U^v- z)Ktqc$t!@xS5(&<N{#-NWvW{nTjeG;HC{bI@yTPzU~P=Ci6#F4;2jhMFwH7W8Tfx? zG$Z6jWd3eVUzUq4(d~Yz0>;wwa4E`^6!y$X%FVaN8zXsud`4Boo);NO>5md=!r%{w zp}=I2jIvWANl{Tvjxa=uE+vf=R8-}WVIs0f!*VOLcE&>4g4!?%#k%1KWg~S+$>U6r zU_vt5$|flzsAf}D_Q`Ntjh1~8?<#*Zq7ZhcC;50~yj(|z_*`KVR>V>m-_5NggVdU1 zMgIULMq|YB7hpX&-b?w>tc(8uHJDMk+v(})Mh8xP^L3?-3w1fAW|9j%HalxIc|~QF zdB4-LjAc74qY8^1)NKB*IY!*>=jL-J61N&rqLa-T;?DaP=rYh)+F|9bL}`CIy_Ta& zt+SFq@FJNUgNvJPBSAw|og|uRp&U6gO;gvAH5J1YNZPlWbUSMsD=V8>gtA`R$8mS! z9lDH`DU;1JiyMYnAO_+y#p9tv@0O9fSS;qk!s52K(V&jTbPw?LE=Ku@GeCye#eS!& zygH?wq$Cp32AxOp<#?g#vXXyoc+$jIM#N4^+)3*f@Ctd;@WbM7Y^nd(iP)dr8h32w zi?zwISn!c^x7jARZ)SX3CEj4x&8sb)w$mo=&Cq4Iaq(_id9lm8``g1?iHoTHk+96D zzz#`GG%YPX2h?W<s7Iw8HY546oS8SI0lH&#QzF0f4!KZl#CD_`t5$!tp*!-)TO$$? za4OE59xlC4W057URosNUhG2{zaC?lDcVq<w@5>%qxV~suTff!YX4-_08s;viR3X;O zlZOeG??m$FkhJX@`LB7KiI4YOx*$2K&#jh0LC?db5!|ws1do<$%$$(1;^|HCwWTRy zJZqAS_dyB*CMVk`#>Rh?B!-Pl$c(mYAwlEbl;0=Bic<>Qx9N~GNDAU5BDJB)#M$ty zDpjjovNE(tA{1;WXma~%q+AJF9HMe=iZnvOYuCdm6Sh>R!Q6#Sjd#fc#G|dW*oyL$ zmOj`(v=WVp1WUy>9vNgsF)FM;70Iy*F2MBYa9dysWP9Ld+Btt49sDrc5*KQEytX<t z(%TQ;DEM6T70HQo9NcMQxo(Qm%(nV6-rg~5sngVQdsm>z@QB0Aw4YR{eJOM?JX)le zlWbPqocmO4GOR|M@*hP9gG*-mp0{@+RiL+#q>;VLZCu=mR6y!oY-4irZhN$Arf1$Y zV5mM=*38Kk$2EU;^>1Zy(YDN-VuOv7a*|$>WT#M{4qX<KtEVh&D;FOOYf?L7J=-8u z-Ii%cZ}(xVQ~7ZlCS`V|HQCaV(Snr&``_C1$JtzzYSzspRHd)_$1Uo>G7E1qv~~*= zVU)zYV?)xuIYhBpY~m{3QnZ$P$#olsE>w_=-Pq(c5&(Yy6Nq=GbIr$5wolq}!k)ni z%wZo%ZMXl>iP<SzWj)UM%}BdkhRVFJB1ikR4I>b-gV3{dt^v7Y5XN;~3gYKf^zCK( z)Z=T(`bLlZs(aHi;gCi<vLb?4eWuL>M&vCl{a-<3Cw6i6s5~;*&e-&}Zm>qSDG~}t z=zu=#*AstoB{52QtdI!$N}CUHm$s0ir9%c`Sn4@1wRgm_44VG{$lAEOj$JN4R>JXB z;&66ajls-+%*0<8Hva$sk|~hs-<5VC$1baI)5yED39{GR<_=!cJfa)pqTb6hwX}lT z6@nD>;Xy@F;hMQ*B<|*jfQYAWyC6#<ymZRa;zoZEs%#5wk{c?;0;PO0C5TX#IzP`Y zjJ9J-ObSoF9}#IvOJiNP$nmW%5U5_8<ZjthLfkWeMp+3{5)mTAc+h43D-IM?F)Ec( zKAf&b*~BId6;g@UwnK$@ofVM`9<--yoFi;Zku+Lu+bsct@X1c2B#J{#F)gv%17u^e z&UJtJ;iR?5^gEnLOLeC8rV3Wt*j_}|&-NOHzC#d8UQLzO^y(UvdTJlt+JM&-@E>Jz z%QEKTR|vJfZN7@jN4&XU0FE}VzS|5y;+D$|gUVyk&Wrha*Ybb8y3FEIPgO#88J=au zkPqgDi0u>R!GFBH^i!bk?sqwxTU(A!^p}5LMpQ`MPa*+1I2IBKG|@0(TfSZkNmv}D z6DLM+De}S<b&{56YHvV_et0I8O<;t8qV=Z#04#Bh*%FdERv46SPWf%_#4hyBLXN!$ z&l4t8&$O#(?R5B}Bu1cBnDnX5e9e~7lF58!)?&XVmbUIxFNk1Lzm8qQ=&q}8|IvSo zTkR5$Z04lw<ZQKaWNQgbU*ASKC;H32G1#b7v6M+`1iV6eqnwDnIUShfXU&mg{)1fV zPhm3F9X>|5QT)OEvnz_DW2#4UB?Vp}M#@*=9vOth^JAxyIX|=xWjAc9*8JDi3V28< z{>q&WH@z|=XRvt@r#?}#6IESm1$uwbAAHekPps3k(~nN++YwyCruEc6Jb_DmcdkUT z0eBs<DyJm{PliY-Qxd7#v^g#mTa>MK>T#qw)K;giBGqHlt_5vW9=N4zg)9_w!V-vv z)<3jZ)3D1U62z*rB2t5q>@v<9V!9P+9a*;DrUOAKqQ^=cm}w<SXCcAs?Sy|CWf-Z6 z!8GaPmRl-mm>uy=Db#f7hL+o{FrnV{#?sV}#Bw6E-v}0hR@+{za~ZdyqY%FI%VUb% ze3G;;<*Q3Bk*R8vZ#4HWMm`F58D>xbaV(MpyyqPEx^=aJ{j1rLB&YILt(0Kc+jc>< z208h+G*<dum4a-s4~0FHP-cHlmwDS>ZJ|#o(@>$gyKmVXLGWXnDVo{B#y2GoB6CoO zbJY8jh(wZ`$V*CU2n*C@&@ECxN`>U89XjQf#;I3M{_w^+inTGDAl#PHUdudbxdN5! zJaRl#kSwKeTRL(qKFQ+0MqO$Gv;iZQIVJlc#d18^(sAZH;c)iaeY<~<j0BRf@bJnp zLadd$Yl;X;5Guohk73^>d$x3c|I&)vo~z(9nJ&qEZNh#h>`E*ADTzp53=0f?n*ExR zzSYPRX;{{fwb8lhF<uNuagq}?Nd;}2P_!qi^uZyuvzXe@GsJ>-2fyQ&0<nc7e7zmB z-TscgBw60DQ73ee9glz0kjzb3Z^uSdUe63dDALzz!l8B)1OeAHS;;2dqyQT)^ugb* zW8Tc^c#NNHtb)ibo&6~41zRN?JV-L67p-w2sct^mn+-S(Cn;7`s{N{-`3YiVBvd4d za!DNq2*CuY2Xt{>z8P<XDT$IkIE<;!!!c$9aCf6!hUXzkZ_<AwY!sIWfT1I=Otb)k zs<E?*0C3tG7SfZ`zDP@=6DxR;C|kn-g|Q@}LFKaK_;+gLhM7ox@=dWQMqyL8(~|+4 zOCddSKrtnIP>s6cP~Z(Q3vCmYh&36wHFi7KEEw1srSnxIx{!(mjj8u#&<#>3uPuML z{{SP|p#JjVi&KBclV&z)*gp0+qjE>ZAJ%oC)U0+f)E>pyWdmWeWZq->o+Q8XWv-nY zRIGfM4{h^dq#`gfJ?$EcBml&2ZCcktSr6H}nUBma*^wFXOWf2&M*G%i`b77Byu)jI zSj>_+=~2*O$&dqGSi%I0=*F)kXPKS4w)D)n@^MczDzASYUCVEiK|l+qw3)6}gl;L5 zt3WiF>C$o1E^f}vH!dW5<x~2cjC%`1M{dU53G6CyGTkwb$PH7JW-ve2BQC}*s%Dqy z;z?za<;6kxLCEBwDLBGoBT{HV>UmS{%Bi*g(}}=-Hu+~X>_c}|2(CKDLHaNb=5gkU zJE-y4{rP_b+>$7`>^JR-3Kr}Xm1Ir1b;-khn_;-59NgNZeXhA+4V2wj$La&c#TXIT zuJ~%rFI#(ti9go#t1GVaLcGto6wMxPW-iYWw7@{!x@I4STkUa-CsRzA#>A8z?D6sl z4Raz8YLt`~Ha&I+woi$O3W*C9+n~pZp9=mY9kPEUqy~+$vE@zj25I57cwv+dBkZ2J zRHO?qy<mFNu<w!CD(ezbgjC?+VoN{=kf<9j9!g5GpdB)NEN!|2)2=IRh*+Ajpcx?+ zJW8lk5yvDpVq24eIc0*eG=iKW*KvS0-pVLdj@c_>s6Yo5T1j80Cjn)Q%G%~%3yMzb zPD+1~Xlvyr%Hdb3LNY)glqRRCEv%ke(va2VQO$k2<`)gbmQXZVcAFmvnEwDf5`IG{ z9n7-xc|=PVspnrXYDw|OYM)n#{{WOlHq7Zuf)S7Nnn2>2J;4p;of=DveLqaJ1J<`t zNcVpDaOJC!>dz)FAR-ZdV$)&r&yb$dC+B~6cEi_~#GcsVmx+*hCoDWXWIUGPMOVaa zn^DT0QSU>_4LVa}lMGT@$je)i-z|yV6}owC34H<xm#L+Hj$_FBoSO8aQrV3_8x6jU zi7k;EBwCfL-D(CHJ}szEfE{x9N`sFfa-`1Q)orb#g+}9c`Q>w!w?;G;NVS?Ge%ODn z5%W`x?6m&?|I~;?)RcdfwsU3Jg?ST{Y@Ns;WJbo>1G+oX^>hnuN))SGlMUhywCvee z5fb4pT%ZhWA=rZ%Wi_a%IVkPT#TRshyk&UQgGXKR$PL+x9F*;%N#qgp3eU84{%-@4 zy4}2YEoh{T-U(F4G<6b2di?PO8m50HH1+=g%gNe*DBAP*giOe%*c{YqwwZP~52PpZ zvjqhS8Hx*eP$?_*H9i?<5J48d>S&>P4e};sL_+@ntCGDoz$BUuNKe@-LF11RN~hI8 zKMvy?DB^6|)9m0+61pzHQyO4O;(%{V6pL|HBc}N(O{uB{=rI8#uU1i9Set)Tq5)p` zX^!hlvvui+zN~->6WpW-3mR{drU2VOB)?RGn^8ax<et>Y#}Ozqh)S-ec^bTk_8Faz z7y-q}lH0erx{QWXQQ?v}!C@SPCUD;RgX3N&zEvzO(afk^HshvB7^#HvTSVo-sjA^s zIoFgf2Uc8FBE7Vyn)ExTH7gq-Q`>BRqC&A2QTl@`)pbPE^b4u$Nf8K7c5Q}YVvWcK z%%iIp@(z^@P&>NR-8q(O_?`0oRIWgGFv2Jjiqdr7Q(L~fn{?&I4`4E>OVR_82=8oj z^L5wiwl-Y8Cu1S`49n0CZZ^7CLFPuqzeme-!}hpEUmV#P+^N?HR8rfBTZ)f=ZO^_~ z3kpj;I<qT(H4a!1kFvO@qY)HdL{}@AY=KRjDni75$R{dtc_f-T7kjDCTF_<LcA(LV zdoAC~EORFVPlVSPvzlBkW@2RJ@uAp!_Q~*u7XJYM)rGTB(D*Q}bA{PQnmqljyYG-m zMR{8GJ8e>H3y+G*_!W^4N}lw8$Y&m#>Kx1mq8CwuT^~}K!eCk;Nr8{@95~M$M405o zM$*|?PfD5}!!7|4babIp^dAG;6Uvgz#*Z(Wyu7#eiVnS|{_1$a{{BA4{cOdKPY!f` zlHJ3_A2xgBeJ*V?%a-<cXj0_W<d6AF9+`ojd{Qn}D3KKG!U>=o48}2kZsKpz0MrbT ziY>kXn)qPgTVAWS5D-gzH?9)4-yN|<v_%xqRDehx^ug5xp|q>?zJaBVis;29O7tTc z1|*`HxQ+T@0gwv}iZ`wrWTw{%N-jXjkctjz*(CUn!-gQ1N~v^C*x3uhx$eToS++rh z5>R3SMR%z@(lGsj_H#Rb9tzyJnR9dqV&#t}IgldblWXIF<vS0D4JtA_aDtp`hLW!w zYq&U46_G-U3{`dnHQyC=1}ew=(2~VHo}h$*3vdcNTWk{>5RuJO&T>22>Gl=g;H%6% zlvgpdvGrSKd2E__Xevh+@{mP6x@9?!sFuhNN<2j!z3s$NiXfqXr@6|HChS{>m40Sj zz2})?wu|C0#v@f7$<3~eaKg*1)!e5+A=c4tmfpor!xHm<_0L?p7W=j3JrdW`owVt0 zN#YfU8u$5SoOLQ|;M&;i@$i#jxjB;3sJ8OW5m=2F5NnYqcE~0&PaCPXQ&|FmAsGNS zw&HJP9Xi|6icmU#v)2woWf+A5gV#8#BT}CM`|?<qB3!o2Egk!{2+J>a6|?`=g-iX} zKP=}RlzF3KZc(ES^vD)c=7YOtOTg-A$^#FcObZ*f<21~*OC#nVF5BNh@m<@k4~Zad zr#1NUcvGYF$<HwojfM~j01d+mh6k}7j%<f34k=c(iYcytQW+o95|E+$2KUI!?P&td zJwNjA%bKo{EyVYXq6t=ts%ug^=Cf0pG7erGQj0l>kdAfhn1RSDp1giL_rq*>S}qFI z9=-7;AR+#-M&f@AuFEP4p12#6vCAV8Sejh3r8?w{3mugMf;Pw<lTPJDB=jAASkkbI zi@AKit(g*krqbZ@r(zFW$&M~4`LxK<Wdi;?sFrA07}yWL!yy<DcWk|^En^|u@vajZ zWgXP!g}4?6Z)~iQZGaM%Owx%IlgWHDA~9)^Ar%euAW@roWoHp3kjMZ+-fAACW+muP zT)!3?Hq)CejgSlB)aG9|4HE<;o<wbmLSq_*S^-RdSt@{WO5iuZmH469RjgIj#ZsRz z#wNFlGH4c6bMzbskw`nCHMaW3pl9tId}I5vohYDDs5{baqc<CPDYFna_pV}P=H*A} zMrv0g-s<vpJ5hMo#+ew$iJT7zd9A*j=4<aTUT;}SL0@k9u{kyo4j9MkGb~C+eM?eu z4SBVHK7rpd#X>G2^-VMLLPu|;+S@1q%4!GsE0o~%O51b#V@yKw*OM!;rFtmL;VW?j zcin!Tl##pu-nj|3VjHMw$_@S7{TU<3GKbWRw_sja#@uozlQ^Z`V53{xuluo^kI|FD z$0J-ncf;D0#`jv;|JR07k^$l~oDR<Ds#v{$VJZ7aaxx6&I-&2J8>3C=1#(er$H|m= z16fOLLA2|J{{Tk3*-7muW0*SpJVM9txteKFuJ-)p<)^p1lJ43){{Z9SHSp}qhe@8t z2~KQuZHgkRiUUAK#C535<MueuR`PznZnRjVa!iqqE=K!|)@Q;vi4JZ&#<x!e?-=-h zsXOnLjBNPU{bkqUt$nbJOX4ny`g#hQ3iQD&J1EGk*Glca#NZgIJ|J8q1b|P-W#S+; z882r7fE~^P6?AhI-n)#hPz=PEOM1+!zf1xNNwt_#`qi!8o-x1jwq!t2Y07OP(Xa0G z>$bO)6Bs`nXZ4Jpc;6&qm2ApeSA@iWt!_CM6{+o&!xi&qJfLzz{*$<&4t^8cCE$j? z7^tkV09@3$?SsQT8F--uGa8y~JaYJknMtV0{@gUzYN{!?7*LtP_7=UyEP^O5EZ2p_ zSJIfPu~w^!yA-L(BC3)|6vYIjx7TsV#y0qvpRzx$BsG)0p(S9&$vc2R_S++W5kinn zn@qGzpI1$V{{U=NevoqqF2&EsyigjJqxIcm>Kv*Nd}qfjglJkxy^!CSKAne@!bC#x zGy}%HP8}bsBMn)sY%9q+W85LQWIRctvGC0ORJsQbO&+DMFxOP`D%r>I!)~EZ(T*=h zJMUSJJeAy(`GF!^$5_A%3WeK$EW%41<9)w{EU-VlVd5xGTRu%Vd8t^aTf3U~I7!$t zj?yZyHyim^`G7bNCP42<4Pbw!S&pE+a(KqY6Xp{EYNAGBc0Dq?=-pdC|ImfPe|9`Z zbEVo$RFoo^$19Q0;zN0k%^+ze2dO5fCAB6YT!~<mIyD&xWMSCkKA=H=<j4{^JyT57 z?e(1=8-@ex3ZgkpKWKRSW}hZA3Ayo@lziBynEanTw4YwFTB&Nw0PKCUKNf2gTx^)= z?4IZr@LSlKZP24kLpt^i^2pDc0(Joa6W6{hilTf%xYDWtP5dzdSAY)m#D!iSl&^01 zDV4X*tWVvMq}xK_XhA=J5bKRCsmXO83J$o4DViNsh^aMGxEW(zsa<Kd_aox?WM8o$ z#?S(cR>syL31a^M<z;}|T$d70Uf5}ktt6egVGC<WByYYY4hls87!s@$@$kh_wz7f% z>dJS;kW3S%EZ5V+6?Q7qY&XSJOH`9xv9(zw#e37-;(}7E`{>Gll?`7Lga)G|o?C)m ztFfnCC55d?NU7^ywZTaVo`WqcnL2ThpT96Q19v|GxS({d7d~dbpN`NDHTT0aQ4FEE zDj%4UzN4qbX%l~>1{-(`vqD5kC61^W3JY}bmO5;40Y$ct;v;IX9rDslivT!77tzB> zdv$#vt4PL`?U&|%J5XuDH?qfdi@@{T)9o|U=ox-7L8~FVN@crf2PHHlW=9RUu{5iv zu`=@}ra@DXkZs0yYVvQZ>aj7|NTdBYfnkv}P`bRT`n|d-s6skrkQ)?8?h~UR^g-?o zGaj6dkN?qyL<I;L&KGepxF}X15&7iX7sC7`MjhAz^zDj&Q!ATSjzbLUQbD*L$G%ny zmS^M}M~cegcBqbL9+IA$cg$@;1c2X&7EtX`L(}!<Fo^}Y%EA+ZA)<~e{G%^vUhh#s zge6)Q{hqsFRbbdqxb8OV+X7aqNaNgONk|2HM_#)h40w~m)Q~?=I&{J$7?_~tiJGjV z;$7Hy<n4ujFBeFvfrT88vw$a)VsiBgwPDa>ve9w2l3m@n`wUFc;o6b6Ju+NKF<M|? zcE^bvE4?kZ>xC4vhWitWmBgqUo%}LPO6UQHa=;eehZKa`-O=C=T8|tJi^GG^*A-gR z!lsPd;<mBnUNz<L_~L?6%Z*~)8I&<<4&FFvtjQ{Wy1$ZBQpb;Bga!skCuVK?cw&oM zqPtgoRkrc)>xoEA^kM>BDC@Hil`{)d&Cb&uiVw=NBhRV0>Sy9|Ik^VReNgxJX)d`U zF6|Fr43vE_Q@KK2w2u?T7-F)w?-&h+WI~#<@=B!*VPaxoNhD+Q-z+<puqrHX%vNh> zZ*K^HVDe2@X6`++BMG*QqJtcq`ickg?Oe)>i~=IX$`v2TI{4&dqExLnr*&~Rs5jy8 z`y2yi+u}%LEVr?iH7ej#{PNSZTEX3u2iT6?a~aC${{a8egQIj|;hgDrk`|{T66CLK z{){0)#2n;_2F0B~`|zYaV^Z?PhN`d`Ns4rTjX{xnfHMGOAewS`0Zi=eqPDe5XoVt( zP*ivE%FMU2$|IucSLKbZDU|q)azL>muSNb|_;`Rv3i$2j=Adm;_J>S()52Bmxx~Rh z<U0}Llj34xRN;<eCpJ5eyCP)DF(~DcHV3Kj%E@jE*a7iV1+6xLo2bW$cvlG38<1Lm zlhj}`JrV%wgS^uOpBOYg1GY*THe)PSTu6d7O}PQP4!FdUSjvLWQ@&YDEn^?r!o;m7 z2n=m$CcSdd7?zug4e-(sw>M!Fl>(#3B*2umTemWLjKrS|kPT)_?KOF=+^9dgV_JAu z0gA?CBBj;th@v*fzf2q~xXK^&oUC_$ZaC6-l^<7-LeUdmUGb$d*#w~RHQkQ&#@Gc0 zomBjL4xdgcs7N4puR<^-YmmKn#cd69ar?GV2yfXq(k(Ia?eucrEL(<n$YI)}F||?j z+n1%D^q2W&<LXzs_pCrzn92uzvKdA2w?cc6l<8WF++Iy^Kqlg@E7T0PEGSZcAuZV! zi#U5}If2EVnF$IkV=D<KnrPA7-G0Obai@A_PA&FhGQhQa*Sfzf8jHknQ`)04;TnsJ zkouD3DhX5Gpd7iTo3|{F@-)SX36yA}YMr@)E0B{Yb7OauOSh-&zg)-zWJ{WOeL(<n ze%Hbe$X7AC)05KQ|I>ue34-E(SQ}##veVcc=SL@eQymvamiJb)x0MRS;F(+ef-*Cm zML<UA-cr5)022B$#KO|nLno|yGK27p6394Ni0<@|IWebB*@WF4mvdzte;kD}wUiqk zm{6zbvFLuBkSHNeNnJsXyJ4$hO$E?M{C9o3VX9>kt|el$qd&RDErQ&CDN0~0gQRk? zlhBU%0NqKT)H;NT{ov%7OjwDchM^k@qf(u+sbejX${qSa3i2!}J7mNdnT035FSs8M ze3*qXIV}q;;IUz_@0JL)N=gR!DcgKJEI6E(fVB^Ncoa=vkU1a}mg&e;3gnQ2xtdtZ zZ_sQqObSsm$i}9-<eG|qp7h@oEUhtC!?%vurLbGJD&ozv9@wjz;F?pW1w&kpP?7~m zHKr=I31p3Uoq_CeTWzLl?Ts<CJG(Kd>U!dYMw@aiZ{WB;WgFu89Iq^HC9@2;WC{aP zib1N~Nm>cbcJ8f=2c@!d^(2-PNo#W@vDwg(^HYZ)unTxB&qAqxw2@csD+bJL-%w6I zX>PWr<4U@WY%0Y48GH`m9PQ?}_YpnUB>O88SLQnA#v#O-f|QnI;%HfUDfbyFV@R7| z0|#NXclqQ|O*PPvuphE;pace^2_<Avvx+y*CkY^g(Pc;S%RlYJybp~@3;c4DKX~K= z=zsszB=e1thg%MRi$Qh;0RCcWoYpyP@8r%j+bC^>PeJPll?nL_#xf<cJ0rbz`zN<t zfr~p_v#9MujV-igUX|;OCfu(=IrQy-g#~P^M5BTDNW&PSQc^@L^*!=nFecSL6l1nZ zlp0#n%37h0*eQ65pq>31lUMM_kjza<?px9-d-XVJiIFOQXyc7*l3?LLcExR=NMVZF zyau3-L*<fGCDh~|Bc@0OxfCxlTO`GIJ@Hgz7b;VW8WFHK3m{fcKcs4{gdmg@)0ZZ@ zlZA;YJCJa(QelOOQZ=c?4Jii{w_0MAtZFyKR&u8JqS|-jMK{H5V>d3lVugnkEGt}6 z(=WhqDCX6F%!YOj+Zq6VjED@6G?Q7kb-0T5G4TQxMIIPN;+w%_-dwsL4R#JFMq_0b zFRNhMN;jy&JD7<3Y}`-Lk_VAkb0Oo=m-^MnCwPk?A0xJI0N4=X$xhm7KC=g6&Jj;- z%`nJp#XQv0>Q_=)6Y(bX`HJN++%k~eFNR7UXQLf|#!ZSW#w3KscHW~QbX5@=TP~d} zAb##Z8Nb&IH~aE7c&)a!fB)4q)bymjn$mWsJ6SnU_Zi9qPe7SfSQ0+fa=uK~a-rX@ z6}I`}wzRnJaim+CO6*QFwz&c2v8_CCg|*!1azPF(2*q96r=}=(!g_VUs%2E{MMe}j zs?vjh6=(_!RiXgg?~1hIBBh7IM}`o_&~o*ycFBeWDHg(p_c*HHK<av8s~L+DP8C~< zyW0vDFvSZ92Ngp}@Vz=>hWV})wT$DoD{Bec6g7$(#Vs6FtX0H(I20*-_wd1Nxv9D< zsSGPokmT8GY>bfn9VluJLYrkSsGm|tZ{8k%K(%lYLXjC+x0GtOzgF{2^|07V+|YRP z-!|Cg`=EQ8vWk)x@+oa3PD6{t!N5N{<&h#mffSf|hUe9}TeUS407>_tu2qY<GCfc} zrDGs=++@hz2)iqy(tyXH++-#cIVK_Ggr2Sl`Bu0qYh?cb|JIT)`5fe)g<`V<w)hf% zrz@~EKZZy#Dkf&_hM3!nG5T<=uR_L^>4K7ouKQxESeoLl4TUSrfUPm4TS`Mz6G~vD zyKc3h#S09v_~E2mUZS4((pF0?C<gVy0}@mVw&S)+nA*pwr+~tSf>#x$j@>a;q}Q$# zHHBNit83p1m89aut*|PLs2J~s2x@PCg$qu{6?a@!)WuxOL0naCD%CMp5w-<bMn#lL z!{d=HjsTte;sQxrEB!D9<X8`%EhV?}4UVFN;gBk^_fwg=Nn9lq<obbC{KSGay0N-d zqEBwT>C&r?idG0*@vYeX+;STs6)Wg~!2bY}*>pB7QV1ks6#lHfLv}64Hy2iauY8Q6 zsl1l_E~Dv5P_REGE&MW&TTEa7*FP?Elc%7zxiIO|4I<F;yK=2|#*j*jOrX%=rZ$O4 z)qrjBWQahDwES=~ZHf)6iVB;s9q@pQQ)%gv0ZCKlO9P7=RyPPYBYXvwsY!FnqLdgw zU}U3FlB8QeuX08-g^c7eAnk^Km9>za&NQJ?swur|fh&L?ED5%y;-g`NAgETm;bQIa zS8s||F{ap1Ru>hf6gN<MU{yC@D~LiE^}>au8se^}--@;d_^VCfimhd?3st;)aYzpN zU?pjyHt@hw7Vz7y*%r_Xhqn0xCea6V+rAK35SMHEI_h@NL>MHHKQP39ZSu*-HwoDz z89dNFY|$jt^dy2a{*!7e7rxt!8hqR{v(>jGEHM2Tt;vE5kAhhulG0T_vg^9$$2P#o za&ds$bb%e?k)eIc60P$FnUZ=IvI}H&@W6!bd(K*IwA_9q4wd<-U*nj-dWdW?<o^Kw z&_0%U<4wg_iVWo*gazn-sl{1^cB#NZx?u~D0*ZTK77%w(Yg8tn(+#lUR!C@@w!<4_ z1AdwDt|?gP!1!T7Qgt=yTv(|UAoR&GR^#x4OpT?cLr$35R2g^41_U%l6fGkPwZ^+* zwC)r*+S90e;;#B|DyJqXzA99JsiqV-^{0j@4O10CPkV10kkSEvl44f}SEB~xr=i6T z1dLUt-xMj31}e1Tt4=EI@Fmr*D$&Ri&M~)AxHy^kON1&iJWR$m%t}QET$Sv=Lv@~s zu14Dq%S3b-1Z@(XEN&<$KKzL_vutB~tp>SSZFfVq+jYf57xNF77FCk!#duzZ60JXI zuUyQ-o{dYBOyPWgm=>=KCGV`LR7`m~j-mP-+Txd|cM?E?w(i<nSt%lrkM>;jHO4aq zW+`4KXVYhho#HCP{gVNaG4%qDkN?pf6r<GjXFS{XRdbPgG{y#=Q|dDCkFuWmFvPDY zYE3KQg$`723~gy~jj4jxmn3aU9=O_RvZj@;3tCth15m$zPBy!=(Z?qB;(hR1ctesn zx-qF8u-Rk66&5(V?mRG~gdpz`R!@ha87P(#LbgeZYl^RWQ>AfAm{uojP`E~EQ;n@P z>G!5Kw6xSUDYg_e2wvTBR}js(2MUl^CA+!f$RIm2w_tkYWOij3n@V-qk(O41-BR8m z#7q3dbtfu+f!)Z-8VP2DB%Z6rD$!D-z-B;kW}V?{YM7;R!qN?eSMkAXMYCl80LXlB zaWddTI7RP5cBUFdDM;K0#YOig8d^h1eQcXFgMSPnDxh^(K_OL``|XK?AtfWNN(~W0 zf%M=I47^T_{+5)lUH({@1c{Y1s@&9`-ZZ8T0K8>?LGSL#qOr+{h8$&0rql_q#Z%#u z*dubHPNK{dbNB6#A}qwDh=gCLp$vYUtcbG&qix#rqba6UAr{dhiDWe8$8nW5vHfR5 zhE{us09%fv9*vn;vyrn*pjJtx+uv!z+Tj48>Ux9ihsEKYh2RnlYCkJp#d9@-AKhF5 z>C66qO0Z%+<;>Hn1aWl@J`uWq|I(C4V+=qvagF+IoPaMzLgpQarc4$jI29OeTjaPC zZs(1v((hgN2LVW0-7qTNB=3q{P!CKj8YZV;FrjfKoiN%dMtHf?ZkU3C6n?HvcF9^e zR(f1}VYG0z0X+sbye*iReK;*N;S*OIT2&E$_~5kDPYRLmgcUJUTV<_lz7kmQq7{03 zWC>=~&9dA;(KTq@$6_!t2@b32S;E%SHCdNnJ@7_5vy`k-O=gb>TZ<xQjkcOE+7!%z zCmK<<QE6vua|oI?ARem5lsN-5j7>~zElCm(4`J6EB|HEi?ltRp*MuojJM_XLO4td1 zb;p=)%8~G*{y8xQCRUQmsYqCm)%Wj~2(Up*e(X;oN${>qg#~PLD;lvQ45PA!#Hn4X zawuOJC8!h+d;~HGl?of<YL9GzXK0j$+>jQmP6DX7w8Gs;QS-sXLm?2T`f3IRrl9sY z10u_WHwBDbj~3?NJcNm77^0RUhjgHS`Q!;?092vvx6dM#Ib-;Fefbi|C6|Z=YBtG* zB?zL1m8d^t<iTLIkqavyw6&+xwkd!A)0mm~np0!nIQ|!=;tLv{rrW8+F{^3RDXAGN zUKKLpoiVkhfhVBAwBHhX<7xudUG~P)O;@%kab58R1-et`hS1k6R+=qbHni4%6jMx~ zjm9fY1p}bP0j54ZaZ5&k5={W_iYakM3Lc*c>0DLYZ{jfu>I^E~2peEk4Np)u7JjfF zyeEoyS0n|i1JtCtnpT=pSh=en{{SJDflLagmh}7E%}l&*VS?Xxr$g<INdOiRC6+mk zC-}BI)kaare4DsZonq?L@NPkWNa`|p?S#@Ri}C*HQNMGSQ&L|%?rP587lv2^8$rFs zPM8@X%*|eu$*?kXb9{T$;~}zwyh_yvrE)aLBI0QXBDMFyOu$z7!4+Sp1u0YyFdH73 z6H3&VGx6Py{i}^4%8#it9;E#w<3TIq8iz04as{>=Pkf01Ae3rBt;Q68NU~SPwaQI4 zi)2=wzXTT};-=KfXHBtc_>Me9Yh10G25WcfKI|b>UctD0Jx)jzA&sTZ$;acb;fB@! z)R_x<H^gc@GmheVZVlWNEq3}cCQZC5a?i(O*AP%pk0noBlNWlWPmUHV1;m)rh2FJ# z>~JdJLB&^f-wms3d~vmZaZ!pXt~hNDp$4AS#@3d#1#6N82W7NkNL|Pri1x#5TZAbM zESpCj?8gBtfKr;`+6d#Cev+TvN8I27)a;1$5=BNWUVq(ziWIIVwt{3y4PEx2@xl<S z(7Grs_K)oNVFuKOhAT-onx%+YEG&O|%ozu_j!p<HVXH&j<$;BNr<x7jzMrS0aKz7X zGm|0t<}OH?7Wfbo8GZ$3<H?U-_VO3p&X_Ku?ALPFrz1r@vo{|n8hp?bPO+)ux#^b1 zEpwG1q`Y#Z6&3Nzgj!`ZGf2mJ(<Q>zN}X%eoHWIHHy)UlR=s8;t_~FOBCY3l?ef9G z*%_je>P6ef#8(=BXK3qPRg|b`)zx+&fzv6R5(;MqrKL>Odv#V`n`ShlF80XCCQzL% zw~11%+Jhl-Z1lP9Z8=xc3OiFGW?2XZ%78el75n%5@LE7?eYf0u;Iz4mG2idOO0koD z#c`x8;6U37xTx8`XklA_|J9yOV++Qc_~#BS>&OTx9f{k2wojV`B+?Sw_Q`-4NTHfq zow8KM-O9V-uI4dAbsh1gT(VOID&ai_G^&xXZk=&eU^{ohuI*o=6{Hk1gQ(nda3Y03 zsXfnZG6;)TSK&CVy7QHX!bsRx7{rEBAx7&_v$8`UswKW^dZl*&pV`Uq9%Zb|bW83v zS>{Ns0ur2mxk*pj@EAw~bQ>+o?p(a*)Si?<*%!kgCX*HRcb<f1Sz5C(;{u-E`63a? zbPnxt>OwtfzixK^?FYAx5H?~_>#Pn@+d&&lzwz;Kd!O=hA}o&SgS=5%{blH=0b^d& z#B)j5FdNH@tzQ0TjY|atigZ;S^5H?Nni<eU0OeJGUZ*60jnHmQ#lxsOA*f$$qdvi& zGi5%$zltbd;sB_n7kr~0BW>nlm<FkGvs}o^D83#!vBwY`NtCK-c?u}4GbSS1avMXl zZ<A<fDJN`JmwGZ{o)Yg&j-vwbl~sh1P(Z@Vg;v@oGIgn^Yz3S`HEb>zRYguf$Tk?4 z2q!{+NmI8`i72AbBsWqrvlLpSGRhrFv8eFK5Xcnv%6rYKS&;SR+cBpQ<U&O?5u*WC zKP;)T-N97t_~2Hb#=Sf+g<H88q15<sRg@T5zyH>ky~if_j&Xc1TWEP_AG3r-fsztg zdHnKVHIkxs!iKqy*rnWn#aj+GrE7{8*8*FAt|(QEFd*P8fJ7vrO<2$lr1ZgTfJ^la zdf8!+z-sBVdvzziTL`sINIxy<D<GFs)g6VRHz5^wmQM<JWV2)gV8a|p+@+pj*5qr( zOK;tRD0u$J>;`6Oux(|lHcjX+(e$RMc}K*<n-=Ss`kcb(Hetqjl-@XEO0e6-vtKEH zlH~9quGv<sI-QrqoSre>#z6@cnidjF#oMs#GT#%RU?FIkE|<~t75KzIWp=_MLfBCH z*@yuP5C=du%OkrIvs%?9lEw=NmFnBb-D-cPPaZogCq0oiclth;<=J?U*HhJHCY*_< z>cK2$i6HF5Zmyo{UNH(O{gIZ{G}?22pj!G9hI3Y7N)58C+qPrb9epY>m16^)4=jWu z-rYGci@r@G6XU%zIuznapj;ZYHUsvu@YGam?^<Hk*lJ?7sH|z!@9&CjqDVWFijv^% z<BIVr!Eo`)tQdh`qX7www`G=6!y+#_Z~IBt?#d(_=#fc+S*O}+I=A1InH1B1ZKGH? zp-uYKWfGZ30A8A1tY_ee4}Q4=GRQ<Kgeo|Vz{c%rl~qUpoQY)98st{g+M^2FRUP)i z5U|RFz8Yi1r(cS>Dn-Bl*OVo@O4J6Y+c>FY;d;2hozU}JOB<CcK^?Mj$OvFyZ9t;~ zLZly-O2>LQOF|i4hY|Es0g&&1UJ|)&Zb<@^Qg=QW_@R6`F1fI}w*!*$M*XaeGYKYG zh*09*6z!9?A~fJg*zby}qVhDSTv8DcqmxP#$L^*;?$44}tzMA6nz$<406XQ89qm$= za#aPZGbd!NPcHt$Bxb-x7d7S0)XItvqZZ2;@yP52vZ7R*Mz^)Mmh#|#da^O72eoqF z&5~$xLBasOs9~%&=sp!74rd^7+aV=%)PlyKo=36Bcucs|mPsX$vzpiL$p`?(?HYle zEr<#`oUf5em!%o02B3F2ZtQkbxo8jsMJ~ssJaWiJMXquIPD>q9OLvk%>HIWb5Dr`7 zN04LTmOnj==^$6BW3V27BPC(3krCVdSpY@&ySH3zQ$0K}49ESBRgtm?JNfAHj$vAx z5#J02rbUm<B2~Ex*SO4xhM7nu%}`GJ9+`M*Dom?g@U<3*QHrz&aB)>s+*F>PII;m> z(~rXz&JgcG4&uKoQNoLfokH*=9+j;y7Gg=&uBmepDPhPBtCUKA9nGR;H?`C)Zb_jj zARCU^k2nz6CJHSJjr(OJG6md;VtQjpwdu&<q}xOf9BFMu!8`ZHl}2~t8L!&LBXmoW zTxKNDgR#t>9h`0?N31;`Q^U7>tA}=cTR;EM=&Nl7xm5|oor`<toaD%5yV}42j$25K ze5x6Tat2qq3}r2U`6%7VY={q}jr_y`wi}TMj8a`f>cEm9L(adlGWbWSWKh+Sj}{z3 z=yL9CY^mQ*I>xGBnhmzeV}&rBkTO@Tb0XRb)l$`_<n_kUNQlD5L(D?PNF2$hToze@ zNv63qrJkNFt;jP;N0q_oy?XoPVrZgAW)6|bcXT9^;e`!<B9Fe^^3y77q{8`<0D-nF zK%ne0vARkw*hE+T9YTTDMi>hqlxz3oOC6d5&fgO^YGzKK7^5bvvo}c^!m4XCRl6=( z#K1>%P~sF3xbPVpwslI2a8&>cf|F|BTcmrM{{WIOa4C`!;`N75g=t!N=0-RY<K&ex zPbz5)(Tz%f>{Z)nBRvB6(*{{q!Ltl+a*ftfk-vvr#)#d%M`VSBZb*=Pj$YG98=F$6 zlvQ9lsL6uYD6NK4QjwZC>Zfc3S_G*BeQdL_0Pvf*J@VM>#zxX>h*~P7gNf-(v=*rv zOQl+L*yO=!P-feHxg~I6PkdFb`wE;@5fW2o7`8-zQl~Jcm{~7bMg*%w%Gi|&m4rrL zhmioC$6d0i8e|j8OBf+yR-~S!<}~Eak-^F)+mC#vt(Y{>lo5#t5?-?f>NdelxSVE) z95lR3WR#G2is1oFlnJTbYlfH-(!mcR+;_&&N?w@dPCFBmy_i<-|IwXIsJ+B18?VF; z&EuSZA;R^-bieDfM`Y4}yHwDxwo-9|TkmALStJCQ$p!u<spxX(zy`A-h_q=0wP3AL zx6dPxCI`Jvd!};A$^%!Z+YB-6vYjQI3dnf;L#|{2vyUTd#Bw&pdiTWuFA(qriK3{h zpJ9QOB$~#Vaj99Gx##-43V+d|8x{lhj$~nf&M}@>Za!EU_2k8-pHPg5q>=p^^{F+d zxy<=uhCyTMaYW2c{FybZ_K;e|Bd~UGI0f<dIjcD3#Z=~T3fQJL_7F})*A2~>RRB8- zsLGDbf;JN>TiRKda1loAdtl-u&`kgs9>1+5*Nr?>U<VZ=z-Gf9A|pk`l<e3*+sv1L zOe*7<=nmN;I6{ME9`S{s4My-8P?fVGMUNKvj>9EuN)k;NVtLU<TNo18DFBJ`{*yMZ zd>%@zoCdP7?@!qol{pM>ui|TfGnMpuTzoi%RSKYSP&_kOcjo4elHFLvUJ`5=QvkbN zsUVgv3`gw#%VpNmLW2Sig?nLQm*1>^JsD_!opw1mu#yShRZt1_ld=7wmeW<F`iYg~ zsU)cA4{Vreos_(dsnFqTQgk^mRZ5=_86^mW;DGE-Er2f$2oyVvS+-yKg+>){Q(uE~ z)|jPU7FR02W&8W!Dxc|gvP9manpdD1OxeUI2{0y|GZs;2Yo1V6m;w~T1=_fOX@#lD zJE@@DVG%)+&#W^V9G40>it#L+4^M0qo+ZLkyB+c2UM0d%-98q`n6eXg^`&v8fB({% zG*mp0@=n>s#Jy1qqUI|ZKMQpyAvm@|T?(ww#H&&ZF%%8kDTy}PX-P#P98bqwoP!a` zLEu-Q-=BQ4C9Xkd)Z|8?6!*!035GlbDk=~+8~Ef&WuHy)0b!AHd_1lz7zhBjKW znu{L2noG~znF`y)p$k@&11&prXd9u`w9&>FcA7D@K-=%b7|WPMxOo+lNn>tEgqa|7 z_B*M}o(W8EvsVxWSZgBEAY+gR{ggchW5-TsCexOJPpN7;rRx@Xo6~K7y)pK4E*N1C zdyOoHNo^xV;M-HE#DaHZ2vmW%FmxMsG|J`X*#u~vrl@1YjluEC=myKR53Ne4<23-# zgO<l17bdw*kkdal_<qlZXX3}gH*u#c<j*{m{{ZeY)7Wt)pnn|KYE8=A>>o=c28<8m zl(v+IQo3m*c5V1nFNWBE#4wRUS-G5;+!sd}WwRLWqbzAssN8_<++m6e2I>}N@NXWu zU@RpRs(7VCUZ3SI<1Y;~gn0q@WB`xm87g2<lG>vZSEr5^vlJY*&b#$r93g><7{qy3 z!z2oY>*>(;IWXD-Mj_P>{`gYTyC%)k-vX)uQOOTZqZFogQLK@Fsi63GJ~<gA6!Iq& zr&<iChCr%`17rwEEiCl*86FlSyVO>0w86r`V;$>Xy9f+PStPN>IXM7ggdF<Kx)0sf zz3}j|33Qsp)4f%xmH?Vx2DNn@m2=2oF;vI&^|6c8spLARe6N8I|I?VDDhT{Dj4x6N zUCVv(r6jIt<l+>6cEDr1F)f{J%F=?unF)=VK&#Ibe5y7*jzAW2Qk7ekPh6EPtTC22 zZHiIm$ksh0)*`-}f__<c52>e7^dbDa<u9&Ax4M-@yPi?njjB9bCzn4C!<$|#974|~ zQu<|rS-fytMhc^POT=tx<CudaubY!BaRX)ntqUY1{a?|4d8k1<3a-o!Y@x(Og5cyM zxhAo(n&$U~v|{3$@NI$i2Qy6WH!n+#!ful*YfA;3MSjm<>h*Z!L&BaF%xU8_aXd}2 ztKB#AKbE|yKAH9HBIYHYAPc81qrTZjDP#tSEtw%2*@AA7l~J%gM|{mVWLgBnLQ*6R z)|;rpH2vv+@oSRVR#_aof(Ko)_{tC)HIQUn6fWa$e4<%NEQA1Mo<q4cxP`Z?4mpjS zZsh6DMpt<$NLfePU~-b)$7dh7V0<ZGo)Fh0$5~Nlq{rw{PXIX%xPfv+jYWt<s8^E$ zI?&Y6*D?$oc*$5_5I3kN+PLsD6Ijn}ASS;N{#~no^kE8VExnJA#H(MIOtLLf2mt+Q z*SI-tFe^-y>-2+-F;S5sew!Z*AR~nfExRV`xXA`V4^j`lD!pVq4&%cWY)H{Cc=q<i z2)H$I8+=Kz8<FFHg-xyR?WNrwg!NOAF=c4MH3me*c$zYk)2F^hnA*pcM&*Kz<m1AE zg`Zh}f+~pr0C@gAhCB;NRmJ?XRI#tH>4%1sN@`0`9=KJqF++!c!xXpr{Bc`<|J0eB z<6fm{>x#HkpW}_H@PsvMkSvDEiCH89y9|S6+9_Z~JBs}BHkxikP7Og!ZBQ2$vED*$ zr2(U22_Nd3<bkc!ixirHL`5HER0RXD9kK_1y^z>a=z(cecdtwe$2`HNS?ikRzLRdB z+#Yw=1E3vv*T*uj$m4~$SwNX&Z{_U}+DB@(;yB_Ijnm<9e0dD3^9K9dJRw^ox}TG8 zW)-%+5)Oqf03?qJ=1whKM=;YpIVnnhRL3lyrQO=7{{Vf$Hp`D`R=_YCOIdySS7erd za<#qGhG_mILN-3bBs)q&m`TwN_BW~dVofGUT4X+y%T#{@++mihjBLbV;VE^&R0I+5 z{{UQK30V}fL&%yPyJhj&q?G%~lN7y_dj|S4yzTmaY~Jky1%KuruQ23Xkdod<MRe{3 zN70juRFN9RWzuaPyA<E&Oq7149#1fTx@SqHkCwrS%-}X~$=#u;T!tSV1vvc}cvjo} zAu5nWfcsM(6k4KdE`+qCkand$9C#UVuY0Q*Bj#gZJ33(qB}B*SFDPHs9v#L=7AhpL z`$C5epsod}?TQqofgDwO;Yt;vb?PcSMk!jP8u2yp7^_>8imp{7ak23jl~r4Ro22_Q zHr_ZXG>DH?y#kEfhrR<8QiPWi%U*0~e(W@~)OOQMM+N#$MG1sj7%N$1JN6k9Bo%+r zaRbF%?a-VAsuFuwg@_`f`LPL85tGnYz7=YnyVjTx{{a8hndcbwF5A8-4S)c^Q%!^8 zf|Yz=2B<g-D65I8M%$kG9u{1GO6eN4Os!w#10qrv`Y{dHu148A_+YiAUr72)dGL^e zBX0`#!W!i|Tcp_9*u>F6@k-~M(>L03eezQzh7kcA%%D`|Ov$AQE-BM2j95z|noQ$! zmQW3RMRy|G*JIZJP)b+kYY~5=NgW5P2@e<2a!{8EWg}vqU#wY4Z*cH`O&M3?_tQ2Q z$u2C|{{Yz{qJwdn0idGvtI=nuMLl@N4J7#-83l$=of>*<t|YXH)YVu5r`YBJ6z7D# z;u9lObfp^%4Ka|}4Lsqr!D<KZAvCYC>60AV&nKN~_U{GssNZU}KTc-A?BYyrm$1tN z2AY%r4e+Y0I&PtKc-pUjC#LJ|hl~V>nKc<`Lqj^4-Az86D5_jk5?Usqr|g~aP*CFf z=|Nd(I*y!$2*QhB1>7=iUM>8m8dO)n<G}X7mAkKMj4H9MZRB9wXx;-A3R2t1roC77 zVwE3=ZaQF9!j1FARbY%fioB^@ZBsZjh{N-k^QT1}uo)p7k;XoM%FqmfLi(Htbp<*f zn8=hg&;my-!R&FgrO#tLY|<+14MFz9YOU#^hwG}KP*K;!_BbtH|JIr38ubp_z5=cx zY2Of)NaE!}PTm*_s*7-@y|7v>Q33%>QEQ{8s4MnIOcFaRZqW|BNGf{nMp7JsCgUni zdQ+|*1RRYP1e4f*WP?PDXxt4(NsYA9yp2}+FbIH?GFrlg>c@#%Y;Y>{Jv@41kgjb( zh0(S$0yp=_TQKv_KWR5G-2G?}(Zv!eABA&arjVwL(O@Tz<FGpn%nm6-N`x^UIU&i9 zSEJ~6ey)jZ38+=2d?}bYKBr-e>0hP8ay1L6-*~|FB5weHdt`EM6AozuPbIzdyiHU& z1y8<YR?avyQlB^JawDdom1vv*>OW-Uu+O6$(dx#C9sc~t1)#6DMdZ#tHv)*h=N>9Z z4?xkptaSY>T*~L`a}I1gE4?!sd2G3Ov515v-kD)@UsgYfy0FPcL@CA#9Mn9gLR!{R z4R#%}_~vMTmI2WryR+0SOM38^QTuC`f*J@ZZy-;?NIgh99FQy(pu3(U)~n!gq}n<i zPTlq`#j-psxREQxhbY^Zz6DfSNZE!ZDstJRwP@dq_Q0rFBAOvrUY%WfVy(kTMPoH1 zdSEMQ_Mah^+!OZKV_b<rM1`kMyCZ7g4}1j$bv!bEC~l9~;;X7a+|r$KRUfR_`kz+? zdi+Q_SH$4qO?7|&*O>r(Yh2@P>8(?Ty)YHxRE{8en&3|oDN-=Br^^Dh#8<8wRlIk_ z4bakn(DpbCZ6LPN%s7T1`;3nTGU8}Rpze0VO?*x>pxVC=hCC>>$vkbfY4gUBYJ!KM z2jzl)nF6>2+*1P5@|`Gm#Vv*iDth+Dk_so+GEs)~?U1HclX;fS?%^y{G%piJH?bS3 zIes})84C{@y|J1JST?|KUux#eOd4>d4bX<As(R$uGH)f`x2r9SAB6mIgZSn)c)2=x zbY_-7b87Xb`Hl^_EJId^MX1cQ+m%PtnJ_nh7Zzi5%*74Gr=s*K$^jnvNHlhCtLDHp z%{8oUeL7GvJNS&v$14sdNKsPItr~r1B;*wV1-<v_lF7-82Jc|hC5|mQgAcZ$RDBt* zt1flWV4l6iE@|;7Jq~4u97)E1Q1Vs*yP^IWIt+n8-Cik}$3f1Ut9QwO*Ew`bZ*+Tq zE3!`?N@3l4Wr9@HV`&B6opoqv>*_`P<1LJ>u2G}|$5!N|Fz_96OdS=}@cox7jV961 z>nKMT6+SpPQ^bvfQH80+z&*wT6xumuNdWy_vJ{mGtSWt252Y`*z~ZV0UAeJvvcOOC z9>?W?MM+LH-FHsEA!3u$o4haZpP-z7grQ7rGU#{P=a34wVeg8rsHJ*fRJ&T$7f-Tj z+=X}eVpHST<lzn?yOgI)7VG4VV^DA2YR@bzmjv$}CPP3!${!9wcjAHkE#e-7-v0pq z(3?<p+uJz0dPQYGQm3vd3W|yaR8s||a4k`UCB?wpjfN;Cpz3$71yhoGRAB*siISHx zNF!=wnA?1bC%teXaE#Kp+O)ATY7w?Jw678bZut|sQ7b4Te2p^&sU`$`IMUl($pe-s zZYfGQr$K^}3bMZ<MFCd(N_rd;8!`_+^LB$JmC%t=wv&=kyN~itWa7!Un+e5&+*_FB zE8*WZ5tX=-6j>IZpk7&6Y2IRgg!;D9xsK%rRNj^OWirSOg4v1$5JNJFktrbETd&iY zfK!`nV^CGQzgGJsKc?z?`{qmyMaGaOL3DE@Hxh>&RX**q95&cZA$8q7H7H<F{oyrN z_V>x-mkVA&#Kv^rM&DgUZ-tSm^o+@t@o0|HWu9x+<%)O`A{h#DKKo^V7@>UIb4-RU z{ve7+GBQz5QJDi7ar{874gICTqM8%X90U-|NEI2Ngv!k8R_xhelAX%lN;~~03NDh{ ztJPO=#;656LCFBbl!E5bU5vKpguU0X%Ry_9LCQ&ugsDdzvSESbg9e_&WQzd3Wo7Ar zgo2B-)aJKpgUNfT$e6N!5>0LO<vU2VAC5r9JV@WHSzW|AFd*~?s5u?dlWKZ+hEU&W zPuX#?A9hYBy#y;*`GZxnWlmpNgP~U4fc7~VKonYYZ|2=HL&ijSr0_e8sN*Yc)V$6h zn%dgZ5pzA!q(~2Q(+qKUuQo`YRMO(pKELKU)_H)bclcwDzp#sc-kE{pN>iwEc9F#x zM$64&7Ky6No~ivCH0$5t;H(c%qc%p&NAsK!F~#Kn0RPdSO&Wy-M^3rLThgrcs6eGg z6k6pk)kfIT+hRwd+omBLOG`Q1wi`iNB^PDe4XT-`!i#V_VueVU3gk*c?td3uuoYg? zs?gU0ZA{#q&PtJgYlX#jtuRt;Rar>uz5*84s|xRek_zxeYA_{L2&it@EtpigwYR%~ z$8fxm?mdsTLz5?sYOqHg+|f7LMqyu;ZE<pNvIb4$J!&<ET?+Juw7MU<<<L3pl;?+5 z%roeYx|MAvNP0$G$4?Iogf|W5NNH`y*FoYp8=rB68wrqqqLMWWT?tf>Lve3>xN{9M zI}}yCmD8h;e#+C|Yy^6#Bk8s!sDv8Dw2CP1#FOJ(%a79HOPhMM%+e}8{+b$N0JE0N zM9k{M6H#8-lH)5wF^z#0E8JvAWF<+T*JcPASV%ipE2i5@r`Dx*c-Zi6gk_=(4AceP zb`L;ubc<+zzada-?~2<@RpTnuJ_o)Or8gF@9awxz<C6uf#Y8vbg^KO5S9IKU#D%qZ zu30_2Mn{DpqQ&r%BOH}#^vcW`T8L58p~(`4%CQt8tB8`2oc>(tX(p{MwR)rKwhDbE zZtOE3Svrph=3JdDXz>*vHF=tSTJ73qKUY@$%pm-KDl6fV)8P>tb85?w$g$6_CoUX5 z4*ofj!oq|9(w@j<E61tMBt0<;_%9K+PPrxy-?vOvs*{CQ6WbIivE+BauAr}vwk0&; z0rtX%isGvosB&ycT9R_ujA;w1JTN7~gwvtGt_@CyZyYqLi0lsggVP4gZCWO89kL`d z1r&#W;rGZhw(qXi&q|)@%>+o)C&cu@0dl59(0IM<ts=aSRxV31`R3GYTp~){E@_hD zHcHku3>(0MhF}m;32(DC^BRf7S4c{v;5a|!fxcp5AQv)Q$7fpIEycRABh*p9%yq<q z3p=odwM3RVqhVSx-`M1(wJ?nvL0Z2H3xq#^u*3nDNN&PBn9bt>ty))bLHqM2KTC{x zt<^{G*W_!A7|n^wILQ9Q=16E-j8539BGPUz!M9fF>~bVCi9_m^z&9~X&s>%=vI$EP z39eYde0L0jmFt0Rv3V?D>>K$>?UJRnqVd^i)ZZFgQIm^rN*t3Fn5(LBR_ldZ2k6Lu zkgBOM6eoO%2?)sKSL3}8YGg<(tSVY+lS86OXs3s%I|&>4hirtP41sOqJT#JC%pO4u zV|D6tA|a@nQ8vsdIS)|&&Pv!@|I_G8>PPVn!ST)<7pN;*LQehiZEFmaI8d>i_QHjv zo~Hu14)~OY!8nALh&}MJSjLp;imA4L=A$OTWk*`m6<amKM??03!%S^;oO0?{UshA; zMh7{YzZ?WpIF2P*^}QJ`O{$(T#Y7L(8h64mk}W4729Xh)8Am9kR`&<^WK2k9V763- z_*<1SG{CDxB`NLKB3Z?adG}XQZ622kRitin(Doe$U8jcj!zB7J3Du=#iaw-&&<xH8 zYiRO%f0sO&rdjEbeQ|kQe|;;RsZsWRjMrt)E7{71FalZT-!N!#TS2ASkuUEkWu<tN zlf{lla<a+>$pw_D>d6&(30r0#c3mN|AX5uu`n2=L<na_EAtPWjh@w)t90j{oKebwX z^6sq{L5%86O=ilOlMY3o^HkM;%_XSprv^4NVeZ_WiRw;dhK9Z&LyD@#<TAJ4iA|## z)$VPHKq*~`@ydj$wyg(0mNgKfL?Tdo1JfomMSK|`A4|MP;8wKoBP<hZ;9K<z)(7`y zi`NelI0_a>q(XTiT6m0@D5_KCU9hWsaaM}rt7=!}g$scPrYhJw;H0#FS=91Ip&hVN z5RZExm(q}a>^CxUVhIvaM{_Gly<DCn+a*-Ql~EHFFJFvrTJMphKmXL|zOs#*u5f$3 zZqAfK$yGEtIGxx^J0W-u=DBUO2(|)_rxeo1Vc1|&rzC+Ewco=eH(+){8-PW3smX}3 zGFj_)uh32!MYLx<L8df+wzaO@>^C^lsItgT<k0rQ7!X!DVL(YEwiJZpMTt@gS&0vF zts0*FGP5n*h)2@HE+Z==A^nm?b2=wy23*!FGH$F?j)WRyn4+!qYp7Tf<%f@afyN6l zkfR&Tm#W_kYW^8n(*n!HELTyM=1ZAwCaGx{l6uqIEfJwh#cAJvko>-N9Z28GD{W{; z!{Yr%(Uj-O!YQ{DFLr0=D~X}<{mhXu3}_8Ml=aL!0C57}kdYQMrD*ngMw0rrq*7M7 zkByuxDnl^)Uq)o)$Ym-VnxhI9#dL@-SL+uKykW>k@ing5c1jyC#eCTX-O)N{)|7K# zJ|!P^RS~^6%3E811H08`x7A)<{W-v>C$?us0-Pf=Xbn#FPQPg=Z-;HWWZ@l)0)0uf z`=p#C9U7~UgDEi?#~Iyx80J86%Rm#;301Dm>69OBh*kNWu~tdu8*!!EX_k>1F^84M ziDSNFV?0EcBOu|Rkw-nmY{h139@X0~q1@BNd)g&P>Unp6H8@dgc{-1U&Ke2gDrRYV zHva%ITr`VPk{fvk#TW<qmu!;S4(8ZKwOIjQ<vnshsiZJ!eXhN-Oj|TQ_^RGJ)NP8k z4l3jQn3YSe(3z0-J7mKYWN`&w6Hd7jRc{vM)2=H20RPpUnh`@&T;VC}7NBIPCac>B z3`ub#pv!}QQ7ak3_5D~-qb<z_!|&S&iwQ{x^2qhxwaK-t3f%_e;iaWOlkqf-d@67y zsbjblC2A@AJK@=PQV+h>2j2}M-N>G&6<p*M+vviC*vhGOWgqOI1-@;Rk+YHx%yV70 zhCNQ<5d1u<KAg<Pc5!lz(zz3g{;aavfKh69$pV^xQ#JZBK(VAdTt`|Rfb4M9prR}_ zJF6+ZT|VH%-?bm?H9SUFlL(hKamZqy{{W7S)vT6U#h7@eUM(zLess)PX?RVCAZM{f z*O1uX=vJ^<-hE{)B;;akq!FEn_ohNO6KSwj{LAJ01oG9anmA=tlu1p=YKj`<apm=! zWhhjCc1Uz-nXK-u<oiG|-?rJ2IPM5oovDFqkr8mGffZVl><(q)J2-g;=_S&!DIh1B z9llw3X=Ivvt;Uh+vH0R{pFE~p7U9W`UMUm5&o3OB3X(BZYIXh?X%?J$Lqd~Ozf0?n zgHN??MbAcI(<se46dUkD&9~;u->z%2vhI<8(G%r|2&mf}^0`u2BLR6HgSK03RMshI zj3`oJzDO%a3aCpG$@YdwAqOp_i@N+n!@furr4dn3K?mO(Ri(=H!%J=w1M|j^xD($R zRWN~@aqcjx@=b6dx|~)2)||w$I@X+h^N6RXGG8G5+JzNr?~?(I+5u}1l<@N$F|R2_ zd>0+jf3RBjQP6RYqZ%mU2z!)SR-re-f)t{)p{ouf!zFA0RfZVjZa`G_t^!$7{{T%( z669~%m@PDjgMdE`54J)~!>%;jd$;62cH_5>5t5K$XkXA59Oww*d!E@Z7%(`B<K@@n zyrrtwa4-z1@Q|-xuF#tBU<b%#&m@j?kZKbNe=jDG_hU@UfZ4^F-7S(@y*~_{F$G~G z2xO+r1#)3&D7leVMh{V5nQR)RvXaONsxUu+$yK3NEA~d?EotwOFba!`XuU;f&lc>< zu^C*Ue9{Xk(Y(=fW{U*iTv>y?da3cuOuBpAIS|cU(3*ty53fwNukcEs?XV?=_{Jm+ zf7=AX<iY&LCqkfrO|fKu^$FWD@wnL-?bKXQGVYDIIe0B3ZkcE|A!42r(2D&z41t_V z4^y{AQzDOuo$_Kutb`4`B3V)<*<Hf9??Z-Mtz*sTaHsAfu(gb(yi^=VRz4>%Co48w z_(p{!8kU{)T~gsDQOJ-$_BoRYTtkhJe^iPJeK_%Fh}3^6%K>Yotbz{tHny;;li>=q z;Z-*8k_xv9t!UMS7*`Y_<g#7(AE4xfkw^()k~TcY(lTJ8maDMDs;8>G4#xto<UWiF zx`)&CDe%`COaIrNOFI$pf)}yQFh%IeP!}$>uTJ?YMXoZLD13Whs$M0a)u=%ff5MHI z&{ramyL?rmzyf-B;<kjkxnV&WrFX7QF<~O0E+<wD8+?%gjg)<!pwB2tH*Uu&lmldD zQw6_tdLM~<<z@|>WGgMyQU+pZ6l8c(B*)DS+ZEoVk(m+X!1u9hm;K~~ot2Jf2M~lB zRm(XW6S2t_G`NBE;ARwL+N7O^fB87>r8Cp?+Y6ln=JsLDh;9sa0-0Q-M5(ed1X~uG z2qaI0abeJm%LT`z467t&j0+DFl43(?Gbsg4DTxRPovrkp&2qC8+pn(G3|Fx2G!5~A z(5_ZtwK9aLsq4lomrK}yG2E}lzWE$twrtDZ#{R199I%1QkHm-DF1l(Pe}pZAJa;UO zu>&P8Oh(H3nf=NU-1WvNC@T@p{@HwGTK)MsazuH(W_j&&yJ;?@02d>#Q`@F$sK}RQ zWFi9$)-KZPQJQpW#?<0ZT+GKBjW{`Fa<no%Ln-gmCcz_dx%uV6tzi_xs!lM4SbrQU zv4#+<DUw#YVyjr1*BV8we{}={Oe%%AwuqX6Mm`xJP*V9-(YUbNvB?G{YdfjyiyLdz zmXzjcieLZGnbAsemCZktXCCw&6lIt&;)}&ce;gn&GEwyyZ(4X^q+3QtJMuL+(%Pb3 z$b)gnd*C4;uNV97xHwb9qb<>C0}YgR$q1s>%<9IY4N?hNT98gCe_tDfVBN+>K_I3P z-A5tc>Bw-i5?->8StIM}R;LFFP%L;z>_222oOx3&L5faC#8_6OoXi}YUaI#NV&2l- z!LP2R>EJRVNti<g<Ut=SlMqm|#?sFns(ral2=EzfO$%ksU(7m^S=dLUTF^xptfY51 z;)R<mvPy*pCT^ahe}|@I@VNX%5Vr{8jCh}Zm?<{3?NyYN6mM?$poJ*pk}^Tt$0Wt2 znDRB<TV@BQ3PKUCo)Y%VPo}>T4{V8pAts}`Xr+#2*(CT_eDVYY#Hd{&t<tqAT7&ZK zh$$gu*yU&LrzaM<A#|WyR<lvm%D=A;L+LAwZW*tfRmP(je<biJ(z{nNFmf(VqnPeU z9Y{1gdu8C;K@`%R$F>wDHycdj{H(AwkhQkSDwELdh(eC}HF2TEDr8}`T5zjGRAEC* zHpNyI2V;t%=7C&MzJrnl6qe4fAHu_G?Aa?~LY~|EVMG7XloC%~*~wGAwu!)ngoD=# zR-T&-Rn^-Re>4RvTu{&e3gJrH@9l+LO-?9qYHW8H3a;irs+t@Ww!S-w9_Axj749)3 zW>7X(-%Qa&&E2-a5ZfcO8@g}A=1_5w*R1B+)IUXziZZyfnB;-CU?vL3Zl~KUJes3c zhtqBnE1SJSP5m%d8u;a;Yyv@JSJ#bkBeAVml1^m6e^6_btqI>G!Ni}Z8bzul#Hmg# zz6xc;h&L-B9M+BFjU<~`!AxKdpwwVOU8=%L3D>IZ*ieLqV%FZ1eWf)R3{(_F!<VyJ zziAKZ%D`uB`ipg<dg=D#ZZZ#JjntHjikFvHmcM1)eNLY&mT#c3!}SJh=RP5#Bki?l zeX|oyf62<!vFnmqm5Ayo_hsS7Djg0<ic@~2E|~TrxnN*cH$jC8P1guQ!@l`7TZ*l1 z@l`X|CYG^RVya}*6f|hUhZRtgG-WE|;q8PJ6alhfFpvG#d*rPY{{a8elpN%p>c(XP zr>|^PXDa;|tE-9{C-21$YSR@{slu&RxK-Gpf2KI4ZGG`o_O%j@<YZ)+b6KjnF(~mk z<7}Bp7*<sWry*abGETs>jmSmoQk9h>1K>pgPFIX<oU)@yL&#N*`?h2N(1w;7ge=8R zp(#8MzF7haNGS+H&P6&`E({h*rCOAx5QRLF9g15GimEHn;H7DhGP0e+U{sEHnkQxG zf69Izd_2`!8N<OM^$aLSfy&HivIu<^;bWG2Nt%$%qyzp?>4tH-EMj^lo=uEN<_Hf_ zA0uCuLpk)8cn7VO`KUCyoumg~K&SgG#=*30$kcBj5_|Ys8<&9p0J_+Z!!CyH6WE}e zW0PQnzO?m|)E{aw_vE5bLR=VACc$Ije+s9QL0WNEjw-Q+NF{C+aXVtFC$1{&P|+2| zRQQfqRsYkHG@ze{u5wiG7MB$Lc&S^<Y$$bbt6Z^G+LZUe5>%>iv2}4)jWJe<7^=o< zCg!-Us#&mUw&(^j<j)!1tyZ3#XT5M@XOSW;!P>vI{c|FkI)#5RN|Cfr+{P)9f5bVi zZfP~UePZS~m#l}6-<Od=syx{iyA%m$9i(ftEAbzEqYS-P<m4ML#)TuP?Y(n2O59{4 zFlOKE<*Za$BWjdAI<6>DEJ3X{#4AA1;;ZpO^=e|2CZ!KG60cFQ#ZpFghE@aYzBI*5 zHyIWC4!E95&3u~;w0Ggqd~zY;e{&O2^tn2JsgY||=1pe8qxKKpqbS6ECA%zrK`Ygv z4x(T;2Yj;}nzBVDahJ3etG>sjDTqQ#bqR`QUe&7Qfq_tG-+Y)=ha`f~#a6Z<4Pj)o zis1!Tw@fHfIIFQ&VykJsD*pig)H#Xw-@bB=<P}nB-nGEW+!%MoS}dUTf5Nt=(lBkP zMku!#YIng&wcM&b@S&nbF;xQQrwt<4DcYw3tZD*%`e8$GNRdh%%@pli5)AR0D%zrt zPB!ii2yELnIdeESO%*=Q<1&ER#=YI<qgbq4rzrROGB`S?KSYuVUMEQ-JXsAv%z`X5 z14Wd){{URDGLMPrMqI^=e<-<TRv69Pa-LDDR1L`<8J7c2IRyY<leqXt+9NLFVyD7N zk_N+|#?YqA%k2srZB$uEG$ZNBD{9rnS7L_91xg*m4!ETuzJq3`SkC_A2thyt_~KZz zPa<6_T*i!QV>G9+%uPV_xi}B0oN9?(Pfu>tarcbEf!WN+H%aG<f1Gb0%zim^HfWHx zIWiP>Q~M_}Z)X^d)Vh4Ip$&a*C5w<B8gE|o$pV5BH$M?MXkg$@6gvv7AmXiIOGg!1 zA)wm`LXJsWz^=tziogHWGcq99pQkxzZn#_`^)(-k4iws=&lB|9*yBYYf}rj3ALZ?Y zAg!*|I}aRCy&GzEfA=^N*?JwfIMQuzGENW?Yf0Y<wi<$%DQ&Lf0xe4BKT-xx{YT6k zK}s_0v)IJvsHZQUMm*e!a$;BVanu>dV?+05WJb;*JF0nx;7bLPgI)>0(~-m|mRV0E zpmZa{E@@05y0{9q_Vq6)p(CIf5(hTel9-D%hS|_=#dmY#e~}Yxst8yu)nO@0lhmBD z+7Of3lTy18+XyHrbnsLXHeuH%5`-HH^~*t3KeBOF!Nm>Yn@ev{3AuJ{jw(`zPA<#f zcE;GIzE-<yZF<xL@XFj+eDbWq;@ODQp2_H>hBtA%b>s8RNHpyTwmB%{Jd5DxGKVJ^ zq}sG097+emf8BE2a_G^LJyOOM8EaEO_VL)^2q_i(9+k^4X53}5Vo>AkjFKEi6gw4K zMk=)7N{bqr;Rsqr6?Q7@RjT5z|JD`3j&k<x+7Y*o8(MK!{@iUySBw!J`<#JDbq9U@ zyJ1$UevDS#%AGM*!6$rG#BYgJq#n2s>f)``e_T?Ce~g(eBu>0W*%QsW2E{PT;C{0p zJ~hnf@^K`gJG6+k-kV_o1lq`yw?Vn+Fq9TWrrIjntbu=c#*LWsmhMI|vX%YK99za~ z{O9;&@x!X*lEifahhvsB+Q|=k3~f-CHr&T0pll$Zr1vltB!SbY%c5-vQlyHUnzc$| zhi{6Yf3*ZACL^kvj0}>?gye!yK6qG=r)epM_6bF5jt}FJn7FJ>N$!?&EY{G>4Md6v zBjuWDiZ0p)!bPaYQu<-yYnc(`<IiG(yoF}AWjpv};%S71vDa1=6zSJ8Ty=zCMJ3w8 zRT56%(44jfi3w7XUktU1><DU@Rn$14WW6che;Z__Op+FngeueW#agZ^?)a<!*BrjD zZ?l}kwRgu}-%cSdg+17OsfkLU2Z!1j6s;@%Q2Vf}bpHS-U_)G2y9`q9BeM)u+<(d# z3b(|6FvVKpzs-so;=j%K990u$1zL<a)8aTK&9@geAF3om@?J6mJZOKWW<!%i`Jj<k ze~GJs*Rjbk2)!*W{Yo@|3j8fY{V+?CLnfPRGxcJuX+TEQK0D?tdG6%Pmdss){bH3I ztMMK4G*vkAOrr=#?^rK^$d)Ck5*i=g3Ve<Pg%YdT#Xd(BLS3b)1hk49xS{@;F=#0) zsQ_<+Ktc99ei?A&g)))<066=O9o4QefByhD`;YX;bg9o2JX*6Y3Z6rW+wjP$Pr^Sz z!$>$&tq;-LB!Yp7f6^l2<6<_U-wEW$tJFNO+RDfKl`>k9w<AEO+O*AnQ5$n{87tfd z_1MhA)vv@i$b`$9lPb{B#Xd(QsSWH_!dEfO3m>$!ZNCh<E<BLMR+~7d$mG}{VD&_Z z*|#YGKeMsPN16&l!X7)wEAl_nE?~J;Y4*iFM-(^4Kj$BD!maR6=O1zYn59ldPv;+T sz^ZPmO`LtlCaPgm`N!OFgcg}o`N!OGR=B7054hs3aa#SM_Z(IK*$rgY;Q#;t delta 55973 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 Je&dS2|JmXl`z`<g diff --git a/resources/public/image/monk-face.svg b/resources/public/image/monk-face.svg new file mode 100644 index 000000000..f261f682d --- /dev/null +++ b/resources/public/image/monk-face.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#fff" d="M256 41c-29.03 0-56.162 11.49-78.38 31.12 1.61 1.512 3.192 3.022 4.714 4.49 9.823 9.47 18.386 17.234 26.963 18.484 5.004.73 11.068-1.446 18.715-4.95 7.647-3.503 16.566-8.274 27.62-8.294 11.163-.02 20.207 4.76 27.975 8.28 7.77 3.522 13.955 5.722 19.08 4.966 8.583-1.267 17.153-9.04 26.98-18.506 1.52-1.464 3.095-2.97 4.704-4.48C312.156 52.488 285.027 41 256 41zM143.572 72.99c-1.595-.076-5.878 2.043-9.21 5.373-4.138 4.14-8.75 9.024-11.485 13.395-2.736 4.37-3.203 7.102-2.465 9.043 3.604 9.48 12.928 14.148 27.156 17.555 14.228 3.408 31.67 4.636 46.905 8.99 20.49 5.857 41.04 10.94 61.052 10.968 20.34.026 41.222-5.056 62.012-10.97 15.233-4.332 32.672-5.563 46.897-8.974 14.224-3.412 23.55-8.088 27.154-17.57.738-1.94.27-4.672-2.465-9.042-2.736-4.37-7.347-9.256-11.486-13.395-3.33-3.33-7.614-5.45-9.21-5.373-8.463.402-16.603 7.244-26.273 16.56-9.67 9.317-20.536 20.948-36.84 23.354-11.4 1.683-21.038-2.707-29.138-6.38-8.1-3.67-14.97-6.685-20.51-6.674-5.422.01-12.174 3.002-20.156 6.66-7.983 3.657-17.506 8.043-28.807 6.396-16.317-2.377-27.19-14.016-36.86-23.34-9.672-9.323-17.813-16.174-26.27-16.576zm-8.437 60.555C126.11 155.883 121 181.13 121 208c0 67.545 32.248 124.872 78 151.332v-5.795h18v9s-.073 4.904 4.2 10.6c4.27 5.696 12.8 12.4 34.8 12.4 22 0 30.53-6.704 34.8-12.4 4.273-5.696 4.2-10.6 4.2-10.6v-9h18v5.795c45.752-26.46 78-83.787 78-151.332 0-26.865-5.11-52.11-14.13-74.443-2.73.888-5.49 1.657-8.24 2.316-16.316 3.913-33.775 5.26-46.167 8.783-21.193 6.028-43.652 11.687-66.96 11.656-22.997-.03-45.113-5.695-65.976-11.658-12.38-3.538-29.835-4.887-46.152-8.795-2.75-.66-5.51-1.428-8.24-2.315zm-29.02 37.197c-6.307 1.07-11.955 2.64-16.623 4.72-9.613 4.29-14.883 9.754-16.68 18.376-1.835 8.79 4.34 18.974 15.292 29.193 5.215 4.868 10.972 9.264 16.693 13.33-1.18-9.257-1.797-18.73-1.797-28.36 0-12.74 1.073-25.202 3.115-37.258zm299.77 0C407.927 182.798 409 195.262 409 208c0 9.63-.616 19.103-1.797 28.36 5.72-4.066 11.478-8.462 16.693-13.33 10.953-10.218 17.127-20.4 15.293-29.192-1.8-8.622-7.07-14.087-16.682-18.375-4.668-2.082-10.315-3.652-16.623-4.72zM151 179.428h82v18h-17.893C216.335 200.745 217 204.332 217 208c0 14.537-10.435 27.842-25 27.842S167 222.537 167 208c0-3.668.665-7.255 1.893-10.572H151v-18zm128 0h82v18h-17.893C344.335 200.745 345 204.332 345 208c0 14.537-10.435 27.842-25 27.842S295 222.537 295 208c0-3.668.665-7.255 1.893-10.572H279v-18zm-87 18.73c-3.11 0-7 3.566-7 9.842 0 6.276 3.89 9.842 7 9.842s7-3.566 7-9.842c0-6.276-3.89-9.842-7-9.842zm128 0c-3.11 0-7 3.566-7 9.842 0 6.276 3.89 9.842 7 9.842s7-3.566 7-9.842c0-6.276-3.89-9.842-7-9.842zm-96.615 29.13l6.664 6.048c10.193 9.253 18.694 12.328 26.63 12.053 7.936-.277 16.305-4.164 25.375-12.146l6.756-5.945 11.89 13.51-6.755 5.946c-11.043 9.72-23.294 16.16-36.64 16.623-13.347.462-26.79-5.312-39.354-16.716l-6.663-6.05 12.098-13.327zm-40.37 28.173l17.97 1.08c-.528 8.798-2.63 16.397-5.788 23.036 46.533 15.463 75.073 15.463 121.606 0-3.158-6.64-5.26-14.238-5.787-23.037l17.968-1.08c1.277 21.287 12.412 30.7 27.993 41.042l-9.954 14.996c-6.863-4.555-13.805-9.87-19.744-16.437l-4.434 1.48c-49.14 16.38-84.553 16.38-133.692 0l-4.433-1.48c-5.938 6.567-12.88 11.883-19.743 16.438l-9.954-14.996c15.58-10.34 26.716-19.755 27.993-41.04zm-53.247 57.112c-11.485 7.137-19.815 15.392-24.174 24.328 15.652 60.648 38.172 93.902 64.263 116.266 22.97 19.69 49.378 31.227 77.143 44.504V441h-32v-18h32v-19.86c-20.637-1.876-33.41-10.15-40.2-19.202-.244-.327-.46-.655-.69-.983-31.41-13.046-58.028-38.055-76.342-70.383zm252.464 0c-18.314 32.328-44.93 57.337-76.34 70.383-.23.328-.448.656-.693.983-6.79 9.053-19.563 17.326-40.2 19.2V423h32v18h-32v56.67c27.765-13.277 54.172-24.815 77.143-44.504 26.09-22.364 48.61-55.618 64.263-116.266-4.36-8.936-12.69-17.19-24.174-24.328zM231 327h50v18h-50v-18zM89.107 345.256c-19.795 1.35-39.674 8.244-54.736 16.61-6.51 3.618-12.106 7.51-16.37 11.148V494h37v-39h18v39h125.602c-13.88-7.477-27.505-16.063-40.46-27.166-28.365-24.313-52.697-60.595-69.035-121.578zm333.786 0c-16.338 60.983-40.67 97.265-69.036 121.578-12.954 11.103-26.58 19.69-40.46 27.166H439v-39h18v39h37V373.014c-4.264-3.637-9.86-7.53-16.37-11.147-15.063-8.367-34.942-15.262-54.737-16.61z"/></svg> \ No newline at end of file diff --git a/resources/public/image/orcpub-box-logo.png b/resources/public/image/orcpub-box-logo.png deleted file mode 100644 index b7233cc85f0afa4e9795008efb942cb3b6da6d03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47613 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 diff --git a/resources/public/image/orcpub-card-logo - original.png b/resources/public/image/orcpub-card-logo - original.png deleted file mode 100644 index 3aa38cd55758b501aaa7b9a1e7e953cf0051b9c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3937 zcmV-n51#OeP)<h;3K|Lk000e1NJLTq004LZ0015c1^@s6=gHcY00006VoOIv0RI60 z0RN!9r;`8x010qNS#tmY9>M?s9>M``&~-il000McNliru;RXT}8X?o}zJ&k)4(>@r zK~!ko?VEd0RCkugKeu@cqViA&MGFOSjG_U8<qqS6kc^MTiE%KNv!$gm*)83ykAgAi z8l#CyqH!~k>>7=On~a)?ngm~)iORaWx{-$%1XKhXb+ARzNC0`XG-v<d-uBXTHw_ux z-KsfNUEKS-zu&pP-}(N|`JHp_0dyoHJ|gm45xFlSCq(2;5lIq}0RBS%FG<mX+Yk6} zV5FUX7T_|F1DsR;UQ!gr(w&!yh&M0`7!UXW)xdE@QF2@y9}NuGH8lctfJsr5ik=8S zM8biYKse9_TnF|kigMFI{{_GZT~i%!AIMP@rLc>4%~aQJR1{^mo#*4#xjax5Ws?&= zDL_A6Qxot5kPn<w6h-U}Iw;+vIuXehk(XQ)<kKQ@%>l2^Ma0Ji9v?cH&lM4w(G6fd zRelybyly)g_d5|WI{5voi1c<cc7xh)uY>2mR-fm0G56C>=CD^pe6_v?-Oifb&CTB% zm;!t#B7R*=s6TK780w%)GO+VUTBArHOGKu*0ImXj1}xEJpr@v$CSPEXGag2x@%#Pz z_g^}7>Qv>eTet43%|8dWboKt?;^JYz%Z{Gc*Vp?3tGnWJ`t<2*Z@u+aPDVz?g`Aw6 z>wo|gfHd0$f2?iYS6_Wq^78VWs;<#!l+4Ub>;3WL$M<$2(BJFFfAPf^vVQ$~xqSI@ z2fo$S)k&^u@TE(a<c&Apkgvb~x&x2;`g)TK0MFNtX=`hfufF<9R<B+y+qP|!+}vCN zZadT9-Me=i0i$YejoY?uvue@VvuDRS)8OpvY$LGOkp|znbH}KfTc;YFnwn~z17O^` zb*oWCjApaBeCg7qf!5tDB30UAqobq6&CN}gE?p|MwY43DXEK>2IXPL}+}tEFG11yy zQ&VF}NlEGNU@`+lq($rd(MKPNy3?d@-@alpnXG)yojX_MD!^G;Syn!~cJ1mg&t=P& zEpfubO+-qyzGkyol9H0FyZ}+puo@Qt&H_?_E&l%g-!wHf8AZgnY15{Edx8LOQ!U`) zXP<rc_u3|#J9q9*%|wsZo1;O429cPUxF$P0yJ+_8*)%jX0AMzonLT?p2M!#-VzJ=m z<yEbX2?`1_czJof<6u6&1U%J=v2o)@t^Lc|+S(17nVD8W#>B)7ij0i>WjD<7Wnf)a zR#vXQv9Yo74JTtJ>z$;H8#i*~$PrX<vw$@s@=rje3-7<8n&i~Fy1HM~)z#Gj^y<|s z5{P(0j`9`Y0Pyk2lP8zktkc^an5yr6`0!zXjka8xo159beLL>%?hgkI8uX{2pdbq{ zP<vihRyGS*s(QA1`k8id_3Bk_+_-^y-u^AX)TvYd12_fX?(WXmv14B^EiKJ*adBn9 zh9gIg{1&Ly8vXqI1^`jOMLT1xj+ohOCL<#Q;Gyb+9I+Q+xazkB1_qV`(E$Mg+Pcf0 za8z_(Cj)n2OecUHk-xt`F)=X!9>6f$=MNt~>;pV&BP3Ko15jRGu4#&O14TziU(k0R zJa}*%;0H8xV`29CIs*9T+C_GDHuvt`(>Ap2yrsL+b#rrb188e&0|@<J2zx!O0%^Cr z{S81>n>r(Vbn4V8Dk>_duC8|F1@3m*te$g4MFqfB;D%LA6y<(vYiqrB9~v5JP-DgJ zP+$Gc-Me=If*p9z);K>WCkMa`Wc--~90*WbTia<)Pl$rFi<>uZ0{9seMOhFP6_t~b zk->up4?2y=%*>>+vXU7yW_0^)BI2eG$W0~_K)Ic+f6^`n3>eTs>s_Q#`kR7+0!RIV zRqLSNaM{4UA1hfA2?9a@N=iyllO|7^q5&Q}ctCD$E<mHZI-#=HUVH7C_3PKWFIlpL z<;$1TuU|VZlP6Ec-{1dxcX#(jV6et%#flX)Ha<FTFI>1_?}c8r=Qz+Q<D~L#AK>K$ zFr*uTRe+V+eN|PJCg8jFP8$8q?c29evvNNc+}^-jfT69eO^zKqrl=XNCq`;=<L>~r zZrw^tOAA1?W>OabZ??6yt=+I;!<@*-NZ)z$<^iyH@nV{ro82=rGdl=p&z?P+DRM>~ z09IdWFHUW-0eC=HjE4>#%G+<h9R~ancus47|NZx^itHdcqfcnq-EgkZ)TvV`EG(QD z6%}<<^+~(|wrts=S*pkGV9lOAo0&6bPK}R`AEhQZg0Ek{Ub1P^rl(YAw@`m(R^GdJ zFIQ2N--L&UpVFI2Z*On^QKLq6mR9fHtClWZIwL(jU5n{1I0M*hHUl&}1K8W!yVE#( zG<NJ*#*Q631{kBY?cKXq1Gow(u`|ri&U|k7M7SX#A%uj44A3^Ch}?Vcz4z{>rKLrx zx%+>A+yIXsKOSJ5`a?u+Bqb&JsO#DVm~EY+gjus@Wyi(E6}xf{o;-Q-Qe0fzlJxZS z|59U8haWSvI9Ly+tD0|*%FD|M4-bEo>r77O;K74dMRqXE$Ig73dLrDjXV3D<C!f@b zh^er!(6gYRAf&amHBf!LtEU^MJ9OyKAue9LcysjV(e>lTjT;aV5iuk$FON6heDg|r zdip<OClC9yqA2q^#UKs#BbNUC`^N%9<Kp7}(ZL)#fT@~avok<dGw$8h;x1jfM1KCG zWKUF-bwBQP0&r^ww0(RW&8JH$EiGl|&Yk|dcI_IOo0~hVwYAk-J@+@cl8^p#P)<$` z8#ZhhG-uA7kt0To=(lm>M!dYd*t~gjgos3T09bVthWYyXCYa3~HT#uUUeR_tHUf$T zm@r|&R967@^z?KTUwYX%VlL5v0|%^Gu!Rd3;^X7<67V;wiL^%@k}$n`_3BZ)tE2O3 zs&WZ1T|Eas{*gSpOJ4&UfL-g>tt-5C?HYjFx8Hu7W~;$bfFUq2FtV<$u2*Sk>0Q+f z^Vw&gapA&+ww9I_HgDeiD601Ghy{p`j}M^ZcUrpuJ2_u%HP{t1&ZnP#dfsd{i<%%| z;lhPUT@1O?A!Z5=?qs&RNk-t1D pHrC^$?|?nPW{br#tD>T!7@%LjeiMMvdVt5N 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 diff --git a/resources/public/image/stone-tablet.svg b/resources/public/image/stone-tablet.svg new file mode 100644 index 000000000..92d86ff3f --- /dev/null +++ b/resources/public/image/stone-tablet.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M206.355 24.53L23.982 171.567l.086.373h-.013l-5.7 124.037 131.676 82.55L245.66 492.14l63.94-8.59 109.808-109.587 72.772-35.453.953-70.24 1.186-50.508L327.75 104.63l-33.41 14.46-3.46 29.756-18.566-2.16 3.21-27.598-46.934-7.715-21.106 28.844-15.082-11.037 27.887-38.11 65.273 10.73-.13.78 25.2-10.908-51.485-64.012-52.793-3.13zM40.605 218.886l117.91 74.13 49.065-9.7-14.14-13.71 13.01-13.42 39.75 38.545-71.942 14.223-18.918 50.844-117.817-73.86 3.082-67.052zm362.754 59.068l44.382 20.55 26.463-12.92-.56 41.165-50.293 24.504 9.59-39.008-37.434-17.332 7.85-16.96zm-211.534 37.373l78.108 93-24.81 54.158-75.405-89.59.602.225 21.504-57.794zm218.403 11.028l-9.896 40.24-89.69 89.51 19.092-49.204 80.493-80.546zm-102.293 85.064l-21.904 56.44-24.312 3.267 26.118-57.01 20.1-2.697z" fill="#fff"/></svg> \ No newline at end of file diff --git a/resources/public/image/wanted-reward.svg b/resources/public/image/wanted-reward.svg new file mode 100644 index 000000000..e0bf3dcb4 --- /dev/null +++ b/resources/public/image/wanted-reward.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#fff" d="M75.1 27.19L74 163.1l10.7 9.9-10.9 11.2-.3 32.9 23.7 20.4L73.3 249l-2 235.8 27.9-.2 13-28.5s23.6 21.4 24 19.9c.3-1.5 82-9.4 82-9.4l9 17 213.5-1.9-.1-106.3-22.9-18.7 22.8-10.3-.3-282.93-34.3-35.34-276.5-.77-9.7 26.4-11-26.46zm194.3 8.7a16.18 9.811 64.69 0 1 13.2 11.03 16.18 9.811 64.69 0 1-2 18.81 16.18 9.811 64.69 0 1-10.5-3.09l-22.8 8.58-5.1-5.51 20.7-16.41a16.18 9.811 64.69 0 1 3.9-12.82 16.18 9.811 64.69 0 1 2.6-.59zM101.7 93.51h12l8.4 39.69 8.3-39.69h12.1l8.4 39.69 8.4-39.69h11.9l-11.5 54.59h-14.4l-8.9-41.5-8.7 41.5h-14.4zm86.9 0h15l18 54.59H209l-3.1-9.9h-19.6l-3.1 9.9h-12.6zm39.4 0h14l17.6 37.39V93.51h11.9v54.59h-14l-17.7-37.5v37.5H228zm50 0h44.8v10.59h-16.1v44h-12.5v-44H278zm51.3 0h33.8v10.59h-21.3v10.2h20v10.6h-20v12.6h22v10.6h-34.5zm45.5 0H388c7.4 0 12.9.59 16.5 1.79 3.6 1.17 6.7 3.17 9.3 6 2.3 2.4 4 5.3 5.1 8.5 1.1 3.2 1.7 6.9 1.7 10.9 0 4.2-.6 7.8-1.7 11.1-1.1 3.2-2.8 6-5.1 8.5-2.6 2.8-5.7 4.8-9.4 6-3.6 1.2-9.1 1.8-16.4 1.8h-13.2zm12.6 10.59v33.4h4.4c5.2 0 9.1-1.5 11.8-4.3 2.7-2.9 4-7 4-12.5 0-5.4-1.3-9.5-4-12.3-2.7-2.8-6.6-4.3-11.8-4.3zm-191.3 2.2l-6.7 21.7h13.3zm184.2 60.4l-.5 205.7-251.6-.5 3.4-194.7.2-8.7zm-18 18.2l-212.9 1.4-2.8 167.6 41 .1c4.5-15.5 11.5-31.7 43.8-36.9a34.89 48.56 0 0 1-11.6-36.2 34.89 48.56 0 0 1 .5-7.3l-49.7.4-.2-18 47.6-.4c5-13.6 4.8-28.8 4.9-44.5 25.5 6.5 41 6.1 60.4 0-.2 14.8.5 28.7 6.4 43.8l46.9-.4.2 18-47.7.4a34.89 48.56 0 0 1 .5 8 34.89 48.56 0 0 1-12 36.6c25.9 5.4 40.5 20.5 44.8 36.8l39.4.1zM152.9 392.7l206.3.8v18l-206.3-.8zm112.7 39.8l93.7.8-.2 18-93.7-.8z"/></svg> \ No newline at end of file diff --git a/resources/public/js/cookies.js b/resources/public/js/cookies.js index c0210a203..47902b11d 100644 --- a/resources/public/js/cookies.js +++ b/resources/public/js/cookies.js @@ -9,8 +9,8 @@ function Pop() { var conDivObj; var fadeInTime = 10; var fadeOutTime = 10; - let cookie = { name: "cookieconsent_status", path: "/", expiryDays: 365 * 24 * 60 * 60 * 5000 }; - let content = { message: "This website uses cookies to ensure you get the best experience on our website.", btnText: "Got it!", mode: " banner bottom", theme: " theme-classic", palette: " palette1", link: "Learn more", href: "https://www.cookiesandyou.com", target: "_blank" }; + let cookie = { name: "flatsome_cookie_notice", path: "/", expiryDays: 365 * 24 * 60 * 60 * 5000 }; + let content = { message: "This website uses cookies to ensure you get the best experience on our website.", btnText: "Got it!", mode: " banner bottom", theme: " theme-classic", palette: " palette1", link: "Learn more", href: "/cookies-policy", target: "_blank" }; let createPopUp = function() { if (typeof conDivObj === "undefined") { conDivObj = document.createElement("DIV"); @@ -67,7 +67,7 @@ function Pop() { expires.setTime(expires.getTime() + cookie.expiryDays); document.cookie = cookie.name + "=" + - "ok" + + "1" + ";expires=" + expires.toUTCString() + "path=" + diff --git a/docker-setup.sh b/run similarity index 73% rename from docker-setup.sh rename to run index 8de489a1c..ddea6d5b9 100755 --- a/docker-setup.sh +++ b/run @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# OrcPub / Docker Setup Script +# OrcPub / Dungeon Master's Vault — Docker Setup Script # # Prepares everything needed to run the application via Docker Compose: # 1. Generates secure random passwords and a signing secret @@ -9,14 +9,15 @@ # 4. Creates required directories (data, logs, deploy/homebrew) # # Usage: -# ./docker-setup.sh # Interactive mode — prompts for optional values -# ./docker-setup.sh --auto # Non-interactive — accepts all defaults -# ./docker-setup.sh --help # Show usage +# ./${SCRIPT_NAME} # Interactive mode — prompts for optional values +# ./${SCRIPT_NAME} --auto # Non-interactive — accepts all defaults +# ./${SCRIPT_NAME} --help # Show usage # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" ENV_FILE="${SCRIPT_DIR}/.env" # --------------------------------------------------------------------------- @@ -92,8 +93,8 @@ write_compose_secrets() { fi if [ "$mode" = "file" ]; then - cat > "$secrets_compose" <<'YAML' -# Generated by ./docker-setup.sh --secrets + cat > "$secrets_compose" <<YAML +# Generated by ./${SCRIPT_NAME} --secrets # Compose merges this with docker-compose.yaml automatically via COMPOSE_FILE. secrets: datomic_password: @@ -114,8 +115,8 @@ services: - admin_password YAML else - cat > "$secrets_compose" <<'YAML' -# Generated by ./docker-setup.sh --swarm + cat > "$secrets_compose" <<YAML +# Generated by ./${SCRIPT_NAME} --swarm # Compose merges this with docker-compose.yaml automatically via COMPOSE_FILE. secrets: datomic_password: @@ -158,6 +159,29 @@ YAML fi } +# Switch transactor host binding between Compose and Swarm modes. +# Usage: switch_transactor_host "compose" → host=datomic (Compose DNS bind) +# switch_transactor_host "swarm" → host=0.0.0.0 (Swarm all-interfaces) +switch_transactor_host() { + local mode="$1" + local template="${SCRIPT_DIR}/docker/transactor.properties.template" + [ -f "$template" ] || return 0 + + if [ "$mode" = "compose" ]; then + if grep -q '^host=0\.0\.0\.0' "$template"; then + sed -i 's/^host=0\.0\.0\.0$/#host=0.0.0.0/' "$template" + sed -i 's/^#host=datomic$/host=datomic/' "$template" + change "Transactor host: 0.0.0.0 → datomic (Compose bind)" + fi + elif [ "$mode" = "swarm" ]; then + if grep -q '^host=datomic' "$template"; then + sed -i 's/^host=datomic$/#host=datomic/' "$template" + sed -i 's/^#host=0\.0\.0\.0$/host=0.0.0.0/' "$template" + change "Transactor host: datomic → 0.0.0.0 (Swarm bind)" + fi + fi +} + # Read DATOMIC_PASSWORD, ADMIN_PASSWORD, SIGNATURE from .env + shell env. # Sets _pw_datomic, _pw_admin, _pw_signature. Exits on missing values. # Usage: read_passwords "--secrets" (label for error message) @@ -186,7 +210,7 @@ read_passwords() { [ -z "$_pw_admin" ] && error " ADMIN_PASSWORD — not found" [ -z "$_pw_signature" ] && error " SIGNATURE — not found" echo "" - error "Either create a .env file (./docker-setup.sh) or export the" + error "Either create a .env file (./${SCRIPT_NAME}) or export the" error "variables in your shell before running ${mode_label}." exit 1 fi @@ -262,42 +286,271 @@ prompt_value() { fi if [ -n "$default_value" ]; then - read -rp "${prompt_text} [${default_value}]: " result + read -rp "${prompt_text} [${default_value}]: " result || true echo "${result:-$default_value}" else - read -rp "${prompt_text}: " result + read -rp "${prompt_text}: " result || true echo "$result" fi } +# Confirm an action, auto-accepting in auto mode. +# Returns 0 (yes) or 1 (no). Usage: +# if confirm_action "Stop Compose services now?"; then ... +confirm_action() { + local prompt_text="$1" + if [ "${AUTO_MODE:-false}" = "true" ]; then + return 0 + fi + local _answer + read -rp "${prompt_text} [Y/n]: " _answer || true + [[ "${_answer,,}" =~ ^(y|)$ ]] +} + +# Write .env file using current variable values. +# Expects PORT, ADMIN_PASSWORD, DATOMIC_PASSWORD, SIGNATURE, EMAIL_*, ORCPUB_IMAGE, +# DATOMIC_IMAGE, INIT_ADMIN_* to be set in scope before calling. +write_env_file() { + cat > "$ENV_FILE" <<EOF +# ============================================================================ +# Dungeon Master's Vault — Docker Environment Configuration +# Generated by run on $(date -u +"%Y-%m-%d %H:%M:%S UTC") +# ============================================================================ + +# --- Application --- +PORT=${PORT} + +# --- Docker Images --- +# Set these to version your builds (e.g. orcpub-app:v2.6.0) +# Leave empty to use default names (orcpub-app, orcpub-datomic) +ORCPUB_IMAGE=${ORCPUB_IMAGE} +DATOMIC_IMAGE=${DATOMIC_IMAGE} + +# --- Datomic Database --- +# ADMIN_PASSWORD — internal storage admin. Controls who can create/delete +# databases on the transactor. Only the transactor uses it; the app never sees it. +# DATOMIC_PASSWORD — peer connection password. The transactor and app must share +# the same value. The app appends it to DATOMIC_URL at startup automatically. +# +# WARNING: Both passwords are locked into the database on first startup. +# Changing them later will prevent the transactor from starting (ADMIN_PASSWORD) +# or the app from connecting (DATOMIC_PASSWORD). Your data is NOT lost — set +# the password back, or use the _OLD vars below for graceful rotation. +ADMIN_PASSWORD=${ADMIN_PASSWORD} +DATOMIC_PASSWORD=${DATOMIC_PASSWORD} +DATOMIC_URL=datomic:dev://datomic:4334/orcpub + +# --- Transactor Tuning --- +# These rarely need changing. See docker/transactor.properties.template. +ALT_HOST=127.0.0.1 +ENCRYPT_CHANNEL=true +# Password rotation: set the OLD var to the previous password, then change the +# main var above. The transactor accepts both during the transition. Remove the +# OLD var after restarting and confirming everything works. +# ADMIN_PASSWORD_OLD= +# DATOMIC_PASSWORD_OLD= + +# --- Security --- +# Secret used to sign JWT tokens (20+ characters recommended) +SIGNATURE=${SIGNATURE} + +# --- Email (SMTP) --- +EMAIL_SERVER_URL=${EMAIL_SERVER_URL} +EMAIL_ACCESS_KEY=${EMAIL_ACCESS_KEY} +EMAIL_SECRET_KEY=${EMAIL_SECRET_KEY} +EMAIL_SERVER_PORT=${EMAIL_SERVER_PORT} +EMAIL_FROM_ADDRESS=${EMAIL_FROM_ADDRESS} +EMAIL_ERRORS_TO=${EMAIL_ERRORS_TO} +EMAIL_SSL=${EMAIL_SSL} +EMAIL_TLS=${EMAIL_TLS} + +# --- SSL (Nginx) --- +# Set to 'true' after running snakeoil.sh or providing your own certs +# SSL_CERT_PATH=./deploy/snakeoil.crt +# SSL_KEY_PATH=./deploy/snakeoil.key + +# --- Initial Admin User (optional) --- +# Set these to create an admin account on first run: +# ./docker-user.sh init +# Safe to run multiple times — duplicates are skipped. +INIT_ADMIN_USER=${INIT_ADMIN_USER} +INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL} +INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD} +EOF + chmod 600 "$ENV_FILE" + change ".env file created at ${ENV_FILE} (permissions: 600)" +} + +# Create required directories if they don't exist. +setup_directories() { + for dir in "${SCRIPT_DIR}/data" "${SCRIPT_DIR}/logs" "${SCRIPT_DIR}/backups" "${SCRIPT_DIR}/deploy/homebrew"; do + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + change "Created directory: ${dir#"${SCRIPT_DIR}"/}" + else + info "Directory exists: ${dir#"${SCRIPT_DIR}"/}" + fi + done +} + +# Generate self-signed SSL certificate if none exists. +setup_ssl_certs() { + local cert="${SCRIPT_DIR}/deploy/snakeoil.crt" + local key="${SCRIPT_DIR}/deploy/snakeoil.key" + if [ -f "$cert" ] && [ -f "$key" ]; then + info "SSL certificates already exist. Skipping generation." + elif command -v openssl &>/dev/null; then + info "Generating self-signed SSL certificate..." + openssl req \ + -subj "/C=US/ST=State/L=City/O=OrcPub/OU=Dev/CN=localhost" \ + -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$key" -out "$cert" 2>/dev/null + change "SSL certificate generated (valid for 365 days)." + else + warn "openssl not found — cannot generate SSL certificates." + warn "Install openssl and run: ./deploy/snakeoil.sh" + fi +} + +# Generate a fresh .env through prompts (interactive) or defaults (--auto). +# Also creates directories and SSL certs. Works in both modes because +# prompt_value() returns the default silently when AUTO_MODE=true. +generate_env() { + header "Database Passwords" + + DEFAULT_ADMIN_PW="$(generate_password 24)" + DEFAULT_DATOMIC_PW="$(generate_password 24)" + DEFAULT_SIGNATURE="$(generate_password 32)" + + echo "" + echo " Datomic uses two passwords (both locked into the DB on first startup):" + echo " Admin password — internal, controls database create/delete" + echo " Peer password — shared between transactor and app for connections" + + if [ -f "${SCRIPT_DIR}/data/db/datomic.mv.db" ]; then + echo "" + warn "Existing database found in data/db/." + warn "The admin password is locked into this database. If you set a new" + warn "password, the transactor will fail to start. Either:" + warn " 1. Keep the same admin password as before" + warn " 2. Wipe the database first: rm -rf data/db/*" + fi + + echo "" + ADMIN_PASSWORD=$(prompt_value "Admin password" "$DEFAULT_ADMIN_PW") + DATOMIC_PASSWORD=$(prompt_value "Peer password" "$DEFAULT_DATOMIC_PW") + SIGNATURE=$(prompt_value "JWT signing secret (20+ chars)" "$DEFAULT_SIGNATURE") + + header "Application" + + PORT=$(prompt_value "Application port" "8890") + + _image_tag=$(prompt_value "Image tag (leave empty for default)" "") + if [ -n "$_image_tag" ]; then + ORCPUB_IMAGE="orcpub-app:${_image_tag}" + DATOMIC_IMAGE="orcpub-datomic:${_image_tag}" + else + ORCPUB_IMAGE="" + DATOMIC_IMAGE="" + fi + EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") + EMAIL_ACCESS_KEY="" + EMAIL_SECRET_KEY="" + EMAIL_SERVER_PORT="587" + EMAIL_FROM_ADDRESS="" + EMAIL_ERRORS_TO="" + EMAIL_SSL="FALSE" + EMAIL_TLS="FALSE" + + if [ -n "$EMAIL_SERVER_URL" ]; then + EMAIL_ACCESS_KEY=$(prompt_value "SMTP username" "") + EMAIL_SECRET_KEY=$(prompt_value "SMTP password" "") + EMAIL_SERVER_PORT=$(prompt_value "SMTP port" "587") + EMAIL_FROM_ADDRESS=$(prompt_value "From email address" "no-reply@orcpub.com") + EMAIL_ERRORS_TO=$(prompt_value "Error notification email" "") + EMAIL_SSL=$(prompt_value "Use SSL? (TRUE/FALSE)" "FALSE") + EMAIL_TLS=$(prompt_value "Use TLS? (TRUE/FALSE)" "FALSE") + fi + + header "Initial Admin User" + + INIT_ADMIN_USER="${INIT_ADMIN_USER:-}" + INIT_ADMIN_EMAIL="${INIT_ADMIN_EMAIL:-}" + INIT_ADMIN_PASSWORD="${INIT_ADMIN_PASSWORD:-}" + + if [ -n "$INIT_ADMIN_USER" ] && [ -n "$INIT_ADMIN_EMAIL" ] && [ -n "$INIT_ADMIN_PASSWORD" ]; then + info "Using admin user from environment: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" + elif [ "${AUTO_MODE}" = "true" ]; then + INIT_ADMIN_USER="admin" + INIT_ADMIN_EMAIL="admin@localhost" + INIT_ADMIN_PASSWORD=$(generate_password 16) + change "Generated admin user: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" + change "Generated admin password: ${INIT_ADMIN_PASSWORD}" + info "Change these in .env before going to production." + else + info "Optionally create an initial admin account." + info "You can skip this and create users later with ./docker-user.sh" + echo "" + INIT_ADMIN_USER=$(prompt_value "Admin username (leave empty to skip)" "") + if [ -n "$INIT_ADMIN_USER" ]; then + _default_email="${INIT_ADMIN_USER}@example.com" + _default_pw="$(generate_password 16)" + INIT_ADMIN_EMAIL=$(prompt_value "Admin email" "$_default_email") + INIT_ADMIN_PASSWORD=$(prompt_value "Admin password" "$_default_pw") + if [ -z "$INIT_ADMIN_EMAIL" ] || [ -z "$INIT_ADMIN_PASSWORD" ]; then + warn "Email and password are required. Skipping admin user setup." + INIT_ADMIN_USER="" + INIT_ADMIN_EMAIL="" + INIT_ADMIN_PASSWORD="" + fi + fi + fi + + info "Writing .env file..." + write_env_file + setup_directories + setup_ssl_certs + echo "" +} + usage() { - cat <<'USAGE' -Usage: ./docker-setup.sh [OPTIONS] + cat <<USAGE +Usage: ./${SCRIPT_NAME} [OPTIONS] -Options: - --auto Non-interactive mode; accept all defaults - --force Overwrite existing .env file +No flags (default): + ./${SCRIPT_NAME} Full pipeline: setup -> build -> up (interactive) + ./${SCRIPT_NAME} --auto Same, non-interactive (CI/scripting) + +Individual steps: + --check Validate .env and environment (read-only) + --build Build Docker images only + --up Deploy as a Docker Swarm stack + --down Teardown (auto-detects Swarm vs Compose) + +Setup & secrets: + --secrets Convert .env passwords to Docker secret files + --swarm Convert .env passwords to Docker Swarm secrets --upgrade Update an existing .env to the latest format --upgrade-secrets Upgrade .env + create Docker secret files (one step) --upgrade-swarm Upgrade .env + create Swarm secrets (one step) - --secrets Convert .env passwords to Docker secret files - --swarm Convert .env passwords to Docker Swarm secrets - --build Build Docker images - --deploy Deploy as a Docker Swarm stack - --check Validate .env and environment (read-only, no changes) + +Modifiers: + --auto Non-interactive mode; accept all defaults + --force Overwrite existing .env file --help Show this help message +Swarm (flags compose together): + ./${SCRIPT_NAME} --swarm --auto --build --up # Zero to running stack + Examples: - ./docker-setup.sh # New install — interactive - ./docker-setup.sh --auto # New install — accept defaults - ./docker-setup.sh --upgrade # Existing install — fix old .env format - ./docker-setup.sh --upgrade-secrets # Upgrade + create Docker secret files - ./docker-setup.sh --upgrade-swarm # Upgrade + create Swarm secrets - ./docker-setup.sh --secrets # Switch passwords to secret files - ./docker-setup.sh --swarm # Switch passwords to Swarm secrets - ./docker-setup.sh --build # Build images - ./docker-setup.sh --deploy # Deploy Swarm stack - ./docker-setup.sh --build --deploy # Build + deploy + ./${SCRIPT_NAME} # Full pipeline — interactive + ./${SCRIPT_NAME} --auto # Full pipeline — accept defaults (CI) + ./${SCRIPT_NAME} --down # Stop everything + ./${SCRIPT_NAME} --check # Validate without changes + ./${SCRIPT_NAME} --build # Build images only + ./${SCRIPT_NAME} --upgrade # Fix old .env format + ./${SCRIPT_NAME} --swarm --auto # Generate .env + init Swarm + create secrets + ./${SCRIPT_NAME} --build --up # Build + deploy (Swarm only) USAGE } @@ -308,7 +561,8 @@ USAGE AUTO_MODE=false BUILD_MODE=false CHECK_MODE=false -DEPLOY_MODE=false +DOWN_MODE=false +UP_MODE=false FORCE_MODE=false SECRETS_MODE=false SWARM_MODE=false @@ -319,7 +573,8 @@ for arg in "$@"; do --auto) AUTO_MODE=true ;; --check) CHECK_MODE=true ;; --build) BUILD_MODE=true ;; - --deploy) DEPLOY_MODE=true ;; + --down) DOWN_MODE=true ;; + --up) UP_MODE=true ;; --force) FORCE_MODE=true ;; --secrets) SECRETS_MODE=true ;; --swarm) SWARM_MODE=true ;; @@ -335,6 +590,34 @@ for arg in "$@"; do esac done +# Conflict: --secrets and --swarm are mutually exclusive +if [ "$SECRETS_MODE" = "true" ] && [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then + error "--secrets and --swarm are mutually exclusive." + echo " --secrets writes secrets as local files (Compose)" + echo " --swarm stores secrets in the Swarm Raft log (Swarm)" + echo "" + echo "Pick one based on your deployment:" + echo " Compose → ./${SCRIPT_NAME} --secrets" + echo " Swarm → ./${SCRIPT_NAME} --swarm" + exit 1 +fi + +# Conflict: --down is standalone +if [ "$DOWN_MODE" = "true" ]; then + for _m in "$UP_MODE" "$BUILD_MODE" "$SECRETS_MODE" "$SWARM_MODE" "$UPGRADE_MODE"; do + if [ "$_m" = "true" ]; then + error "--down cannot be combined with other mode flags." + exit 1 + fi + done +fi + +# Detect naked mode (no mode flags set) +_any_mode=false +for _m in "$CHECK_MODE" "$SECRETS_MODE" "$SWARM_MODE" "$BUILD_MODE" "$UP_MODE" "$UPGRADE_MODE" "$DOWN_MODE"; do + [ "$_m" = "true" ] && _any_mode=true +done + # --------------------------------------------------------------------------- # Check mode (--check) — read-only validation # --------------------------------------------------------------------------- @@ -342,14 +625,14 @@ done # and reports shell env conflicts. Makes no changes. if [ "$CHECK_MODE" = "true" ]; then - header "Environment Check" + header "Dungeon Master's Vault — Environment Check" if [ ! -f "$ENV_FILE" ]; then warn "No .env file found." echo " 1) Interactive setup (prompts for each value)" echo " 2) Auto setup (generates random passwords, safe defaults)" echo " 3) Skip" - read -rp "Choice [1]: " _create + read -rp "Choice [1]: " _create || true case "${_create:-1}" in 1) exec "$0" ;; 2) exec "$0" --auto ;; @@ -399,19 +682,19 @@ if [ "$CHECK_MODE" = "true" ]; then _url=$(read_env_val DATOMIC_URL "$ENV_FILE") if [[ "$_url" == *"password="* ]]; then warn " DATOMIC_URL has embedded password (legacy format)" - warn " Run ./docker-setup.sh --upgrade to clean it" + warn " Run ./${SCRIPT_NAME} --upgrade to clean it" WARNING_MSGS+=("DATOMIC_URL has embedded password") ERRORS=$((ERRORS + 1)) fi if [[ "$_url" == *"datomic:free://"* ]]; then warn " DATOMIC_URL uses old Free protocol" - warn " Run ./docker-setup.sh --upgrade to convert to datomic:dev://" + warn " Run ./${SCRIPT_NAME} --upgrade to convert to datomic:dev://" WARNING_MSGS+=("DATOMIC_URL uses Free protocol") ERRORS=$((ERRORS + 1)) fi if [[ "$_url" == *"localhost"* ]]; then warn " DATOMIC_URL contains 'localhost' (should be 'datomic' for Docker)" - warn " Run ./docker-setup.sh --upgrade to fix" + warn " Run ./${SCRIPT_NAME} --upgrade to fix" WARNING_MSGS+=("DATOMIC_URL contains localhost") ERRORS=$((ERRORS + 1)) fi @@ -436,7 +719,7 @@ if [ "$CHECK_MODE" = "true" ]; then success "All checks passed" else warn "${ERRORS} issue(s) found." - read -rp "Run upgrade to fix? [Y/n]: " _fix + read -rp "Run upgrade to fix? [Y/n]: " _fix || true if [[ "${_fix,,}" =~ ^(y|)$ ]]; then exec "$0" --upgrade fi @@ -445,86 +728,30 @@ if [ "$CHECK_MODE" = "true" ]; then fi # --------------------------------------------------------------------------- -# Build mode (--build) — build Docker images +# Down mode (--down) — teardown running services # --------------------------------------------------------------------------- -if [ "$BUILD_MODE" = "true" ] && [ "$DEPLOY_MODE" != "true" ]; then - header "Build" +if [ "$DOWN_MODE" = "true" ]; then + header "Dungeon Master's Vault — Teardown" - if ! docker compose version &>/dev/null; then - error "docker compose is not available." - exit 1 - fi - - if [ ! -f "$ENV_FILE" ]; then - error "No .env file found. Run setup first." - exit 1 - fi - - info "Building images..." - docker compose build || { error "Build failed."; exit 1; } - - echo "" - success "Images built!" - exit 0 -fi - -# --------------------------------------------------------------------------- -# Deploy mode (--deploy) — deploy as a Docker Swarm stack -# --------------------------------------------------------------------------- - -if [ "$DEPLOY_MODE" = "true" ]; then - header "Swarm Deploy" - - if ! docker compose version &>/dev/null; then - error "docker compose is not available." - exit 1 - fi - - # Verify Swarm is active _swarm_state=$(docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null || true) - if [ "$_swarm_state" != "active" ]; then - error "Docker Swarm is not active. Run --swarm first." - exit 1 - fi - - if [ ! -f "$ENV_FILE" ]; then - error "No .env file found. Run setup first." - exit 1 - fi - - if ! command -v jq &>/dev/null; then - error "jq is required for --deploy. Install it with: apt-get install jq" - exit 1 - fi - if [ "$BUILD_MODE" = "true" ]; then - info "Building images..." - docker compose build || { error "Build failed."; exit 1; } - echo "" + if [ "$_swarm_state" = "active" ] && docker stack ls 2>/dev/null | grep -q orcpub; then + info "Swarm stack detected." + if confirm_action "Remove Swarm stack 'orcpub'?"; then + docker stack rm orcpub 2>&1 + change "Swarm stack removed." + fi + elif docker compose ps -q 2>/dev/null | head -1 | grep -q .; then + info "Compose services detected." + if confirm_action "Tear down Compose services?"; then + docker compose down 2>&1 + change "Compose services stopped." + fi + else + info "No running services found." fi - info "Deploying stack..." - # docker compose config outputs Compose Specification format; - # docker stack deploy expects legacy v3 schema. JSON + jq bridges the gap. - # See docs/kb/docker-swarm-compat.md for the full incompatibility list. - docker compose config --format json | jq ' - del(.name) | - .services |= with_entries( - .value.depends_on |= (if type == "object" then keys else . end) - ) | - .services |= with_entries( - .value.ports |= (if . then [.[] | .published |= tonumber] else . end) - ) - ' | docker stack deploy -c - orcpub || { error "Deploy failed."; exit 1; } - - echo "" - success "Stack deployed!" - echo "" - echo "Useful commands:" - echo " docker stack services orcpub # List services" - echo " docker stack ps orcpub # List tasks" - echo " docker service logs <service> # View logs" exit 0 fi @@ -536,14 +763,14 @@ fi # Auto-generates docker-compose.secrets.yaml and wires it via COMPOSE_FILE. if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then - header "Secrets Migration" + header "Dungeon Master's Vault — Secrets Migration" # Ask if they're running Swarm — different flow entirely if [ "${AUTO_MODE}" != "true" ]; then echo "This will create secret files on your machine (works with docker compose)." echo "If you're running Docker Swarm, secrets are stored in the cluster instead." echo "" - read -rp "Are you using Docker Swarm? [y/N]: " _is_swarm + read -rp "Are you using Docker Swarm? [y/N]: " _is_swarm || true if [[ "${_is_swarm,,}" == "y" ]]; then exec "$0" --swarm fi @@ -551,6 +778,11 @@ if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then SECRETS_DIR="${SCRIPT_DIR}/secrets" + # If no .env exists, generate one (enables one-step secrets setup) + if [ ! -f "$ENV_FILE" ]; then + generate_env + fi + read_passwords "--secrets" # Check for existing secrets (--auto implies --force here) @@ -562,9 +794,9 @@ if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then mkdir -p "$SECRETS_DIR" # Write each password to its own file (printf, not echo, to avoid trailing newline) - printf '%s' "$_pw_datomic" > "${SECRETS_DIR}/datomic_password" - printf '%s' "$_pw_admin" > "${SECRETS_DIR}/admin_password" - printf '%s' "$_pw_signature" > "${SECRETS_DIR}/signature" + printf '%s' "$_pw_datomic" > "${SECRETS_DIR}/datomic_password" || { error "Failed to write ${SECRETS_DIR}/datomic_password"; exit 1; } + printf '%s' "$_pw_admin" > "${SECRETS_DIR}/admin_password" || { error "Failed to write ${SECRETS_DIR}/admin_password"; exit 1; } + printf '%s' "$_pw_signature" > "${SECRETS_DIR}/signature" || { error "Failed to write ${SECRETS_DIR}/signature"; exit 1; } chmod 600 "${SECRETS_DIR}"/* change "Created secrets/datomic_password" @@ -577,6 +809,10 @@ if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then # Generate compose override so secrets are wired in automatically write_compose_secrets "file" + # Revert transactor host to compose-compatible binding if previously + # switched to Swarm mode (host=0.0.0.0). + switch_transactor_host "compose" + # Move secret vars from .env to a backup file so they aren't duplicated BACKUP_FILE="${ENV_FILE}.secrets.backup" SECRET_VARS="DATOMIC_PASSWORD|ADMIN_PASSWORD|SIGNATURE" @@ -606,29 +842,39 @@ fi # containers in memory — never written to disk on any node. if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then - header "Swarm Secrets" + header "Dungeon Master's Vault — Swarm Secrets" + + # Check for running Compose containers — they'll conflict with Swarm networking + _running=$(docker compose ps -q 2>/dev/null | head -1 || true) + if [ -n "$_running" ]; then + warn "Compose containers are running. These must be stopped before Swarm deploy." + if confirm_action "Stop Compose services now?"; then + docker compose down 2>&1 || true + change "Compose services stopped." + else + warn "Swarm deploy will fail if Compose networks overlap. Continuing anyway..." + fi + fi # Verify this node is part of a Swarm — offer to initialize if not _swarm_state=$(docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null || true) if [ "$_swarm_state" != "active" ]; then warn "This Docker node is not in Swarm mode." echo "" - if [ "${AUTO_MODE}" = "true" ]; then - info "Initializing single-node Swarm (--auto)..." + if confirm_action "Initialize a single-node Swarm now?"; then docker swarm init 2>&1 || { error "Failed to initialize Swarm."; exit 1; } change "Docker Swarm initialized." else - read -rp "Initialize a single-node Swarm now? [Y/n]: " _init_swarm - if [[ "${_init_swarm,,}" =~ ^(y|)$ ]]; then - docker swarm init 2>&1 || { error "Failed to initialize Swarm."; exit 1; } - change "Docker Swarm initialized." - else - info "Run 'docker swarm init' manually, then re-run: ./docker-setup.sh --swarm" - exit 0 - fi + info "Run 'docker swarm init' manually, then re-run: ./${SCRIPT_NAME} --swarm" + exit 0 fi fi + # If no .env exists, generate one (enables one-step swarm setup) + if [ ! -f "$ENV_FILE" ]; then + generate_env + fi + read_passwords "--swarm" # Check for existing secrets and handle accordingly @@ -656,48 +902,162 @@ if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then signature) _val="$_pw_signature" ;; esac - if printf '%s' "$_val" | docker secret create "$_name" - &>/dev/null; then - change "Created Swarm secret: $_name" - _created=$((_created + 1)) + if printf '%s' "$_val" | docker secret create "$_name" - 2>/dev/null; then + change "Created Swarm secret: $_name" + _created=$((_created + 1)) + else + error "Failed to create Swarm secret: $_name" + exit 1 + fi + done + + unset _pw_datomic _pw_admin _pw_signature _val + + echo "" + if [ "$_existing" -gt 0 ] && [ "$FORCE_MODE" = "false" ]; then + warn "${_existing} secret(s) already existed (skipped)." + fi + if [ "$_created" -gt 0 ]; then + change "${_created} Swarm secret(s) created." + fi + + # Generate compose override so secrets are wired in automatically + write_compose_secrets "external" + + # Switch transactor to Swarm-compatible host binding. + switch_transactor_host "swarm" + set_env_val ALT_HOST datomic "$ENV_FILE" + change "ALT_HOST=datomic (peer fallback via Docker DNS)" + + echo "" + success "Done! Swarm secrets created, compose is configured." + + # If --build or --up also specified, fall through to those blocks. + if [ "$BUILD_MODE" != "true" ] && [ "$UP_MODE" != "true" ]; then + echo "" + echo "Next steps:" + echo "" + printf ' %sBUILD%s images:\n' "$color_cyan" "$color_reset" + next " ./${SCRIPT_NAME} --build" + echo "" + printf ' %sDEPLOY%s as a Swarm stack:\n' "$color_cyan" "$color_reset" + next " ./${SCRIPT_NAME} --up" + echo "" + info "Or both in one step: ./${SCRIPT_NAME} --build --up" + echo "" + echo "--- Tip: Managing secrets later ---" + echo " docker secret ls # List all secrets" + echo " docker secret inspect <name> # Show metadata (not the value)" + echo " docker secret rm <name> # Remove (stop services first)" + exit 0 + fi +fi +# --------------------------------------------------------------------------- +# Build mode (--build) — build Docker images +# --------------------------------------------------------------------------- + +if [ "$BUILD_MODE" = "true" ] && [ "$UP_MODE" != "true" ]; then + header "Dungeon Master's Vault — Build" + + if ! docker compose version &>/dev/null; then + error "docker compose is not available." + exit 1 + fi + + if [ ! -f "$ENV_FILE" ]; then + error "No .env file found." + echo " Run setup first: ./${SCRIPT_NAME} [--auto]" + if confirm_action "Run setup now?"; then + generate_env else - error "Failed to create Swarm secret: $_name" exit 1 fi - done + fi - unset _pw_datomic _pw_admin _pw_signature _val + info "Building images..." + docker compose build || { error "Build failed."; exit 1; } echo "" - if [ "$_existing" -gt 0 ] && [ "$FORCE_MODE" = "false" ]; then - warn "${_existing} secret(s) already existed (skipped)." + success "Images built!" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Up mode (--up) — deploy as a Docker Swarm stack +# --------------------------------------------------------------------------- + +if [ "$UP_MODE" = "true" ]; then + header "Dungeon Master's Vault — Swarm Deploy" + + if ! docker compose version &>/dev/null; then + error "docker compose is not available." + exit 1 fi - if [ "$_created" -gt 0 ]; then - change "${_created} Swarm secret(s) created." + + # Verify Swarm is active + _swarm_state=$(docker info --format '{{.Swarm.LocalNodeState}}' 2>/dev/null || true) + if [ "$_swarm_state" != "active" ]; then + error "Docker Swarm is not active." + echo "" + echo "Options:" + echo " 1) Initialize Swarm: ./${SCRIPT_NAME} --swarm [--auto]" + echo " 2) Use Compose instead: docker compose up --build -d" + exit 1 fi - # Generate compose override so secrets are wired in automatically - write_compose_secrets "external" + if [ ! -f "$ENV_FILE" ]; then + error "No .env file found." + echo " Run setup first: ./${SCRIPT_NAME} [--auto]" + exit 1 + fi + + if ! command -v jq &>/dev/null; then + error "jq is required for --up. Install it with: apt-get install jq" + exit 1 + fi + + if [ "$BUILD_MODE" = "true" ]; then + info "Building images..." + docker compose build || { error "Build failed."; exit 1; } + echo "" + fi + + info "Deploying stack..." + # docker compose config resolves ${VAR:-default} from shell env first, then + # .env. Shell vars (e.g. Codespace DATOMIC_URL=localhost) override .env + # values meant for Docker. Unset known conflicts so .env wins. + _deploy_env=(DATOMIC_URL DATOMIC_PASSWORD SIGNATURE ADMIN_PASSWORD) + for _v in "${_deploy_env[@]}"; do unset "$_v" 2>/dev/null || true; done + + # docker compose config outputs Compose Specification format; + # docker stack deploy expects legacy v3 schema. JSON + jq bridges the gap. + # See docs/kb/docker-swarm-compat.md for the full incompatibility list. + docker compose config --format json | jq ' + del(.name) | + .services |= with_entries( + .value.depends_on |= (if type == "object" then keys else . end) + ) | + .services |= with_entries( + .value.ports |= (if . then [.[] | .published |= tonumber] else . end) + ) | + # Strip explicit nulls last — Swarm rejects null entrypoint/command/ports + # and |= on missing keys re-creates them as null + .services |= with_entries( + .value |= with_entries(select(.value != null)) + ) + ' | docker stack deploy -c - orcpub || { error "Deploy failed."; exit 1; } echo "" - success "Done! Swarm secrets created, compose is configured." - echo "" - echo "Next steps:" - echo "" - printf ' %sBUILD%s images:\n' "$color_cyan" "$color_reset" - next " ./docker-setup.sh --build" - echo "" - printf ' %sDEPLOY%s as a Swarm stack:\n' "$color_cyan" "$color_reset" - next " ./docker-setup.sh --deploy" - echo "" - info "Or both in one step: ./docker-setup.sh --build --deploy" + success "Stack deployed!" echo "" - echo "--- Tip: Managing secrets later ---" - echo " docker secret ls # List all secrets" - echo " docker secret inspect <name> # Show metadata (not the value)" - echo " docker secret rm <name> # Remove (stop services first)" + echo "Useful commands:" + echo " docker stack services orcpub # List services" + echo " docker stack ps orcpub # List tasks" + echo " docker service logs <service> # View logs" exit 0 fi + # --------------------------------------------------------------------------- # Upgrade existing .env (--upgrade mode) # --------------------------------------------------------------------------- @@ -705,7 +1065,7 @@ fi # variables. Backs up the original first. No data loss. if [ "$UPGRADE_MODE" = "true" ]; then - header "Upgrade .env" + header "Dungeon Master's Vault — Upgrade .env" if [ ! -f "$ENV_FILE" ]; then warn "No .env file found." @@ -754,7 +1114,7 @@ if [ "$UPGRADE_MODE" = "true" ]; then warn " Using transactor value (that's what the database is actually using)" echo "${_var}=${_tv}" >> "$ENV_FILE" else - read -rp " Use which? [1] compose [2] transactor (recommended): " _choice + read -rp " Use which? [1] compose [2] transactor (recommended): " _choice || true if [ "$_choice" = "1" ]; then echo "${_var}=${_cv}" >> "$ENV_FILE" change " Using compose value for ${_var}" @@ -777,7 +1137,7 @@ if [ "$UPGRADE_MODE" = "true" ]; then if [ "$_found" -eq 0 ]; then error "No hardcoded values found in docker-compose.yaml or transactor.properties." - error "For a new install, run: ./docker-setup.sh --auto" + error "For a new install, run: ./${SCRIPT_NAME} --auto" exit 1 fi @@ -839,7 +1199,7 @@ if [ "$UPGRADE_MODE" = "true" ]; then else warn " DATOMIC_PASSWORD is missing." warn " This should match what your transactor uses." - read -rp " Generate a random one? [Y/n]: " _gen + read -rp " Generate a random one? [Y/n]: " _gen || true if [[ "${_gen,,}" =~ ^(y|)$ ]]; then _new_pw="$(generate_password 24)" set_env_val DATOMIC_PASSWORD "$_new_pw" "$ENV_FILE" @@ -862,7 +1222,7 @@ if [ "$UPGRADE_MODE" = "true" ]; then warn " Note: Changing this later will log out all active users." else warn " SIGNATURE is missing (needed for login/API)." - read -rp " Generate a random one? [Y/n]: " _gen + read -rp " Generate a random one? [Y/n]: " _gen || true if [[ "${_gen,,}" =~ ^(y|)$ ]]; then _new_sig="$(generate_password 32)" set_env_val SIGNATURE "$_new_sig" "$ENV_FILE" @@ -884,7 +1244,7 @@ if [ "$UPGRADE_MODE" = "true" ]; then change " Generated new ADMIN_PASSWORD (random)" else warn " ADMIN_PASSWORD is missing." - read -rp " Generate a random one? [Y/n]: " _gen + read -rp " Generate a random one? [Y/n]: " _gen || true if [[ "${_gen,,}" =~ ^(y|)$ ]]; then _new_admin="$(generate_password 24)" set_env_val ADMIN_PASSWORD "$_new_admin" "$ENV_FILE" @@ -995,11 +1355,11 @@ if [ "$UPGRADE_MODE" = "true" ]; then if [ ! -d "${SCRIPT_DIR}/secrets" ]; then echo "" if [ "${AUTO_MODE}" = "true" ]; then - info "Tip: Run ./docker-setup.sh --upgrade-secrets or --upgrade-swarm to also" + info "Tip: Run ./${SCRIPT_NAME} --upgrade-secrets or --upgrade-swarm to also" info " move passwords out of .env in one step." else echo "" - read -rp "Move passwords to Docker secret files? (more secure) [y/N]: " _do_secrets + read -rp "Move passwords to Docker secret files? (more secure) [y/N]: " _do_secrets || true if [[ "${_do_secrets,,}" == "y" ]]; then exec "$0" --secrets fi @@ -1022,10 +1382,14 @@ if [ "$UPGRADE_MODE" = "true" ]; then fi # --------------------------------------------------------------------------- -# Main +# Main — setup, validate, and (in naked mode) build + start # --------------------------------------------------------------------------- -header "Docker Setup" +if [ "$_any_mode" = "false" ]; then + header "Dungeon Master's Vault — Full Setup & Deploy" +else + header "Dungeon Master's Vault — Docker Setup" +fi # ---- Step 1: .env file --------------------------------------------------- @@ -1037,190 +1401,10 @@ else source_env "$ENV_FILE" fi - header "Database Passwords" - - # Generate defaults but let user override - DEFAULT_ADMIN_PW="$(generate_password 24)" - DEFAULT_DATOMIC_PW="$(generate_password 24)" - DEFAULT_SIGNATURE="$(generate_password 32)" - - ADMIN_PASSWORD=$(prompt_value "Datomic admin password" "$DEFAULT_ADMIN_PW") - DATOMIC_PASSWORD=$(prompt_value "Datomic application password" "$DEFAULT_DATOMIC_PW") - SIGNATURE=$(prompt_value "JWT signing secret (20+ chars)" "$DEFAULT_SIGNATURE") - - header "Application" - - PORT=$(prompt_value "Application port" "8890") - - # Image tag — used to version builds (e.g. "v2.6.0") - _image_tag=$(prompt_value "Image tag (leave empty for default)" "") - if [ -n "$_image_tag" ]; then - ORCPUB_IMAGE="orcpub-app:${_image_tag}" - DATOMIC_IMAGE="orcpub-datomic:${_image_tag}" - else - ORCPUB_IMAGE="" - DATOMIC_IMAGE="" - fi - EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") - EMAIL_ACCESS_KEY="" - EMAIL_SECRET_KEY="" - EMAIL_SERVER_PORT="587" - EMAIL_FROM_ADDRESS="" - EMAIL_ERRORS_TO="" - EMAIL_SSL="FALSE" - EMAIL_TLS="FALSE" - - if [ -n "$EMAIL_SERVER_URL" ]; then - EMAIL_ACCESS_KEY=$(prompt_value "SMTP username" "") - EMAIL_SECRET_KEY=$(prompt_value "SMTP password" "") - EMAIL_SERVER_PORT=$(prompt_value "SMTP port" "587") - EMAIL_FROM_ADDRESS=$(prompt_value "From email address" "no-reply@orcpub.com") - EMAIL_ERRORS_TO=$(prompt_value "Error notification email" "") - EMAIL_SSL=$(prompt_value "Use SSL? (TRUE/FALSE)" "FALSE") - EMAIL_TLS=$(prompt_value "Use TLS? (TRUE/FALSE)" "FALSE") - fi - - header "Initial Admin User" - - # Check environment / existing .env for pre-set values - INIT_ADMIN_USER="${INIT_ADMIN_USER:-}" - INIT_ADMIN_EMAIL="${INIT_ADMIN_EMAIL:-}" - INIT_ADMIN_PASSWORD="${INIT_ADMIN_PASSWORD:-}" - - if [ -n "$INIT_ADMIN_USER" ] && [ -n "$INIT_ADMIN_EMAIL" ] && [ -n "$INIT_ADMIN_PASSWORD" ]; then - info "Using admin user from environment: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" - elif [ "${AUTO_MODE}" = "true" ]; then - INIT_ADMIN_USER="admin" - INIT_ADMIN_EMAIL="admin@localhost" - INIT_ADMIN_PASSWORD=$(generate_password 16) - change "Generated admin user: ${INIT_ADMIN_USER} <${INIT_ADMIN_EMAIL}>" - change "Generated admin password: ${INIT_ADMIN_PASSWORD}" - info "Change these in .env before going to production." - else - info "Optionally create an initial admin account." - info "You can skip this and create users later with ./docker-user.sh" - echo "" - INIT_ADMIN_USER=$(prompt_value "Admin username (leave empty to skip)" "") - if [ -n "$INIT_ADMIN_USER" ]; then - _default_email="${INIT_ADMIN_USER}@example.com" - _default_pw="$(generate_password 16)" - INIT_ADMIN_EMAIL=$(prompt_value "Admin email" "$_default_email") - INIT_ADMIN_PASSWORD=$(prompt_value "Admin password" "$_default_pw") - if [ -z "$INIT_ADMIN_EMAIL" ] || [ -z "$INIT_ADMIN_PASSWORD" ]; then - warn "Email and password are required. Skipping admin user setup." - INIT_ADMIN_USER="" - INIT_ADMIN_EMAIL="" - INIT_ADMIN_PASSWORD="" - fi - fi - fi - - info "Writing .env file..." - - cat > "$ENV_FILE" <<EOF -# ============================================================================ -# Docker Environment Configuration -# Generated by docker-setup.sh on $(date -u +"%Y-%m-%d %H:%M:%S UTC") -# ============================================================================ - -# --- Application --- -PORT=${PORT} - -# --- Docker Images --- -# Set these to version your builds (e.g. orcpub-app:v2.6.0) -# Leave empty to use default names (orcpub-app, orcpub-datomic) -ORCPUB_IMAGE=${ORCPUB_IMAGE} -DATOMIC_IMAGE=${DATOMIC_IMAGE} - -# --- Datomic Database --- -# ADMIN_PASSWORD secures the Datomic admin interface -# DATOMIC_PASSWORD is shared by transactor and app — the app appends it -# to DATOMIC_URL automatically at startup (no need to embed in the URL) -ADMIN_PASSWORD=${ADMIN_PASSWORD} -DATOMIC_PASSWORD=${DATOMIC_PASSWORD} -DATOMIC_URL=datomic:dev://datomic:4334/orcpub - -# --- Transactor Tuning --- -# These rarely need changing. See docker/transactor.properties.template. -ALT_HOST=127.0.0.1 -ENCRYPT_CHANNEL=true -# ADMIN_PASSWORD_OLD= -# DATOMIC_PASSWORD_OLD= - -# --- Security --- -# Secret used to sign JWT tokens (20+ characters recommended) -SIGNATURE=${SIGNATURE} - -# --- Email (SMTP) --- -EMAIL_SERVER_URL=${EMAIL_SERVER_URL} -EMAIL_ACCESS_KEY=${EMAIL_ACCESS_KEY} -EMAIL_SECRET_KEY=${EMAIL_SECRET_KEY} -EMAIL_SERVER_PORT=${EMAIL_SERVER_PORT} -EMAIL_FROM_ADDRESS=${EMAIL_FROM_ADDRESS} -EMAIL_ERRORS_TO=${EMAIL_ERRORS_TO} -EMAIL_SSL=${EMAIL_SSL} -EMAIL_TLS=${EMAIL_TLS} - -# --- SSL (Nginx) --- -# Set to 'true' after running snakeoil.sh or providing your own certs -# SSL_CERT_PATH=./deploy/snakeoil.crt -# SSL_KEY_PATH=./deploy/snakeoil.key - -# --- Initial Admin User (optional) --- -# Set these to create an admin account on first run: -# ./docker-user.sh init -# Safe to run multiple times — duplicates are skipped. -INIT_ADMIN_USER=${INIT_ADMIN_USER} -INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL} -INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD} -EOF - - chmod 600 "$ENV_FILE" - change ".env file created at ${ENV_FILE} (permissions: 600)" -fi - -# ---- Step 2: Directories ------------------------------------------------- - -header "Directories" - -for dir in "${SCRIPT_DIR}/data" "${SCRIPT_DIR}/logs" "${SCRIPT_DIR}/backups" "${SCRIPT_DIR}/deploy/homebrew"; do - if [ ! -d "$dir" ]; then - mkdir -p "$dir" - change "Created directory: ${dir#"${SCRIPT_DIR}"/}" - else - info "Directory exists: ${dir#"${SCRIPT_DIR}"/}" - fi -done - -# ---- Step 3: SSL certificates -------------------------------------------- - -header "SSL Certificates" - -CERT_FILE="${SCRIPT_DIR}/deploy/snakeoil.crt" -KEY_FILE="${SCRIPT_DIR}/deploy/snakeoil.key" - -if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then - info "SSL certificates already exist. Skipping generation." -else - if command -v openssl &>/dev/null; then - info "Generating self-signed SSL certificate..." - openssl req \ - -subj "/C=US/ST=State/L=City/O=OrcPub/OU=Dev/CN=localhost" \ - -x509 \ - -nodes \ - -days 365 \ - -newkey rsa:2048 \ - -keyout "$KEY_FILE" \ - -out "$CERT_FILE" \ - 2>/dev/null - change "SSL certificate generated (valid for 365 days)." - else - warn "openssl not found — cannot generate SSL certificates." - warn "Install openssl and run: ./deploy/snakeoil.sh" - fi + generate_env fi -# ---- Step 4: Validation -------------------------------------------------- +# ---- Step 2: Validation -------------------------------------------------- header "Validation" @@ -1261,8 +1445,8 @@ fi check_file ".env" "$ENV_FILE" check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" check_file "nginx.conf.template" "${SCRIPT_DIR}/deploy/nginx.conf.template" -check_file "SSL certificate" "$CERT_FILE" -check_file "SSL key" "$KEY_FILE" +check_file "SSL certificate" "${SCRIPT_DIR}/deploy/snakeoil.crt" +check_file "SSL key" "${SCRIPT_DIR}/deploy/snakeoil.key" check_dir "data/" "${SCRIPT_DIR}/data" check_dir "logs/" "${SCRIPT_DIR}/logs" check_dir "backups/" "${SCRIPT_DIR}/backups" @@ -1276,13 +1460,54 @@ else info "All checks passed!" fi -# ---- Step 5: Next steps --------------------------------------------------- +# ---- Step 3: Build + Start (naked mode only) -------------------------------- + +if [ "$_any_mode" = "false" ]; then + + # Build + if confirm_action "Build Docker images?"; then + info "Building images..." + docker compose build || { error "Build failed."; exit 1; } + echo "" + change "Images built successfully." + else + info "Skipping build." + fi + + # Start + if confirm_action "Start services?"; then + COMPOSE_CMD=$(build_compose_cmd "docker compose up -d") + info "Starting services..." + eval "$COMPOSE_CMD" || { error "Failed to start services."; exit 1; } + echo "" + change "Services started." + echo "" + echo "Wait for healthy (app takes ~2 minutes to boot):" + echo " docker compose ps" + echo "" + echo "Create your first user:" + echo " ./docker-user.sh init # uses INIT_ADMIN_* from .env" + echo " ./docker-user.sh create <username> <email> <password> # or specify directly" + echo "" + echo "Access the site at:" + echo " https://localhost" + else + info "Skipping start." + COMPOSE_CMD=$(build_compose_cmd "docker compose up --build -d") + echo "" + echo "To start manually:" + printf ' %s\n' "$COMPOSE_CMD" + fi + +else + + # ---- Upgrade fall-through: show next steps only ---------------------------- -header "Next Steps" + header "Next Steps" -COMPOSE_CMD=$(build_compose_cmd "docker compose up --build -d") + COMPOSE_CMD=$(build_compose_cmd "docker compose up --build -d") -cat <<NEXT + cat <<NEXT 1. Review your .env file and adjust values if needed. 2. Build and launch: @@ -1311,6 +1536,8 @@ cat <<NEXT For more details, see docs/DOCKER.md NEXT +fi + # ---- Final status banner ---------------------------------------------------- echo "" @@ -1327,6 +1554,8 @@ if [ "$ERRORS" -gt 0 ]; then echo "" else success "SUCCESS — ready to launch" - printf ' %s\n' "$COMPOSE_CMD" + if [ "$_any_mode" = "true" ]; then + printf ' %s\n' "$COMPOSE_CMD" + fi echo "" fi diff --git a/scripts/common.sh b/scripts/common.sh index f7ace7e1b..c59da0336 100755 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -23,10 +23,11 @@ REPO_ROOT="${REPO_ROOT:-$(cd "$COMMON_DIR/.." && pwd)}" # ----------------------------------------------------------------------------- # Source .env if present (authoritative config) +# tr -d '\r' strips Windows line endings so values don't silently include \r if [[ -f "$REPO_ROOT/.env" ]]; then set -a # shellcheck disable=SC1091 - . "$REPO_ROOT/.env" + . <(tr -d '\r' < "$REPO_ROOT/.env") set +a fi @@ -438,7 +439,7 @@ show_startup_failure() { get_datomic_port_from_config() { local config="$1" if [[ -f "$config" ]]; then - grep -E '^port=' "$config" 2>/dev/null | cut -d= -f2 || echo "$DATOMIC_PORT" + grep -E '^port=' "$config" 2>/dev/null | cut -d= -f2 | tr -d '\r' || echo "$DATOMIC_PORT" else echo "$DATOMIC_PORT" fi diff --git a/src/clj/orcpub/config.clj b/src/clj/orcpub/config.clj index 6d4471f28..f7cbc1064 100644 --- a/src/clj/orcpub/config.clj +++ b/src/clj/orcpub/config.clj @@ -1,22 +1,53 @@ (ns orcpub.config (:require [environ.core :refer [env]] - [clojure.string :as str])) + [clojure.string :as str] + [clojure.java.io :as io])) (def default-datomic-uri "datomic:dev://localhost:4334/orcpub") +(defn read-secret + "Read a Docker secret from /run/secrets/<name>, or nil if not mounted. + Trims trailing whitespace (secret files often end with a newline)." + [name] + (let [f (io/file "/run/secrets" name)] + (when (.exists f) + (not-empty (str/trim (slurp f)))))) + (defn datomic-env "Return the raw DATOMIC_URL environment value or nil if unset." [] (or (env :datomic-url) (some-> (System/getenv "DATOMIC_URL") not-empty))) +(defn datomic-password + "Return DATOMIC_PASSWORD from Docker secret, env var, or nil. + Resolution order: /run/secrets/datomic_password > DATOMIC_PASSWORD env var." [] + (or (read-secret "datomic_password") + (env :datomic-password) + (some-> (System/getenv "DATOMIC_PASSWORD") not-empty))) + +(defn signature + "Return SIGNATURE from Docker secret, env var, or nil. + Resolution order: /run/secrets/signature > SIGNATURE env var." [] + (or (read-secret "signature") + (env :signature) + (some-> (System/getenv "SIGNATURE") not-empty))) + (defn get-datomic-uri "Return the Datomic URI from the environment or the default. Prefers the raw env value (from `datomic-env`), otherwise returns a safe - local development default (datomic:dev://localhost:4334/orcpub)." + local development default (datomic:dev://localhost:4334/orcpub). + + If the URL does not contain a ?password= parameter and DATOMIC_PASSWORD + is set, appends it automatically. This allows admins to keep the password + out of DATOMIC_URL (e.g. for Docker secrets) while remaining backward + compatible with URLs that embed the password." [] - (or (datomic-env) - default-datomic-uri)) + (let [url (or (datomic-env) default-datomic-uri) + pw (datomic-password)] + (if (and pw (not (str/includes? url "password="))) + (str url "?password=" pw) + url))) ;; Content Security Policy configuration ;; CSP_POLICY environment variable options: diff --git a/src/clj/orcpub/csp.clj b/src/clj/orcpub/csp.clj index f4f2e15d5..7e07f8370 100644 --- a/src/clj/orcpub/csp.clj +++ b/src/clj/orcpub/csp.clj @@ -26,14 +26,12 @@ Options: :dev-mode? - When true, adds ws://localhost:3449 to connect-src - for Figwheel hot-reload WebSocket support. - :extra-connect-src - Seq of additional connect-src origins (from integrations). - :extra-frame-src - Seq of additional frame-src origins (from integrations). + :extra-connect-src - Seq of additional connect-src origins (from integrations) + :extra-frame-src - Seq of additional frame-src origins (from integrations) The resulting CSP: - Uses 'strict-dynamic' for script-src (only nonced scripts execute) - Allows Google Fonts for styles and fonts - - Merges integration domains into connect-src and frame-src - Restricts all other sources to 'self' - Blocks object embeds, restricts base-uri, frame-ancestors, and form-action" [nonce & {:keys [dev-mode? extra-connect-src extra-frame-src]}] diff --git a/src/clj/orcpub/db/schema.clj b/src/clj/orcpub/db/schema.clj index 06ed3c833..a7061c1bf 100644 --- a/src/clj/orcpub/db/schema.clj +++ b/src/clj/orcpub/db/schema.clj @@ -150,7 +150,16 @@ :db/cardinality :db.cardinality/one} {:db/ident :orcpub.user/following :db/valueType :db.type/ref - :db/cardinality :db.cardinality/many}]) + :db/cardinality :db.cardinality/many} + {:db/ident :orcpub.user/patron + :db/valueType :db.type/boolean + :db/cardinality :db.cardinality/one} + {:db/ident :orcpub.user/patron-tier + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} + {:db/ident :orcpub.user/last-login + :db/valueType :db.type/instant + :db/cardinality :db.cardinality/one}]) (def entity-schema [{:db/ident ::se/key diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 56faeef81..c6e542e62 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -89,7 +89,10 @@ :port (environ/env :email-server-port)} e))))) -(defn emailfrom [] +(defn emailfrom + "Returns the configured from-address. Delegates to branding/email-from-address + which already reads EMAIL_FROM_ADDRESS env var with a fallback default." + [] branding/email-from-address) (defn send-verification-email diff --git a/src/clj/orcpub/index.clj b/src/clj/orcpub/index.clj index b48268fdf..be80b49ba 100644 --- a/src/clj/orcpub/index.clj +++ b/src/clj/orcpub/index.clj @@ -1,11 +1,11 @@ (ns orcpub.index (:require [hiccup.page :refer [html5 include-css]] + [cheshire.core :as cheshire] [orcpub.oauth :as oauth] + [orcpub.fork.branding :as branding] [orcpub.dnd.e5.views-2 :as views-2] [orcpub.favicon :as fi] [orcpub.fork.integrations :as integrations] - [orcpub.fork.branding :as branding] - [cheshire.core :as cheshire] [environ.core :refer [env]])) (def homebrew-url @@ -46,6 +46,13 @@ (meta-tag "og:title" title) (meta-tag "og:description" description) (meta-tag "og:image" image) + (meta-tag "og:site_name" branding/app-name) + (meta-tag "og:type" "website") + (meta-tag "twitter:card" "summary_large_image") + (meta-tag "twitter:site" branding/app-name) + (meta-tag "twitter:title" title) + (meta-tag "twitter:description" description) + (meta-tag "twitter:image" image) [:meta {:charset "UTF-8"}] [:meta {:name "viewport" :content "width=device-width, initial-scale=1.0, minimum-scale=1.0"}] @@ -63,7 +70,7 @@ .splash-button .splash-button-content {height: 120px; width: 120px} .splash-button .svg-icon {height: 64px; width: 64px} -@media (max-width: 767px) +@media (max-width: 767px) {.splash-button .svg-icon {height: 32px; width: 32px} .splash-button-title-prefix {display: none} .splash-button .splash-button-content {height: 60px; width: 60px; font-size: 10px} @@ -88,7 +95,7 @@ b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, -article, aside, canvas, details, figcaption, figure, +article, aside, canvas, details, figcaption, figure, footer, header, hgroup, menu, nav, section, summary, time, mark, audio, video { margin: 0; @@ -100,7 +107,7 @@ time, mark, audio, video { vertical-align: baseline; } /* HTML5 display-role reset for older browsers */ -article, aside, details, figcaption, figure, +article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section { display: block; } diff --git a/src/clj/orcpub/pdf.clj b/src/clj/orcpub/pdf.clj index 97a89b565..eab84b466 100644 --- a/src/clj/orcpub/pdf.clj +++ b/src/clj/orcpub/pdf.clj @@ -25,7 +25,8 @@ [orcpub.common :as common] [orcpub.dnd.e5.display :as dis5e] [orcpub.dnd.e5.monsters :as monsters] - [orcpub.dnd.e5.options :as options]) + [orcpub.dnd.e5.options :as options] + [clj-http.client :as client]) (:import (org.apache.pdfbox.pdmodel.interactive.form PDCheckBox PDTextField) (org.apache.pdfbox.pdmodel PDPage PDDocument PDPageContentStream PDResources) ;; PDFBox 3.x: AppendMode enum replaces boolean flags in PDPageContentStream constructor diff --git a/src/clj/orcpub/security.clj b/src/clj/orcpub/security.clj index 35b8966cc..7d79bf692 100644 --- a/src/clj/orcpub/security.clj +++ b/src/clj/orcpub/security.clj @@ -87,6 +87,6 @@ (>= 3))) (defn multiple-ip-attempts-to-same-account? [username] - (multiple-ip-attempts-to-same-account-aux + multiple-ip-attempts-to-same-account-aux username - @failed-login-attempts-by-username)) + @failed-login-attempts-by-username) diff --git a/src/clj/orcpub/styles/core.clj b/src/clj/orcpub/styles/core.clj index 5ffbf7cbc..d868a8068 100644 --- a/src/clj/orcpub/styles/core.clj +++ b/src/clj/orcpub/styles/core.clj @@ -302,13 +302,19 @@ {:height "72px"}] [:.h-120 {:height "120px"}] + [:.h-170 + {:height "170px"}] [:.h-200 {:height "200px"}] [:.h-800 {:height "800px"}] + [:.h-10-p + {:height "10%"}] [:.h-100-p {:height "100%"}] + [:.h-auto + {:height "auto"}] [:.overflow-auto {:overflow :auto}] @@ -330,9 +336,11 @@ {:color "#191919"}] [:.orange {:color button-color} - [:a :a:visited {:color button-color}]] + [:.a-white + [:a :a:visited + {:color "white !important"}]] [:.green {:color green} @@ -423,6 +431,8 @@ {:border-radius "50%"}] [:.b-rad-5 {:border-radius "5px"}] + [:.b-rad-10 + {:border-radius "10px"}] [:.b-1 {:border "1px solid"}] @@ -470,6 +480,11 @@ :position "absolute" :z-index "1"}]] + [:.image-thumbnail + {:max-height "100px" + :max-width "200px" + :border-radius "5px"}] + [:.tooltip:hover [:.tooltiptext {:visibility "visible"}]] @@ -477,7 +492,7 @@ {:max-height "100px" :max-width "200px" :border-radius "5px"}] - + [:.image-faction-thumbnail {:max-height "100px" :max-width "200px" @@ -950,14 +965,14 @@ [:.app-header {:background-color :black :background-image "url(/../../image/header-background.jpg)" - :background-position "right center" + :background-position "center" :background-size "cover" :height (px const/header-height)}] [:.header-tab {:background-color "rgba(0, 0, 0, 0.5)" - :-webkit-backdrop-filter "blur(3px)" - :backdrop-filter "blur(3px)" + :-webkit-backdrop-filter "blur(5px)" + :backdrop-filter "blur(5px)" :border-radius "5px"}] [:.header-tab.mobile @@ -1326,7 +1341,7 @@ [:.text-shadow {:text-shadow :none}] - + [:.bg-light {:background-color "rgba(0,0,0,0.4)"}] [:.bg-lighter diff --git a/src/cljc/orcpub/constants.cljc b/src/cljc/orcpub/constants.cljc index 32ac59919..951602341 100644 --- a/src/cljc/orcpub/constants.cljc +++ b/src/cljc/orcpub/constants.cljc @@ -1,3 +1,3 @@ (ns orcpub.constants) -(def header-height 227) +(def header-height 320) diff --git a/src/cljc/orcpub/dnd/e5/template.cljc b/src/cljc/orcpub/dnd/e5/template.cljc index d5f9cc617..550d1085f 100644 --- a/src/cljc/orcpub/dnd/e5/template.cljc +++ b/src/cljc/orcpub/dnd/e5/template.cljc @@ -1325,7 +1325,7 @@ content] frame]) -(def srd-url "/SRD-OGL_V5.1.pdf") +(def srd-url "/dnld/SRD-OGL_V5.1.pdf") (def srd-link [:a.orange {:href srd-url :target "_blank"} "the 5e SRD"]) diff --git a/src/cljc/orcpub/dnd/e5/templates/ua_base.cljc b/src/cljc/orcpub/dnd/e5/templates/ua_base.cljc index 43a97d93d..8f41e36ed 100644 --- a/src/cljc/orcpub/dnd/e5/templates/ua_base.cljc +++ b/src/cljc/orcpub/dnd/e5/templates/ua_base.cljc @@ -1,5 +1,5 @@ (ns orcpub.dnd.e5.templates.ua-base - #_(:require [orcpub.template :as t] + (:require [orcpub.template :as t] [orcpub.common :as common] [orcpub.modifiers :as mods] [orcpub.entity-spec :as es] @@ -23,7 +23,7 @@ [orcpub.dnd.e5.templates.ua-skill-feats :as ua-skill-feats] [orcpub.dnd.e5.templates.ua-revised-class-options :as ua-revised-class-options] [orcpub.dnd.e5.templates.ua-warlock-and-wizard :as ua-warlock-and-wizard] - [re-frame.core :refer [subscribe]])) + #_[re-frame.core :refer [subscribe]])) #_(defn ua-help [name url] [:a {:href url :target :_blank} name]) diff --git a/src/cljc/orcpub/pdf_spec.cljc b/src/cljc/orcpub/pdf_spec.cljc index 61cf71186..0e27daed1 100644 --- a/src/cljc/orcpub/pdf_spec.cljc +++ b/src/cljc/orcpub/pdf_spec.cljc @@ -213,6 +213,42 @@ (apply dissoc treasure coin-keys) unequipped-items)))}))) +(defn treasure-fields [built-char] + (let [equipment (es/entity-val built-char :equipment) + armor (es/entity-val built-char :armor) + magic-armor (es/entity-val built-char :magic-armor) + magic-items (es/entity-val built-char :magic-items) + weapons (sort (es/entity-val built-char :weapons)) + magic-weapons (sort (es/entity-val built-char :magic-weapons)) + custom-equipment (into {} + (map + (juxt ::char-equip5e/name identity) + (char5e/custom-equipment built-char))) + custom-treasure (into {} + (map + (juxt ::char-equip5e/name identity) + (char5e/custom-treasure built-char))) + all-equipment (merge equipment custom-equipment custom-treasure magic-items armor magic-armor) + treasure (es/entity-val built-char :treasure) + treasure-map (into {} (map (fn [[kw {qty ::char-equip5e/quantity}]] [kw qty]) treasure)) + unequipped-items (filter + (fn [[kw {:keys [::char-equip5e/equipped? ::char-equip5e/quantity]}]] + (and (not equipped?) + (pos? quantity))) + (merge all-equipment weapons magic-weapons magic-items))] + (merge + (select-keys treasure-map coin-keys) + {:treasure (s/join + "\n" + (map + (fn [[kw {count ::char-equip5e/quantity}]] + (str (disp5e/equipment-name mi5e/all-equipment-map kw) " (" count ")")) + (merge + (apply dissoc treasure coin-keys) + unequipped-items)))}))) + + + (def level-max-spells {0 8 1 12 diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index fdf29b11e..3d581752d 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -148,7 +148,7 @@ "cookies-policy" cookies-policy-route "following/users" {["/" :user] follow-user-route} - + "dnd/" {"5e/" {"characters" {"" dnd-e5-char-list-route ["/" :id] dnd-e5-char-route} diff --git a/src/cljs/orcpub/character_builder.cljs b/src/cljs/orcpub/character_builder.cljs index 6253d4e3a..26db04d31 100644 --- a/src/cljs/orcpub/character_builder.cljs +++ b/src/cljs/orcpub/character_builder.cljs @@ -36,7 +36,6 @@ [orcpub.dnd.e5.db :as db] [orcpub.dnd.e5.views :as views5e] [orcpub.dnd.e5.subs :as subs5e] - [orcpub.fork.branding :as branding] [orcpub.route-map :as routes] [orcpub.pdf-spec :as pdf-spec] [orcpub.user-agent :as user-agent] @@ -46,6 +45,7 @@ [clojure.core.match :refer [match]] [reagent.core :as r] + [orcpub.fork.branding :as branding] [orcpub.fork.integrations :as integrations] [re-frame.core :refer [subscribe dispatch dispatch-sync]])) ;console-print @@ -125,20 +125,30 @@ (def update-value-field (memoize update-value-field-fn)) -(defn character-field [entity-values prop-name type & [cls-str handler input-type]] +(defn character-field-255 [entity-values prop-name type & [cls-str handler input-type]] + [comps/input-field + type + (get entity-values prop-name) + (update-value-field prop-name) + {:type input-type + :maxLength "255" + :class-name (str "input w-100-p " cls-str)}]) + +(defn character-field-50000 [entity-values prop-name type & [cls-str handler input-type]] [comps/input-field type (get entity-values prop-name) (update-value-field prop-name) {:type input-type - :class (str "input w-100-p " cls-str)}]) + :maxLength "50000" + :class-name (str "input w-100-p " cls-str)}]) (defn character-input [entity-values prop-name & [cls-str handler type]] - [character-field entity-values prop-name :input cls-str handler type]) + [character-field-255 entity-values prop-name :input cls-str handler type]) (defn character-textarea [entity-values prop-name & [cls-str]] - [character-field entity-values prop-name :textarea cls-str]) + [character-field-50000 entity-values prop-name :textarea cls-str]) (defn prereq-failures [option] (remove @@ -499,7 +509,7 @@ (when (and content selected?) content) (when explanation-text - [:div.i.f-s-12.f-w-n + [:div.i.f-s-12.f-w-n explanation-text])]]]))) (defn skill-help [name key ability icon description] @@ -860,7 +870,7 @@ {:class (when (and (not ability-disabled?) (zero? (ability-increases k 0))) "opacity-5")} - (ability-value (ability-increases k 0))] + (ability-value (ability-increases k 0))] [:div.f-s-16 [:i.fa.fa-minus-circle.orange {:class (when decrease-disabled? "opacity-5 cursor-disabled") @@ -1083,7 +1093,7 @@ {:value (when (abilities k) (total-abilities k)) :type :number - :on-change (fn [e] (let [total (total-abilities k) + :on-change (fn [e] (let [total (total-abilities k) value (.-value (.-target e)) diff (- total (abilities k)) @@ -1744,9 +1754,6 @@ remaining)]))) sorted-selections)))])])]])) -(def image-style - {:max-height "100px" - :max-width "200px"}) (defn set-random-name "Dispatch random name generation. Passes built-char so the handler can @@ -1837,7 +1844,7 @@ :on-error (image-error :failed-loading-image image-url) :on-load (when image-url-failed image-loaded)}]) [:div.flex-grow-1 - [:span.personality-label.f-s-18 "Image URL"] + [:span.personality-label.f-s-18 "Image URL (128k max image size for PDF)"] [character-input entity-values ::char5e/image-url nil set-image-url] (when image-url-failed [:div.red.m-t-5 "Image failed to load, please check the URL"])]] @@ -1851,7 +1858,7 @@ :on-load (when faction-image-url-failed faction-image-loaded)}]) [:div.flex-grow-1 - [:span.personality-label.f-s-18 "Faction Image URL"] + [:span.personality-label.f-s-18 "Faction Image URL (128k max image size for PDF)"] [character-input entity-values ::char5e/faction-image-url nil set-faction-image-url] (when faction-image-url-failed [:div.red.m-t-5 "Image failed to load, please check the URL"])]] @@ -2136,11 +2143,8 @@ {:confirm-button-text "CREATE CLONE" :question "You have unsaved changes, are you sure you want to discard them and clone this character? The new character will have the unsaved changes, the original will not." :event [::char5e/clone-character]})} - {:title "Print" - :icon "print" - :on-click (views5e/make-print-handler (:db/id character) built-char)} {:title (if (:db/id character) - "Update Existing Character" + "Save" "Save New Character") :icon "save" :style (when character-changed? unsaved-button-style) @@ -2148,7 +2152,10 @@ (when (:db/id character) {:title "View" :icon "eye" - :on-click (load-character-page (:db/id character))})])) + :on-click (load-character-page (:db/id character))}) + {:title "Export" + :icon "download" + :on-click (views5e/make-print-handler (:db/id character) built-char)}])) [:div [:div.container [:div.content diff --git a/src/cljs/orcpub/dnd/e5/db.cljs b/src/cljs/orcpub/dnd/e5/db.cljs index 2d1ad8d4b..51f8d50ee 100644 --- a/src/cljs/orcpub/dnd/e5/db.cljs +++ b/src/cljs/orcpub/dnd/e5/db.cljs @@ -278,8 +278,10 @@ (spec/def ::email string?) (spec/def ::token string?) (spec/def ::theme string?) +(spec/def ::patron string?) ; patron +(spec/def ::patron-tier string?) ; patron-tier (spec/def ::user-data (spec/keys :req-un [::username ::email])) -(spec/def ::user (spec/keys :opt-un [::user-data ::token ::theme])) +(spec/def ::user (spec/keys :opt-un [::user-data ::token ::theme ::patron ::patron-tier])) (reg-local-store-cofx :local-store-user diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 6bc7b9fa1..98bea8c97 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -77,10 +77,11 @@ [bidi.bidi :as bidi] [orcpub.route-map :as routes] [orcpub.errors :as errors] + [orcpub.fork.integrations :as integrations] + [orcpub.fork.branding :as branding] [clojure.set :as sets] [cljsjs.filesaverjs] - [clojure.pprint :as pprint] - [orcpub.fork.integrations :as integrations]) + [clojure.pprint :as pprint]) (:require-macros [cljs.core.async.macros :refer [go]])) ;; ============================================================================= @@ -161,7 +162,7 @@ encounter->local-store-interceptor]) (def combat-interceptors [(path ::combat/tracker-item) - combat->local-store-interceptor]) + combat->local-store-interceptor]) (def background-interceptors [(path ::bg5e/builder-item) background->local-store-interceptor]) @@ -173,13 +174,13 @@ invocation->local-store-interceptor]) (def boon-interceptors [(path ::class5e/boon-builder-item) - boon->local-store-interceptor]) + boon->local-store-interceptor]) (def selection-interceptors [(path ::selections5e/builder-item) - selection->local-store-interceptor]) + selection->local-store-interceptor]) (def feat-interceptors [(path ::feats5e/builder-item) - feat->local-store-interceptor]) + feat->local-store-interceptor]) (def race-interceptors [(path ::race5e/builder-item) race->local-store-interceptor]) @@ -191,10 +192,10 @@ class->local-store-interceptor]) (def subclass-interceptors [(path ::class5e/subclass-builder-item) - subclass->local-store-interceptor]) + subclass->local-store-interceptor]) (def plugins-interceptors [(path :plugins) - plugins->local-store-interceptor]) + plugins->local-store-interceptor]) ;; -- Event Handlers -------------------------------------------------- @@ -287,13 +288,13 @@ (def selection-randomizers {:ability-scores (fn [s _] (fn [_] {::entity/key :standard-roll - ::entity/value (char5e/standard-ability-rolls)})) + ::entity/value (char5e/standard-ability-rolls)})) :hit-points (fn [{[_ class-kw] ::entity/path} built-char] (fn [_] (random-hit-points-option (char5e/levels built-char) class-kw)))}) #_ ;; unreferenced — random-character loop hardcodes 10 -(def max-iterations 100) + (def max-iterations 100) (defn keep-options [built-template entity option-paths] (reduce @@ -353,7 +354,7 @@ {:dispatch [:set-random-character character built-template locked-components]})) #_ ;; unreferenced — character path is constructed inline -(def dnd-5e-characters-path [:dnd :e5 :characters]) + (def dnd-5e-characters-path [:dnd :e5 :characters]) (reg-event-fx :character-save-success @@ -803,14 +804,14 @@ ::char5e/parties-map (common/map-by-id parties))}))) (reg-event-fx - ::party5e/make-empty-party - (fn [{:keys [db]} [_]] - {:dispatch [:set-loading true] - :http {:method :post - :headers (authorization-headers db) - :url (url-for-route routes/dnd-e5-char-parties-route) - :transit-params {::party5e/name "A New Party"} - :on-success [::party5e/make-empty-party-success]}})) + ::party5e/make-empty-party + (fn [{:keys [db]} [_]] + {:dispatch [:set-loading true] + :http {:method :post + :headers (authorization-headers db) + :url (url-for-route routes/dnd-e5-char-parties-route) + :transit-params {::party5e/name "A New Party"} + :on-success [::party5e/make-empty-party-success]}})) (reg-event-fx ::party5e/rename-party @@ -1299,15 +1300,15 @@ nil))) #_ ;; never dispatched from UI -(reg-event-db - :toggle-public - character-interceptors - (fn [character _] - (update character - ::entity/values - update - ::char5e/share? - not))) + (reg-event-db + :toggle-public + character-interceptors + (fn [character _] + (update character + ::entity/values + update + ::char5e/share? + not))) (reg-event-db :set-faction-image-url @@ -1498,7 +1499,7 @@ character path (fn [skills] - (if selected? + (if selected? (vec (remove (fn [s] (= skill-key (::entity/key s))) skills)) (vec (conj skills {::entity/key skill-key})))))) @@ -1576,8 +1577,8 @@ flat-params (flatten seq-params) path (apply routes/path-for (or handler new-route) flat-params)] (when (and js/window.location - secure? - (not= "localhost" js/window.location.hostname)) + secure? + (not= "localhost" js/window.location.hostname)) (set! js/window.location.href (make-url "https" js/window.location.hostname path @@ -1608,7 +1609,7 @@ (fn [{:keys [db]} _] (if (:token (:user-data db)) (do (go (let [response (<! (http/get (url-for-route routes/user-route) - {:headers (authorization-headers db)}))] + {:headers (authorization-headers db)}))] (case (:status response) 200 nil 401 (do (dispatch [:clear-login]) @@ -1623,13 +1624,13 @@ (assoc db :user user-data))) #_ ;; never dispatched from UI -(defn set-active-tabs [db [_ active-tabs]] - (assoc-in db tab-path active-tabs)) + (defn set-active-tabs [db [_ active-tabs]] + (assoc-in db tab-path active-tabs)) #_ ;; never dispatched from UI -(reg-event-db - :set-active-tabs - set-active-tabs) + (reg-event-db + :set-active-tabs + set-active-tabs) (defn set-loading "Loading is a counter, not a boolean. true increments, false decrements. @@ -1797,10 +1798,10 @@ {:db (update db :user-data merge (-> response :body)) :dispatch [:route (or (:return-route db) - routes/dnd-e5-char-builder-route)]})) + routes/dnd-e5-char-builder-route)]})) (defn show-old-account-message [] - [:show-login-message [:div "There is no account for the email or username, please double-check it. Usernames and passwords are case sensitive, email addresses are not. You can also try to " [:a {:href (routes/path-for routes/register-page-route)} "register"] "." ]]) + [:show-login-message [:div "There is no account for the email or username, please double-check it. Usernames and passwords are case sensitive, email addresses are not. You can also try to " [:a {:href (routes/path-for routes/register-page-route)} "register"] "."]]) (defn dispatch-login-failure [message] {:dispatch-n [[:clear-login] @@ -1814,13 +1815,17 @@ (= error-code errors/username-required) (dispatch-login-failure "Username is required.") (= error-code errors/too-many-attempts) (dispatch-login-failure "You have made too many login attempts, you account is locked for 15 minutes. Please do not try to login again until 15 minutes have passed.") (= error-code errors/password-required) (dispatch-login-failure "Password is required.") - (= error-code errors/bad-credentials) (dispatch-login-failure "Password is incorrect.") + (= error-code errors/bad-credentials) (dispatch-login-failure "Password is incorrect.") (= error-code errors/no-account) {:dispatch-n [[:clear-login] (show-old-account-message)]} (= error-code errors/unverified) {:db (assoc db :temp-email (-> response :body :email)) :dispatch [:route routes/verify-sent-route]} (= error-code errors/unverified-expired) {:dispatch [:route routes/verify-failed-route]} - :else (dispatch-login-failure [:div "A login error occurred."]))))) + :else (dispatch-login-failure + (if (seq branding/support-email) + [:div "An error occurred. If the problem persists please email " + [:a {:href (str "mailto:" branding/support-email) :target :blank} branding/support-email]] + [:div "An error occurred. Please try again later."])))))) (reg-event-fx :logout @@ -1862,7 +1867,8 @@ {:dispatch [:clear-login]})) #_ ;; dead stub — real impl is orcpub.registration/validate-registration -(defn validate-registration []) + (defn validate-registration []) + (reg-event-db :email-taken @@ -1875,10 +1881,10 @@ (assoc db :username-taken? (-> response :body (= "true"))))) #_ ;; never dispatched — registration form uses :register-first-and-last-name -(reg-event-db - :registration-first-and-last-name - (fn [db [_ first-and-last-name]] - (assoc-in db [:registration-form :first-and-last-name] first-and-last-name))) + (reg-event-db + :registration-first-and-last-name + (fn [db [_ first-and-last-name]] + (assoc-in db [:registration-form :first-and-last-name] first-and-last-name))) (reg-event-fx :registration-email @@ -1908,10 +1914,10 @@ (assoc-in db [:registration-form :send-updates?] send-updates?))) #_ ;; never dispatched from UI -(reg-event-db - :register-first-and-last-name - (fn [db [_ first-and-last-name]] - (assoc-in db [:registration-form :first-and-last-name] first-and-last-name))) + (reg-event-db + :register-first-and-last-name + (fn [db [_ first-and-last-name]] + (assoc-in db [:registration-form :first-and-last-name] first-and-last-name))) (reg-event-fx :check-email @@ -1979,21 +1985,21 @@ ;; never dispatched — character loading uses :load-user-data flow #_(reg-event-db - :load-characters-success - (fn [db [_ response]] - (assoc-in db [:dnd :e5 :characters] (:body response)))) + :load-characters-success + (fn [db [_ response]] + (assoc-in db [:dnd :e5 :characters] (:body response)))) (defn get-auth-token [db] (-> db :user-data :token)) #_ ;; never dispatched — character loading uses :load-user-data flow -(reg-event-fx - :load-characters - (fn [{:keys [db]} [_ params]] - {:http {:method :get - :auth-token (get-auth-token db) - :url (backend-url (routes/path-for routes/dnd-e5-char-list-route)) - :on-success [:load-characters-success]}})) + (reg-event-fx + :load-characters + (fn [{:keys [db]} [_ params]] + {:http {:method :get + :auth-token (get-auth-token db) + :url (backend-url (routes/path-for routes/dnd-e5-char-list-route)) + :on-success [:load-characters-success]}})) (reg-event-db :password-reset-success @@ -2227,10 +2233,10 @@ :login-message message))) #_ ;; never dispatched from UI -(reg-event-db - :hide-warning - (fn [db _] - (assoc db :warning-hidden true))) + (reg-event-db + :hide-warning + (fn [db _] + (assoc db :warning-hidden true))) (reg-event-db :hide-confirmation @@ -2260,12 +2266,12 @@ :sex sex})}))) #_ ;; unreferenced -(defn remove-subtypes [subtypes hidden-subtypes] - (let [result (sets/difference subtypes hidden-subtypes)] - result)) + (defn remove-subtypes [subtypes hidden-subtypes] + (let [result (sets/difference subtypes hidden-subtypes)] + result)) #_ ;; orphaned re-export alias — callers use compute/compute-plugin-vals directly -(def compute-plugin-vals compute/compute-plugin-vals) + (def compute-plugin-vals compute/compute-plugin-vals) (def compute-sorted-spells compute/compute-sorted-spells) (def compute-sorted-items compute/compute-sorted-items) (def filter-by-name-xform compute/filter-by-name-xform) @@ -2323,11 +2329,11 @@ (dissoc :search-text)))) #_ ;; never dispatched from UI (note: "orcacle" typo) -(reg-event-fx - :open-orcacle-over-character-builder - (fn [] - {:dispatch-n [[:route routes/dnd-e5-char-builder-route] - [:open-orcacle]]})) + (reg-event-fx + :open-orcacle-over-character-builder + (fn [] + {:dispatch-n [[:route routes/dnd-e5-char-builder-route] + [:open-orcacle]]})) (reg-event-db :open-orcacle @@ -2347,10 +2353,10 @@ (assoc db ::char5e/builder-tab tab))) (reg-event-db - ::char5e/sort-monsters - (fn [db [_ sort-criteria sort-direction]] - (assoc db ::char5e/monster-sort-criteria sort-criteria - ::char5e/monster-sort-direction sort-direction))) + ::char5e/sort-monsters + (fn [db [_ sort-criteria sort-direction]] + (assoc db ::char5e/monster-sort-criteria sort-criteria + ::char5e/monster-sort-direction sort-direction))) (reg-event-db ::char5e/filter-monsters @@ -2377,8 +2383,8 @@ (assoc db ::char5e/item-text-filter filter-text ::char5e/filtered-items (if (>= (count filter-text) 3) - (filter-items filter-text sorted) - sorted))))) + (filter-items filter-text sorted) + sorted))))) (reg-event-db ::char5e/toggle-selected @@ -2529,15 +2535,15 @@ (defn toggle-feature-used [character units nm] (-> character - (update-in - [::entity/values - ::char5e/features-used - units] - (partial toggle-set nm)) - (dissoc - [::entity/values - ::char5e/features-used - :db/id]))) + (update-in + [::entity/values + ::char5e/features-used + units] + (partial toggle-set nm)) + (dissoc + [::entity/values + ::char5e/features-used + :db/id]))) (reg-event-fx ::char5e/toggle-feature-used @@ -2569,17 +2575,17 @@ ::units5e/rest))) (reg-event-fx - ::char5e/finish-short-rest-warlock - (fn [{:keys [db]} [_ id]] - (clear-period db - id - (fn [character] - (update - character - ::entity/values - dissoc - ::spells/slots-used)) - ::units5e/rest))) + ::char5e/finish-short-rest-warlock + (fn [{:keys [db]} [_ id]] + (clear-period db + id + (fn [character] + (update + character + ::entity/values + dissoc + ::spells/slots-used)) + ::units5e/rest))) (reg-event-fx ::char5e/finish-short-rest @@ -2635,11 +2641,11 @@ "light-theme"))))) #_ ;; never dispatched from UI -(reg-event-db - ::mi/set-builder-item - [magic-item->local-store-interceptor] - (fn [db [_ magic-item]] - (assoc db ::mi/builder-item magic-item))) + (reg-event-db + ::mi/set-builder-item + [magic-item->local-store-interceptor] + (fn [db [_ magic-item]] + (assoc db ::mi/builder-item magic-item))) (reg-event-db ::mi/toggle-attunement @@ -2865,16 +2871,16 @@ :removed-conditions (map :type removed-conditions)}) individuals)) (:monster-data updated)))))] - {:dispatch-n (cond-> [[::combat/set-combat updated]] - (seq removed-conditions) - (conj [:show-message - [:div.m-t-5.f-w-b.f-s-18 - (doall - (map-indexed - (fn [i {:keys [name index removed-conditions]}] - ^{:key i} - [:div.m-b-5 (str name " #" (inc index) " is no longer " (common/list-print (map common/kw-to-name removed-conditions) "or") ".")]) - removed-conditions))]]))}))) + {:dispatch-n (cond-> [[::combat/set-combat updated]] + (seq removed-conditions) + (conj [:show-message + [:div.m-t-5.f-w-b.f-s-18 + (doall + (map-indexed + (fn [i {:keys [name index removed-conditions]}] + ^{:key i} + [:div.m-b-5 (str name " #" (inc index) " is no longer " (common/list-print (map common/kw-to-name removed-conditions) "or") ".")]) + removed-conditions))]]))}))) (reg-event-db ::encounters/set-encounter-path-prop @@ -3080,11 +3086,11 @@ (assoc-in subclass [class-spells-key level index] spell-kw))) #_ ;; never dispatched from UI -(reg-event-db - ::class5e/set-spell-list - subclass-interceptors - (fn [subclass [_ class-kw]] - (assoc-in subclass [:spellcasting :spell-list] class-kw))) + (reg-event-db + ::class5e/set-spell-list + subclass-interceptors + (fn [subclass [_ class-kw]] + (assoc-in subclass [:spellcasting :spell-list] class-kw))) (reg-event-db ::feats5e/set-feat-prop @@ -3093,11 +3099,11 @@ (assoc feat prop-key prop-value))) #_ ;; never dispatched from UI -(reg-event-db - ::bg5e/set-feature-prop - background-interceptors - (fn [background [_ prop-key prop-value]] - (assoc-in background [:traits 0 prop-key] prop-value))) + (reg-event-db + ::bg5e/set-feature-prop + background-interceptors + (fn [background [_ prop-key prop-value]] + (assoc-in background [:traits 0 prop-key] prop-value))) (reg-event-db ::feats5e/toggle-feat-prop @@ -3106,11 +3112,11 @@ (update-in feat [:props key] not))) #_ ;; never dispatched from UI — feat builder uses toggle-feat-prop instead -(reg-event-db - ::feats5e/toggle-feat-selection - feat-interceptors - (fn [feat [_ key]] - (update-in feat [:selections key] not))) + (reg-event-db + ::feats5e/toggle-feat-selection + feat-interceptors + (fn [feat [_ key]] + (update-in feat [:selections key] not))) (reg-event-db ::feats5e/toggle-feat-value-prop @@ -3132,29 +3138,29 @@ subrace-interceptors (fn [subrace [_ key num]] (update subrace :props (fn [m] - (if (= (get m key) num) - (dissoc m key) - (assoc m key num)))))) + (if (= (get m key) num) + (dissoc m key) + (assoc m key num)))))) #_ ;; never dispatched — class/subclass builder UI not wired for value-prop toggles -(reg-event-db - ::class5e/toggle-subclass-value-prop - subclass-interceptors - (fn [subclass [_ key num]] - (update subclass :props (fn [m] - (if (= (get m key) num) - (dissoc m key) - (assoc m key num)))))) + (reg-event-db + ::class5e/toggle-subclass-value-prop + subclass-interceptors + (fn [subclass [_ key num]] + (update subclass :props (fn [m] + (if (= (get m key) num) + (dissoc m key) + (assoc m key num)))))) #_ ;; never dispatched — class builder UI not wired for value-prop toggles -(reg-event-db - ::class5e/toggle-class-value-prop - class-interceptors - (fn [class [_ key num]] - (update class :props (fn [m] - (if (= (get m key) num) - (dissoc m key) - (assoc m key num)))))) + (reg-event-db + ::class5e/toggle-class-value-prop + class-interceptors + (fn [class [_ key num]] + (update class :props (fn [m] + (if (= (get m key) num) + (dissoc m key) + (assoc m key num)))))) (reg-event-db ::feats5e/toggle-feat-map-prop @@ -3181,16 +3187,16 @@ (update-in class prop-path not))) #_ ;; never dispatched — class builder UI not wired for prof toggles -(reg-event-db - ::class5e/toggle-class-prof - class-interceptors - (fn [class [_ prop-path]] - (let [v (get-in class prop-path)] - ;; for classes, the value for a prof signals whether - ;; it only applies to the first class a character takes - (if (= v false) - (common/dissoc-in class prop-path) - (assoc-in class prop-path false))))) + (reg-event-db + ::class5e/toggle-class-prof + class-interceptors + (fn [class [_ prop-path]] + (let [v (get-in class prop-path)] + ;; for classes, the value for a prof signals whether + ;; it only applies to the first class a character takes + (if (= v false) + (common/dissoc-in class prop-path) + (assoc-in class prop-path false))))) (reg-event-db ::class5e/toggle-subclass-path-prop @@ -3217,25 +3223,27 @@ (update-in race [:props key value] not))) #_ ;; never dispatched — class builder UI not wired for subclass map-prop toggles -(reg-event-db - ::class5e/toggle-subclass-map-prop - subclass-interceptors - (fn [subclass [_ key value]] - (update-in subclass [:props key value] not))) + (reg-event-db + ::class5e/toggle-subclass-map-prop + subclass-interceptors + (fn [subclass [_ key value]] + (update-in subclass [:props key value] not))) #_ ;; never dispatched — class builder UI not wired for class map-prop toggles -(reg-event-db - ::class5e/toggle-class-map-prop - class-interceptors - (fn [class [_ key value]] - (update-in class [:props key value] not))) + (reg-event-db + ::class5e/toggle-class-map-prop + class-interceptors + (fn [class [_ key value]] + (update-in class [:props key value] not))) #_ ;; never dispatched — background builder UI not wired for map-prop toggles -(reg-event-db - ::bg5e/toggle-background-map-prop - background-interceptors - (fn [background [_ key value]] - (update-in background [:props key value] not))) + (reg-event-db + ::bg5e/toggle-background-map-prop + background-interceptors + (fn [background [_ key value]] + (update-in background [:props key value] not))) + + (reg-event-db ::feats5e/toggle-feat-ability-increase @@ -3561,7 +3569,7 @@ (set-value item ::mi/magical-ac-bonus bonus))) #_ ;; orphaned re-export aliases — all callers use event-utils/ directly now -(def mod-cfg event-utils/mod-cfg) + (def mod-cfg event-utils/mod-cfg) #_(def mod-key event-utils/mod-key) #_(def compare-mod-keys event-utils/compare-mod-keys) #_(def default-mod-set event-utils/default-mod-set) @@ -3622,7 +3630,7 @@ (js/saveAs blob (str name ".orcbrew")) (if (seq (:warnings validation)) {:dispatch [:show-warning-message - (str "Plugin '" name "' exported with warnings. Check console for details.")]} + (str "Plugin '" name "' exported with warnings. Check console for details.")]} {}))) ;; Other validation failure - don't export @@ -3631,7 +3639,7 @@ (js/console.error "Export validation failed for" name ":") (js/console.error (:errors validation)) {:dispatch [:show-error-message - (str "Cannot export '" name "' - contains invalid data. Check console for details.")]}))))) + (str "Cannot export '" name "' - contains invalid data. Check console for details.")]}))))) ;; Export warning modal events (reg-event-db @@ -3670,9 +3678,9 @@ (let [all-plugins (:plugins db) ;; Validate each plugin validations (into {} - (map (fn [[name plugin]] - [name (import-val/validate-before-export plugin)]) - all-plugins)) + (map (fn [[name plugin]] + [name (import-val/validate-before-export plugin)]) + all-plugins)) has-errors (some (fn [[_ v]] (not (:valid v))) validations) has-warnings (some (fn [[_ v]] (seq (:warnings v))) validations)] @@ -3686,7 +3694,7 @@ (if has-errors {:dispatch [:show-error-message - "Cannot export all plugins - some contain invalid data. Check console for details."]} + "Cannot export all plugins - some contain invalid data. Check console for details."]} (let [blob (js/Blob. (clj->js [(str all-plugins)]) @@ -3694,26 +3702,40 @@ (js/saveAs blob "all-content.orcbrew") (if has-warnings {:dispatch [:show-warning-message - "All plugins exported with some warnings. Check console for details."]} + "All plugins exported with some warnings. Check console for details."]} {})))))) + +(defn clj->json + [ds] + (.stringify js/JSON (clj->js ds) nil 2)) + (reg-event-fx - ::e5/export-plugin-pretty-print - (fn [_ [_ name plugin]] - (let [blob (js/Blob. - (clj->js [(with-out-str (pprint/pprint plugin))]) - (clj->js {:type "text/plain;charset=utf-8"}))] - (js/saveAs blob (str name ".orcbrew")) - {}))) + ::e5/save-to-json + (fn [_ [_ name plugin]] + (let [blob (js/Blob. + (clj->js [(clj->json plugin)]) + (clj->js {:type "application/json;charset=utf-8"}))] + (js/saveAs blob (str name ".json")) + {}))) + +(reg-event-fx + ::e5/export-plugin-pretty-print + (fn [_ [_ name plugin]] + (let [blob (js/Blob. + (clj->js [(with-out-str (pprint/pprint plugin))]) + (clj->js {:type "text/plain;charset=utf-8"}))] + (js/saveAs blob (str name ".orcbrew")) + {}))) ;; Export all homebrew plugins as pretty-printed .orcbrew file. (reg-event-fx - ::e5/export-all-plugins-pretty-print - (fn [{:keys [db]} _] - (let [blob (js/Blob. - (clj->js [(with-out-str (pprint/pprint (:plugins db)))]) - (clj->js {:type "text/plain;charset=utf-8"}))] - (js/saveAs blob "all-content.orcbrew") - {}))) + ::e5/export-all-plugins-pretty-print + (fn [{:keys [db]} _] + (let [blob (js/Blob. + (clj->js [(with-out-str (pprint/pprint (:plugins db)))]) + (clj->js {:type "text/plain;charset=utf-8"}))] + (js/saveAs blob "all-content.orcbrew") + {}))) (reg-event-fx ::e5/delete-plugin @@ -3792,7 +3814,7 @@ :import-source-name plugin-name}) user-message (import-val/format-import-result result) has-conflicts? (or (seq (get-in result [:key-conflicts :internal-conflicts])) - (seq (get-in result [:key-conflicts :external-conflicts])))] + (seq (get-in result [:key-conflicts :external-conflicts])))] ;; Log detailed results to console for debugging (js/console.log "Import validation result:" (clj->js result)) @@ -3834,7 +3856,7 @@ (:success result) (let [plugin (:data result) is-multi-plugin (and (spec/valid? ::e5/plugins plugin) - (not (spec/valid? ::e5/plugin plugin)))] + (not (spec/valid? ::e5/plugin plugin)))] ;; Log skipped items if any (when (:had-errors result) @@ -3884,7 +3906,7 @@ {:dispatch-n [[::e5/set-plugins (if (= :multi-plugin (:strategy result)) (e5/merge-all-plugins (:plugins db) plugin) (assoc (:plugins db) plugin-name plugin))] - [:show-warning-message user-message]]}) + [:show-warning-message user-message]]}) {:dispatch [:show-error-message user-message]})))) @@ -4571,10 +4593,10 @@ (assoc db ::char5e/options-shown? false))) #_ ;; never dispatched — print UI not wired -(reg-event-db - ::char5e/toggle-character-sheet-print - (fn [db _] - (update db ::char5e/exclude-character-sheet-print? not))) + (reg-event-db + ::char5e/toggle-character-sheet-print + (fn [db _] + (update db ::char5e/exclude-character-sheet-print? not))) (reg-event-db ::char5e/toggle-spell-cards-print @@ -4582,10 +4604,10 @@ (update db ::char5e/exclude-spell-cards-print? not))) #_ ;; never dispatched — print UI not wired -(reg-event-db - ::char5e/toggle-spell-cards-by-level - (fn [db _] - (update db ::char5e/exclude-spell-cards-by-level? not))) + (reg-event-db + ::char5e/toggle-spell-cards-by-level + (fn [db _] + (update db ::char5e/exclude-spell-cards-by-level? not))) (reg-event-db ::char5e/toggle-spell-cards-by-dc-mod @@ -4710,18 +4732,18 @@ weapon-kw)))) #_ ;; never dispatched — attunement UI not wired -(reg-event-fx - ::char5e/attune-magic-item - (fn [{:keys [db]} [_ id i weapon-kw]] - (update-character-fx db id #(update-in - % - [::entity/values - ::char5e/attuned-magic-items] - (fn [items] - (assoc - (or items [:none :none :none]) - i - weapon-kw)))))) + (reg-event-fx + ::char5e/attune-magic-item + (fn [{:keys [db]} [_ id i weapon-kw]] + (update-character-fx db id #(update-in + % + [::entity/values + ::char5e/attuned-magic-items] + (fn [items] + (assoc + (or items [:none :none :none]) + i + weapon-kw)))))) (reg-event-db :close-srd-message diff --git a/src/cljs/orcpub/ver.cljc b/src/cljs/orcpub/ver.cljc index 3610839fd..7b8ed922a 100644 --- a/src/cljs/orcpub/ver.cljc +++ b/src/cljs/orcpub/ver.cljc @@ -11,6 +11,6 @@ (.format (java.time.LocalDate/now (java.time.ZoneId/of tz)) (java.time.format.DateTimeFormatter/ofPattern "MM-dd-yyyy"))))) -(defn version [] "2.4.0.28") +(defn version [] "2.6.0.0") (defn date [] (build-date)) -(defn description [] "Assault of the Last Stand") \ No newline at end of file +(defn description [] "Liberation of the Iron Coder - tinkan's last stand") \ No newline at end of file diff --git a/test/docker/README.md b/test/docker/README.md index 937cefb5d..99bf665a7 100644 --- a/test/docker/README.md +++ b/test/docker/README.md @@ -1,6 +1,6 @@ # Docker Setup Tests -Manual and automated tests for `docker-setup.sh` and `docker-user.sh`. +Manual and automated tests for `run` and `docker-user.sh`. ## Scripts @@ -38,15 +38,15 @@ Test `.env` files representing real-world configurations: ```bash # New install ./test/docker/reset-test.sh fresh -./docker-setup.sh --auto +./run --auto docker compose up --build -d ./docker-user.sh init # Upgrade + secrets ./test/docker/reset-test.sh upgrade -./docker-setup.sh --upgrade-secrets --auto +./run --upgrade-secrets --auto # Upgrade + swarm (conflict detection) ./test/docker/reset-test.sh conflict -./docker-setup.sh --upgrade-swarm --auto +./run --upgrade-swarm --auto ``` diff --git a/test/docker/reset-test.sh b/test/docker/reset-test.sh index cf3d3d7dc..66ba01fd8 100755 --- a/test/docker/reset-test.sh +++ b/test/docker/reset-test.sh @@ -19,6 +19,41 @@ rm -f .env .env.backup.* .env.secrets.backup rm -rf secrets/ docker-compose.secrets.yaml docker secret rm datomic_password admin_password signature 2>/dev/null || true git checkout docker-compose.yaml 2>/dev/null || true +git checkout docker/transactor.properties.template 2>/dev/null || true + +# H2 database has ADMIN_PASSWORD locked in at creation. A fresh .env with +# new passwords will crash the transactor unless the DB is wiped or backed up. +if [ -f data/db/datomic.mv.db ]; then + echo "" + echo "Existing H2 database found in data/db/." + echo "The admin password is locked into this database." + echo "" + echo " 1) Back up to data/db.bak/ and wipe (can restore later)" + echo " 2) Wipe data/db/ (permanent)" + echo " 3) Keep it (next test may fail if passwords don't match)" + echo "" + printf "Choice [1]: " + read -r _choice </dev/tty 2>/dev/null || _choice="1" + _choice="${_choice:-1}" + case "$_choice" in + 1) + rm -rf data/db.bak + mv data/db data/db.bak + mkdir -p data/db + echo "Moved data/db/ → data/db.bak/" + ;; + 2) + rm -rf data/db/* + echo "Wiped data/db/" + ;; + 3) + echo "Keeping existing database" + ;; + *) + echo "Invalid choice, keeping existing database" + ;; + esac +fi case "$scenario" in fresh) diff --git a/test/docker/test-upgrade.sh b/test/docker/test-upgrade.sh index 1de6ed5d4..c64f30273 100755 --- a/test/docker/test-upgrade.sh +++ b/test/docker/test-upgrade.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# Test docker-setup.sh --upgrade against historical .env formats. +# Test run --upgrade against historical .env formats. # # Copies each fixture to a temp directory as .env, runs --upgrade --auto, # then validates the result. No Docker daemon needed — only tests the @@ -15,7 +15,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" FIXTURE_DIR="${SCRIPT_DIR}/fixtures" -SETUP_SCRIPT="${PROJECT_ROOT}/docker-setup.sh" +SETUP_SCRIPT="${PROJECT_ROOT}/run" # Colors green='\033[0;32m' From 73a9246eb4cd391d1005f12cf72f8b7faf78cb35 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 5 Mar 2026 05:04:49 +0000 Subject: [PATCH 42/50] fix: update remaining docker-setup.sh references to run 12 files still referenced the old script name in comments, error messages, and documentation. --- .env.example | 2 +- docker-compose.yaml | 2 +- docker-migrate.sh | 2 +- docker-user.sh | 4 ++-- docker/transactor.properties.template | 2 +- docs/DOCKER-SECURITY.md | 8 +++---- docs/DOCKER.md | 26 ++++++++++----------- docs/docker-user-management.md | 10 ++++---- docs/migration/datomic-data-migration.md | 2 +- test/docker/fixtures/compose-hardcoded.yaml | 2 +- test/docker/test-upgrade.sh | 14 +++++------ 11 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index b9a284f13..9516f341e 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ # cp .env.example .env # # Or run the setup script to generate .env with secure random values: -# ./docker-setup.sh +# ./run # ============================================================================ # --- Application --- diff --git a/docker-compose.yaml b/docker-compose.yaml index 388c8e56d..be568a6d9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,7 +16,7 @@ # docker compose config | grep DATOMIC_URL # # Recommended setup: -# 1. Run ./docker-setup.sh to generate .env with secure passwords +# 1. Run ./run to generate .env with secure passwords # 2. Run: docker compose up --build -d # 3. If a variable isn't being picked up from .env, check for a conflicting # shell variable with: echo $DATOMIC_URL diff --git a/docker-migrate.sh b/docker-migrate.sh index dd758cc69..f6a9b3d69 100755 --- a/docker-migrate.sh +++ b/docker-migrate.sh @@ -23,7 +23,7 @@ # - Docker Compose v2 (docker compose plugin) # - For backup: OLD datomic container must be running # - For restore: NEW datomic container must be running -# - .env file must exist (run docker-setup.sh first) +# - .env file must exist (run run first) # # See docs/migration/datomic-data-migration.md for the full guide. # ============================================================================= diff --git a/docker-user.sh b/docker-user.sh index 832ac7e6e..7ad469e0f 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -246,7 +246,7 @@ wait_for_ready "$CONTAINER" if [ "${1:-}" = "init" ]; then ENV_FILE="${SCRIPT_DIR}/.env" if [ ! -f "$ENV_FILE" ]; then - error "No .env file found. Run ./docker-setup.sh first." + error "No .env file found. Run ./run first." exit 1 fi @@ -256,7 +256,7 @@ if [ "${1:-}" = "init" ]; then if [ -z "${INIT_ADMIN_USER:-}" ]; then error "INIT_ADMIN_USER is not set in .env" - error "Run ./docker-setup.sh to configure, or set it manually in .env" + error "Run ./run to configure, or set it manually in .env" exit 1 fi if [ -z "${INIT_ADMIN_EMAIL:-}" ] || [ -z "${INIT_ADMIN_PASSWORD:-}" ]; then diff --git a/docker/transactor.properties.template b/docker/transactor.properties.template index 4c52ad82f..d753fd534 100644 --- a/docker/transactor.properties.template +++ b/docker/transactor.properties.template @@ -27,7 +27,7 @@ protocol=dev # SWARM: host=0.0.0.0, alt-host=datomic # "datomic" resolves to a VIP in overlay networks — can't bind to that. # 0.0.0.0 binds to all interfaces; alt-host gives peers the service name -# for reconnection. docker-setup.sh --swarm switches this automatically. +# for reconnection. run --swarm switches this automatically. # # To switch manually: comment one host= line and uncomment the other. host=datomic diff --git a/docs/DOCKER-SECURITY.md b/docs/DOCKER-SECURITY.md index 1af0efd15..23a0a4e6b 100644 --- a/docs/DOCKER-SECURITY.md +++ b/docs/DOCKER-SECURITY.md @@ -86,7 +86,7 @@ escape_sed_replacement() { **Order matters:** backslash must be escaped first. If we escaped `&` first (producing `\&`), then the backslash pass would double it to `\\&`. -`docker-setup.sh` generates alphanumeric-only passwords (no special chars), but +`run` generates alphanumeric-only passwords (no special chars), but users who set passwords manually via `.env` can use any characters. The escaping makes this safe. @@ -100,7 +100,7 @@ sed ... "$TEMPLATE" > "$OUTPUT" chmod 600 "$OUTPUT" ``` -Similarly, `docker-setup.sh` sets `chmod 600` on the generated `.env` file, +Similarly, `run` sets `chmod 600` on the generated `.env` file, which contains `ADMIN_PASSWORD`, `DATOMIC_PASSWORD`, `SIGNATURE` (JWT secret), and SMTP credentials. @@ -173,7 +173,7 @@ password in `DATOMIC_URL`, the app connects with the wrong credential. The error is a cryptic Datomic authentication failure with no mention of password mismatch. -`docker-setup.sh` validates this in its verification section: +`run` validates this in its verification section: ```bash _env_datomic_pw=$(grep -m1 '^DATOMIC_PASSWORD=' "$ENV_FILE" | cut -d= -f2-) @@ -183,7 +183,7 @@ if [[ "$_env_datomic_url" != *"password=${_env_datomic_pw}"* ]]; then fi ``` -When `docker-setup.sh` generates the file, it constructs `DATOMIC_URL` using +When `run` generates the file, it constructs `DATOMIC_URL` using `${DATOMIC_PASSWORD}` so they always match at creation time. ## Environment Variable Passthrough diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 135eab9c7..b34542e68 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -26,7 +26,7 @@ one compose file, and the configuration patterns that connect them. ### Platform Notes -The setup and management scripts (`docker-setup.sh`, `docker-user.sh`, +The setup and management scripts (`run`, `docker-user.sh`, `docker-migrate.sh`) are bash scripts. They work on: - **Linux** — natively @@ -48,7 +48,7 @@ You need to run **3 commands**. You don't need to edit any files. ```sh # 1. Setup — generates passwords, creates directories, makes SSL certs -./docker-setup.sh --auto +./run --auto # 2. Build and launch (first build takes ~10 minutes, then seconds) docker compose up --build -d @@ -68,7 +68,7 @@ That's it. Everything else is optional. Run the interactive version instead: ```sh -./docker-setup.sh # prompts for each setting +./run # prompts for each setting ``` Or edit `.env` after setup — it's a plain text file with comments explaining @@ -110,7 +110,7 @@ anything that's out of date. git pull # 2. Let the upgrade script check and fix your .env -./docker-setup.sh --upgrade +./run --upgrade # 3. Rebuild and restart docker compose up --build -d @@ -151,7 +151,7 @@ reads env vars the same way it always did. If you want to start using `.env`: ```sh -./docker-setup.sh --auto # creates .env with generated passwords +./run --auto # creates .env with generated passwords ``` Then edit the generated `.env` to use your existing passwords instead of @@ -163,10 +163,10 @@ Move passwords out of `.env` so they aren't all sitting in one file: ```sh # Single server (creates secret files on disk) -./docker-setup.sh --secrets +./run --secrets # Swarm cluster (stores secrets encrypted in the cluster) -./docker-setup.sh --swarm +./run --swarm ``` Both read your existing passwords from `.env` or shell env vars — you @@ -213,7 +213,7 @@ These are the variables you'll actually touch. Full reference in | `DEV_MODE` | No | *(empty)* | Set to `true` for CSP Report-Only mode (allows Figwheel hot-reload). | | `LOAD_HOMEBREW_URL` | No | *(empty)* | URL to fetch `.orcbrew` plugins on first page load. | -`docker-setup.sh` generates `DATOMIC_PASSWORD`, `ADMIN_PASSWORD`, and +`run` generates `DATOMIC_PASSWORD`, `ADMIN_PASSWORD`, and `SIGNATURE` automatically. You only need to edit `.env` if you want email or custom branding. @@ -401,7 +401,7 @@ docker swarm init # (everything else stays the same) # 3. (Optional) Move passwords into Swarm secrets -./docker-setup.sh --swarm +./run --swarm # This creates docker-compose.secrets.yaml and wires COMPOSE_FILE in .env # 4. Build images (Swarm doesn't build — it needs pre-built images) @@ -438,7 +438,7 @@ changes needed. The setup script handles everything: ```sh -./docker-setup.sh --secrets +./run --secrets ``` This reads your passwords from `.env` (or shell env vars if you export @@ -463,7 +463,7 @@ the cluster. When a container needs one, Swarm delivers it into memory — the password is never saved to disk on the server running the container. ```sh -./docker-setup.sh --swarm +./run --swarm ``` This checks that your node is in Swarm mode, reads passwords from `.env` @@ -496,7 +496,7 @@ trailing newlines defensively, but `printf` avoids the issue entirely. | `deploy/snakeoil.sh` | Self-signed SSL certificate generator | | `docker-compose.yaml` | Compose file (pull or build-from-source) | | `docker-compose.secrets.yaml` | Generated by `--secrets`/`--swarm` — merges secrets into compose | -| `docker-setup.sh` | Interactive setup: generates `.env`, dirs, SSL certs, secrets | +| `run` | Interactive setup: generates `.env`, dirs, SSL certs, secrets | | `.env.example` | Environment variable reference with defaults | ## Security @@ -578,7 +578,7 @@ docker compose logs orcpub --tail 50 Common causes: - Wrong `DATOMIC_URL` (see above) - Transactor not ready yet (wait for `datomic` to show "healthy") -- Missing `SIGNATURE` (set it in `.env` or `docker-setup.sh` generates one) +- Missing `SIGNATURE` (set it in `.env` or `run` generates one) ### Build takes too long / hangs diff --git a/docs/docker-user-management.md b/docs/docker-user-management.md index 6ed59c076..de4c33512 100644 --- a/docs/docker-user-management.md +++ b/docs/docker-user-management.md @@ -59,9 +59,9 @@ If you forget the commands: `./docker-user.sh --help` If you've cloned the repo, the setup script generates secure passwords, SSL certs, and required directories in one step: ```bash -./docker-setup.sh # Interactive — prompts for optional values -./docker-setup.sh --auto # Non-interactive — accepts all defaults -./docker-setup.sh --auto --force # Regenerate everything from scratch +./run # Interactive — prompts for optional values +./run --auto # Non-interactive — accepts all defaults +./run --auto --force # Regenerate everything from scratch ``` In interactive mode, the setup script will prompt for an initial admin account. Then start the stack and initialize: @@ -108,7 +108,7 @@ The setup script creates a `.env` file used by `docker-compose.yaml`. You can al Reads `INIT_ADMIN_USER`, `INIT_ADMIN_EMAIL`, and `INIT_ADMIN_PASSWORD` from `.env` and creates the account. Safe to run multiple times — if the user already exists, it's skipped. -This is the easiest path after running `docker-setup.sh` in interactive mode, which prompts for these values. +This is the easiest path after running `run` in interactive mode, which prompts for these values. ### Create a Single User @@ -172,7 +172,7 @@ Prints a table of all users in the database with their verification status. `docker-compose.yaml` has been updated: -- **Environment variables use `.env` interpolation** — no more editing passwords directly in the YAML. All config flows from the `.env` file generated by `docker-setup.sh`. +- **Environment variables use `.env` interpolation** — no more editing passwords directly in the YAML. All config flows from the `.env` file generated by `run`. - **Native healthchecks** — Datomic and the application containers declare healthchecks so that dependent services wait for readiness automatically. This replaces fragile startup-order workarounds. - **Service dependencies use `condition: service_healthy`** — nginx won't start until the app is actually serving, and the app won't start until Datomic is accepting connections. diff --git a/docs/migration/datomic-data-migration.md b/docs/migration/datomic-data-migration.md index 38c6cac71..32807dc3c 100644 --- a/docs/migration/datomic-data-migration.md +++ b/docs/migration/datomic-data-migration.md @@ -363,5 +363,5 @@ continue to work for regular backups going forward. - [datomic-pro.md](datomic-pro.md) — Code-level changes (dependency, URI, API) - [../ENVIRONMENT.md](../ENVIRONMENT.md) — Environment variable reference -- [../../docker-setup.sh](../../docker-setup.sh) — Initial Docker setup +- [../../run](../../run) — Initial Docker setup - [../../docker-user.sh](../../docker-user.sh) — User management after migration diff --git a/test/docker/fixtures/compose-hardcoded.yaml b/test/docker/fixtures/compose-hardcoded.yaml index 997d6ea71..e93d5417e 100644 --- a/test/docker/fixtures/compose-hardcoded.yaml +++ b/test/docker/fixtures/compose-hardcoded.yaml @@ -4,7 +4,7 @@ # # To test: make sure no .env exists, then: # cp test/docker/fixtures/compose-hardcoded.yaml docker-compose.yaml -# ./docker-setup.sh --upgrade --auto +# ./run --upgrade --auto # # Expected: script extracts hardcoded values into a new .env, then upgrades it services: diff --git a/test/docker/test-upgrade.sh b/test/docker/test-upgrade.sh index c64f30273..71911fe1b 100755 --- a/test/docker/test-upgrade.sh +++ b/test/docker/test-upgrade.sh @@ -36,7 +36,7 @@ FAILURES=0 # Helpers # --------------------------------------------------------------------------- -# Read a value from an .env file (same logic as docker-setup.sh) +# Read a value from an .env file (same logic as run) read_val() { grep -m1 "^${1}=" "$2" 2>/dev/null | cut -d= -f2- | tr -d '\r' } @@ -115,13 +115,13 @@ run_fixture() { # Don't trap RETURN — we clean up at the end of the function # Copy fixture as .env and the setup script into tmpdir. - # docker-setup.sh uses SCRIPT_DIR (dirname of the script) to find .env, + # run uses SCRIPT_DIR (dirname of the script) to find .env, # so the script must live next to the .env for it to find the fixture. cp "$fixture_file" "${tmpdir}/.env" - cp "$SETUP_SCRIPT" "${tmpdir}/docker-setup.sh" + cp "$SETUP_SCRIPT" "${tmpdir}/run" local output - output=$(bash "${tmpdir}/docker-setup.sh" --upgrade --auto 2>&1) || true + output=$(bash "${tmpdir}/run" --upgrade --auto 2>&1) || true local result_env="${tmpdir}/.env" local label="[${fixture_name}] " @@ -220,7 +220,7 @@ run_fixture() { # --------------------------------------------------------------------------- if [ ! -f "$SETUP_SCRIPT" ]; then - echo "docker-setup.sh not found at: $SETUP_SCRIPT" >&2 + echo "run not found at: $SETUP_SCRIPT" >&2 exit 1 fi @@ -236,10 +236,10 @@ run_secrets_test() { # Use v3-current as the base .env (already up to date) cp "${FIXTURE_DIR}/env-v3-current.env" "${tmpdir}/.env" - cp "$SETUP_SCRIPT" "${tmpdir}/docker-setup.sh" + cp "$SETUP_SCRIPT" "${tmpdir}/run" local output - output=$(bash "${tmpdir}/docker-setup.sh" --secrets --auto 2>&1) || true + output=$(bash "${tmpdir}/run" --secrets --auto 2>&1) || true local label="[secrets] " From c220e558f1d13e2bc839a51ae575c59d1b7ef760 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 5 Mar 2026 05:09:41 +0000 Subject: [PATCH 43/50] fix: resolve shellcheck warnings in run script Suppress intentional SC2016 (literal '${' check), SC2001 (regex too complex for parameter expansion), fix SC2129 (grouped redirects). --- run | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/run b/run index ddea6d5b9..e0c26dda9 100755 --- a/run +++ b/run @@ -145,9 +145,11 @@ YAML local current_cf current_cf=$(read_env_val COMPOSE_FILE "$ENV_FILE") if [ -z "$current_cf" ]; then - echo "" >> "$ENV_FILE" - echo "# --- Compose file merge (secrets) ---" >> "$ENV_FILE" - echo "COMPOSE_FILE=${compose_file_var}" >> "$ENV_FILE" + { + echo "" + echo "# --- Compose file merge (secrets) ---" + echo "COMPOSE_FILE=${compose_file_var}" + } >> "$ENV_FILE" change "Added COMPOSE_FILE to .env (compose will merge both files)" elif [ "$current_cf" != "$compose_file_var" ]; then warn "COMPOSE_FILE already set in .env: ${current_cf}" @@ -1082,6 +1084,7 @@ if [ "$UPGRADE_MODE" = "true" ]; then if [ -f "$_compose" ]; then for _var in DATOMIC_URL DATOMIC_PASSWORD ADMIN_PASSWORD SIGNATURE; do _val=$(grep -E "^\s+${_var}:" "$_compose" | head -1 | sed "s/^[[:space:]]*${_var}: *//" | tr -d '\r') + # shellcheck disable=SC2016 # Intentional: checking for literal '${' prefix if [ -n "$_val" ] && [ "${_val#'${'}" = "$_val" ]; then _compose_vals[$_var]="$_val" fi @@ -1094,6 +1097,7 @@ if [ "$UPGRADE_MODE" = "true" ]; then _key="${_prop%%:*}" _var="${_prop##*:}" _val=$(grep -E "^${_key}=" "$_transactor" | head -1 | sed 's/.*=//' | tr -d '\r') + # shellcheck disable=SC2016 # Intentional: checking for literal '${' prefix if [ -n "$_val" ] && [ "${_val#'${'}" = "$_val" ]; then _transactor_vals[$_var]="$_val" fi @@ -1166,6 +1170,7 @@ if [ "$UPGRADE_MODE" = "true" ]; then if [[ "$_url" == *"password="* ]]; then # Extract password from URL _url_pw=$(echo "$_url" | sed -n 's/.*[?&]password=\([^&]*\).*/\1/p') + # shellcheck disable=SC2001 # Regex too complex for parameter expansion _clean_url=$(echo "$_url" | sed 's/[?&]password=[^&]*//') if [ -n "$_url_pw" ]; then From 71609dd48596743f4ab0913fb08e35624365a44b Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 5 Mar 2026 05:19:27 +0000 Subject: [PATCH 44/50] =?UTF-8?q?fix:=20CI=20=E2=80=94=20remove=20fork-inc?= =?UTF-8?q?ompatible=20PR=20comment,=20update=20password=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove PR comment step from CI (fork PRs lack write access, always 403) - Update docker-integration password validation for new URL format (password no longer embedded in DATOMIC_URL, appended at runtime) --- .github/workflows/continuous-integration.yml | 73 -------------------- .github/workflows/docker-integration.yml | 30 +++++--- 2 files changed, 20 insertions(+), 83 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 698c1f670..45352ad36 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -13,7 +13,6 @@ on: permissions: contents: read - pull-requests: write checks: write jobs: @@ -162,78 +161,6 @@ jobs: exit 1 fi - - name: Post PR comment with results - if: github.event_name == 'pull_request' && always() - continue-on-error: true # Fork PRs get read-only GITHUB_TOKEN - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - - const lintOutput = fs.existsSync('lint-output.txt') ? fs.readFileSync('lint-output.txt', 'utf8') : 'No output'; - const testOutput = fs.existsSync('test-output.txt') ? fs.readFileSync('test-output.txt', 'utf8') : 'No output'; - const cljsOutput = fs.existsSync('cljs-output.txt') ? fs.readFileSync('cljs-output.txt', 'utf8') : 'No output'; - - const lintOk = '${{ steps.lint.outcome }}' === 'success'; - const testOk = '${{ steps.test.outcome }}' === 'success'; - const cljsOk = '${{ steps.cljs.outcome }}' === 'success'; - const allOk = lintOk && testOk && cljsOk; - const stackLabel = '${{ needs.detect-stack.outputs.stack-label }}'; - - const testMatch = testOutput.match(/Ran (\d+) tests containing (\d+) assertions/); - const testSummary = testMatch ? `${testMatch[1]} tests, ${testMatch[2]} assertions` : 'See logs'; - - const status = allOk ? 'All checks passed' : 'Some checks failed'; - const body = [ - `## ${status}`, - '', - '| Check | Status | Details |', - '|-------|--------|---------|', - `| Lint | ${lintOk ? 'Pass' : 'Fail'} | ${lintOk ? 'No errors' : 'See workflow logs'} |`, - `| Tests | ${testOk ? 'Pass' : 'Fail'} | ${testOk ? testSummary : 'See workflow logs'} |`, - `| CLJS Build | ${cljsOk ? 'Pass' : 'Fail'} | ${cljsOk ? 'Compiled' : 'See workflow logs'} |`, - '', - `**Stack**: ${stackLabel}`, - '', - '<details>', - '<summary>Full test output</summary>', - '', - '```', - testOutput.slice(-2000), - '```', - '', - '</details>', - '', - '---', - `*[Workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})*` - ].join('\n'); - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number - }); - - const botComment = comments.find(c => - c.user.type === 'Bot' && (c.body.includes('All checks passed') || c.body.includes('Some checks failed')) - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: body - }); - } - - name: Upload artifacts on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 49f226952..10f079763 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -76,12 +76,23 @@ jobs: - name: Validate .env password consistency run: | PW=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) - URL_PW=$(grep '^DATOMIC_URL=' .env | sed 's/.*password=//') - if [ "$PW" != "$URL_PW" ]; then - echo "FAIL: DATOMIC_PASSWORD and DATOMIC_URL password mismatch" - exit 1 + URL=$(grep '^DATOMIC_URL=' .env | cut -d= -f2-) + if echo "$URL" | grep -q 'password='; then + # Legacy format: password embedded in URL — must match DATOMIC_PASSWORD + URL_PW=$(echo "$URL" | sed 's/.*password=//') + if [ "$PW" != "$URL_PW" ]; then + echo "FAIL: DATOMIC_PASSWORD and DATOMIC_URL password mismatch" + exit 1 + fi + echo "OK: Passwords match (embedded in URL)" + else + # New format: password separate — just verify both exist + if [ -z "$PW" ]; then + echo "FAIL: DATOMIC_PASSWORD not set" + exit 1 + fi + echo "OK: DATOMIC_PASSWORD set, URL clean (password appended at runtime by config.clj)" fi - echo "OK: Passwords match" - name: Test — setup --force preserves existing values run: | @@ -102,14 +113,13 @@ jobs: grep -q '^SIGNATURE=' .env || { echo "FAIL: SIGNATURE missing"; exit 1; } grep -q '^PORT=' .env || { echo "FAIL: PORT missing"; exit 1; } - # Re-check password consistency after --force + # Re-check password exists after --force PW=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) - URL_PW=$(grep '^DATOMIC_URL=' .env | sed 's/.*password=//') - if [ "$PW" != "$URL_PW" ]; then - echo "FAIL: Password mismatch after --force re-run" + if [ -z "$PW" ]; then + echo "FAIL: DATOMIC_PASSWORD missing after --force re-run" exit 1 fi - echo "OK: --force regenerated .env with consistent passwords" + echo "OK: --force regenerated .env with DATOMIC_PASSWORD set" # ── Container image acquisition ───────────────────────────── # Java 21: build from source (no pre-built images for new stack) From a1a6dae4fb0d8a3522028950bebe72f91c64d68b Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 5 Mar 2026 05:28:20 +0000 Subject: [PATCH 45/50] =?UTF-8?q?fix:=20correct=20log=20labels=20=E2=80=94?= =?UTF-8?q?=20success=20for=20completions,=20not=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/run b/run index e0c26dda9..71e3e3860 100755 --- a/run +++ b/run @@ -1462,7 +1462,7 @@ echo "" if [ "$ERRORS" -gt 0 ]; then warn "Setup completed with ${ERRORS} warning(s). Review the items above." else - info "All checks passed!" + success "All checks passed!" fi # ---- Step 3: Build + Start (naked mode only) -------------------------------- @@ -1474,7 +1474,7 @@ if [ "$_any_mode" = "false" ]; then info "Building images..." docker compose build || { error "Build failed."; exit 1; } echo "" - change "Images built successfully." + success "Images built successfully." else info "Skipping build." fi @@ -1485,7 +1485,7 @@ if [ "$_any_mode" = "false" ]; then info "Starting services..." eval "$COMPOSE_CMD" || { error "Failed to start services."; exit 1; } echo "" - change "Services started." + success "Services started." echo "" echo "Wait for healthy (app takes ~2 minutes to boot):" echo " docker compose ps" From 7d482155452d7317b1ac1bb770965750add1babd Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 5 Mar 2026 05:39:21 +0000 Subject: [PATCH 46/50] =?UTF-8?q?fix:=20CI=20cleanup=20between=20setup=20s?= =?UTF-8?q?teps=20=E2=80=94=20naked=20./run=20creates=20H2=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ./run --auto (naked mode) now runs the full pipeline (setup→build→up). The CI --force step regenerated passwords against an existing H2 database created by the first run, causing "Unable to connect to embedded storage". Add docker compose down + rm -rf data/ between setup validation steps so CI's explicit build/start steps get a clean slate. --- .github/workflows/docker-integration.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 10f079763..9ea6a7c82 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -70,6 +70,10 @@ jobs: - name: Run run --auto run: | ./run --auto + # Naked ./run runs full pipeline (setup→build→up). Tear down containers + # and wipe H2 data so CI's own build/start steps get a clean slate. + docker compose down -v 2>/dev/null || true + rm -rf data/ echo "--- Generated .env (secrets redacted) ---" sed 's/=.*/=***/' .env @@ -102,6 +106,9 @@ jobs: ORIG_SIG=$(grep '^SIGNATURE=' .env | cut -d= -f2) # Re-run with --force --auto (should regenerate) + # Tear down first — naked ./run creates containers + H2 data with old passwords + docker compose down -v 2>/dev/null || true + rm -rf data/ ./run --auto --force # Verify .env was regenerated (new passwords, since --auto generates fresh ones) @@ -121,6 +128,10 @@ jobs: fi echo "OK: --force regenerated .env with DATOMIC_PASSWORD set" + # Clean up containers/data from --force pipeline run + docker compose down -v 2>/dev/null || true + rm -rf data/ + # ── Container image acquisition ───────────────────────────── # Java 21: build from source (no pre-built images for new stack) # Java 8: pull pre-built images from Docker Hub (skip if unavailable) From ba79f0578dfc07c4db144963e8d08663138941f9 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Thu, 5 Mar 2026 05:48:43 +0000 Subject: [PATCH 47/50] =?UTF-8?q?fix:=20sudo=20rm=20for=20H2=20data=20?= =?UTF-8?q?=E2=80=94=20datomic=20user=20owns=20the=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-integration.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 9ea6a7c82..9c4462f39 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -73,7 +73,7 @@ jobs: # Naked ./run runs full pipeline (setup→build→up). Tear down containers # and wipe H2 data so CI's own build/start steps get a clean slate. docker compose down -v 2>/dev/null || true - rm -rf data/ + sudo rm -rf data/ echo "--- Generated .env (secrets redacted) ---" sed 's/=.*/=***/' .env @@ -108,7 +108,7 @@ jobs: # Re-run with --force --auto (should regenerate) # Tear down first — naked ./run creates containers + H2 data with old passwords docker compose down -v 2>/dev/null || true - rm -rf data/ + sudo rm -rf data/ ./run --auto --force # Verify .env was regenerated (new passwords, since --auto generates fresh ones) @@ -130,7 +130,7 @@ jobs: # Clean up containers/data from --force pipeline run docker compose down -v 2>/dev/null || true - rm -rf data/ + sudo rm -rf data/ # ── Container image acquisition ───────────────────────────── # Java 21: build from source (no pre-built images for new stack) From 5895f681d4c488862dc4d9243fec81b65266b9b2 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Fri, 6 Mar 2026 03:29:30 +0000 Subject: [PATCH 48/50] feat: first-class Docker Swarm & Portainer support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ./run --swarm generates docker-compose.swarm.yaml and .env.portainer as text files — no Docker daemon required. The generated compose uses named volumes, overlay networks, deploy sections with resource limits, rolling updates, and commented Traefik labels. Upgrade path: detects existing swarm compose, backs it up, extracts customizations (Traefik labels, resource limits, env values, networks, stack name), regenerates with latest template preserving admin config, and shows a colorized diff (white/cyan/green/yellow). .env.portainer is a flat KEY=VALUE file (no comments, no blanks, no COMPOSE_FILE) ready to paste into Portainer's Advanced mode editor. ./run --swarm --secrets activates Swarm Raft secrets (uncomments secrets blocks in the generated compose). Also fixes: - docker-user.sh: flexible Swarm container discovery (label matching) - Secret file permissions: 644 (non-root container user needs read) - .gitignore: generated swarm/portainer files excluded 60 tests, 0 failures (14 new swarm tests). --- .gitignore | 5 + docker-user.sh | 23 +- run | 117 ++++++- scripts/swarm.sh | 679 ++++++++++++++++++++++++++++++++++++ test/docker/test-upgrade.sh | 179 ++++++++++ 5 files changed, 980 insertions(+), 23 deletions(-) create mode 100644 scripts/swarm.sh diff --git a/.gitignore b/.gitignore index 2bdb84276..8e7616ba8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ .env.secrets.backup /secrets/ docker-compose.secrets.yaml +docker-compose.swarm.yaml +docker-compose.swarm.yaml.backup.* +.env.portainer +.env.backup.* +transactor.properties.reference .editorconfig /resources/public/css/compiled /resources/public/js/compiled diff --git a/docker-user.sh b/docker-user.sh index 7ad469e0f..d514966d4 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -88,19 +88,28 @@ find_container() { container=$(docker compose ps -q orcpub 2>/dev/null || true) fi - # Try Swarm: look for running task of orcpub_orcpub service + # Try Swarm: look for running task by service label + # Stack name varies (orcpub, dev_dmv, etc.) — match any service containing "orcpub" if [ -z "$container" ]; then - container=$(docker ps -q --filter "label=com.docker.swarm.service.name=orcpub_orcpub" 2>/dev/null | head -1 || true) + container=$(docker ps -q --filter "label=com.docker.swarm.service.name" 2>/dev/null | while read -r cid; do + local svc + svc=$(docker inspect --format '{{index .Config.Labels "com.docker.swarm.service.name"}}' "$cid" 2>/dev/null || true) + if [[ "$svc" == *orcpub* ]] && [[ "$svc" != *datomic* ]]; then + echo "$cid" + break + fi + done || true) fi - # Fallback: search by image name + # Fallback: search by image name pattern if [ -z "$container" ]; then - container=$(docker ps -q --filter "ancestor=orcpub-app:latest" 2>/dev/null | head -1 || true) + container=$(docker ps -q --filter "ancestor=orcpub-app" 2>/dev/null | head -1 || true) + [ -z "$container" ] && container=$(docker ps -q --filter "ancestor=dmv" 2>/dev/null | head -1 || true) fi - # Fallback: search by container name pattern (matches both compose and swarm) + # Fallback: search by container name pattern if [ -z "$container" ]; then - container=$(docker ps -q --filter "name=orcpub" --filter "name=orcpub_orcpub" 2>/dev/null | head -1 || true) + container=$(docker ps --format '{{.ID}} {{.Names}}' 2>/dev/null | grep -i 'orcpub' | grep -iv 'datomic' | head -1 | awk '{print $1}' || true) fi echo "$container" @@ -235,7 +244,7 @@ fi if [ -z "$CONTAINER" ]; then error "Cannot find the orcpub container." - error "Make sure the containers are running: docker-compose up -d" + error "Make sure the services are running (Compose: docker compose up -d, Swarm: docker stack ps <stack>)" exit 1 fi diff --git a/run b/run index 71e3e3860..fe74f797c 100755 --- a/run +++ b/run @@ -52,6 +52,10 @@ source_env() { . <(tr -d '\r' < "$1") } +# Source optional modules +# shellcheck source=scripts/swarm.sh +[ -f "${SCRIPT_DIR}/scripts/swarm.sh" ] && . "${SCRIPT_DIR}/scripts/swarm.sh" + # Set a variable in a .env file. Uses awk to avoid sed delimiter issues with URLs. # Usage: set_env_val "VAR_NAME" "value" "/path/to/.env" set_env_val() { @@ -378,6 +382,37 @@ EMAIL_TLS=${EMAIL_TLS} INIT_ADMIN_USER=${INIT_ADMIN_USER} INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL} INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD} +$(if [ "$SWARM_MODE" = "true" ]; then cat <<'SWARM_VARS' + +# --- Swarm / Portainer --- +# These are used by docker-compose.swarm.yaml. Compose users can ignore them. + +# Timezone (POSIX standard — works on both musl/Alpine and glibc) +TZ=America/Chicago + +# Content Security Policy mode (strict|relaxed|off) +CSP_POLICY=strict + +# Stack / project name +# COMPOSE_PROJECT_NAME=orcpub + +# JVM — Transactor (read natively by Datomic's bin/transactor) +# Set to ~75% of DATOMIC_MEMORY_LIMIT. Example: 2G container → -Xms1536m +# XMS=-Xms1g +# XMX=-Xmx1g + +# JVM — App (passed via JAVA_OPTS) +# Modern: -XX:MaxRAMPercentage=75.0 (auto-scales to container limit) +# Traditional: -Xms1g -Xmx1g +# JAVA_OPTS= + +# Container resource limits (Swarm deploy.resources) +# APP_MEMORY_LIMIT=2G +# APP_MEMORY_RESERVATION=1G +# DATOMIC_MEMORY_LIMIT=2G +# DATOMIC_MEMORY_RESERVATION=1G +SWARM_VARS +fi) EOF chmod 600 "$ENV_FILE" change ".env file created at ${ENV_FILE} (permissions: 600)" @@ -592,17 +627,8 @@ for arg in "$@"; do esac done -# Conflict: --secrets and --swarm are mutually exclusive -if [ "$SECRETS_MODE" = "true" ] && [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then - error "--secrets and --swarm are mutually exclusive." - echo " --secrets writes secrets as local files (Compose)" - echo " --swarm stores secrets in the Swarm Raft log (Swarm)" - echo "" - echo "Pick one based on your deployment:" - echo " Compose → ./${SCRIPT_NAME} --secrets" - echo " Swarm → ./${SCRIPT_NAME} --swarm" - exit 1 -fi +# --secrets alone = Compose file-based secrets +# --swarm --secrets = Swarm Raft-based secrets (after compose generation) # Conflict: --down is standalone if [ "$DOWN_MODE" = "true" ]; then @@ -799,12 +825,12 @@ if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then printf '%s' "$_pw_datomic" > "${SECRETS_DIR}/datomic_password" || { error "Failed to write ${SECRETS_DIR}/datomic_password"; exit 1; } printf '%s' "$_pw_admin" > "${SECRETS_DIR}/admin_password" || { error "Failed to write ${SECRETS_DIR}/admin_password"; exit 1; } printf '%s' "$_pw_signature" > "${SECRETS_DIR}/signature" || { error "Failed to write ${SECRETS_DIR}/signature"; exit 1; } - chmod 600 "${SECRETS_DIR}"/* + chmod 644 "${SECRETS_DIR}"/* change "Created secrets/datomic_password" change "Created secrets/admin_password" change "Created secrets/signature" - change "File permissions set to 600" + change "File permissions set to 644" unset _pw_datomic _pw_admin _pw_signature @@ -837,13 +863,66 @@ if [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then fi # --------------------------------------------------------------------------- -# Swarm secrets (--swarm mode) +# Swarm compose generation (--swarm) — standalone Swarm-ready YAML +# --------------------------------------------------------------------------- +# Pure text file generation — no Docker daemon required. +# Produces docker-compose.swarm.yaml + .env.portainer for Portainer import. + +if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then + # Ensure .env exists — generate if needed + if [ ! -f "$ENV_FILE" ]; then + generate_env + fi + source_env "$ENV_FILE" + + _backup_file="" + _is_upgrade=false + + if [ -f "$SWARM_COMPOSE" ]; then + _is_upgrade=true + _backup_file="${SWARM_COMPOSE}.backup.$(date +%s)" + cp "$SWARM_COMPOSE" "$_backup_file" + info "Existing swarm compose backed up: $_backup_file" + + # Extract customizations from existing file + if ! extract_swarm_config "$SWARM_COMPOSE"; then + warn "Could not parse existing compose — generating fresh." + _is_upgrade=false + fi + fi + + # Generate the swarm compose file (uses _swarm_* vars if upgrade) + generate_swarm_compose "$SWARM_COMPOSE" "$_is_upgrade" + change "Generated: $SWARM_COMPOSE" + + # Generate stripped .env for Portainer advanced mode + generate_env_portainer "$ENV_FILE" "$SWARM_PORTAINER_ENV" + change "Generated: $SWARM_PORTAINER_ENV" + + # Generate transactor.properties reference if bind-mount detected + if [ "${_swarm_has_transactor_props:-false}" = "true" ]; then + if generate_transactor_reference; then + change "Generated: ${SCRIPT_DIR}/transactor.properties.reference" + fi + fi + + # Show colorized summary + print_swarm_summary "$SWARM_COMPOSE" "$_is_upgrade" "$_backup_file" + + # If --build, --up, or --secrets also specified, fall through; otherwise exit + if [ "$BUILD_MODE" != "true" ] && [ "$UP_MODE" != "true" ] && [ "$SECRETS_MODE" != "true" ]; then + exit 0 + fi +fi +# --------------------------------------------------------------------------- +# Swarm secrets (--swarm --secrets) # --------------------------------------------------------------------------- # Creates Docker secrets via `docker secret create` for use in Swarm clusters. # Secrets are stored encrypted in the Swarm Raft log and delivered to # containers in memory — never written to disk on any node. +# Requires a running Docker daemon in Swarm mode. -if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then +if [ "$SWARM_MODE" = "true" ] && [ "$SECRETS_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then header "Dungeon Master's Vault — Swarm Secrets" # Check for running Compose containers — they'll conflict with Swarm networking @@ -867,7 +946,7 @@ if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then docker swarm init 2>&1 || { error "Failed to initialize Swarm."; exit 1; } change "Docker Swarm initialized." else - info "Run 'docker swarm init' manually, then re-run: ./${SCRIPT_NAME} --swarm" + info "Run 'docker swarm init' manually, then re-run: ./${SCRIPT_NAME} --swarm --secrets" exit 0 fi fi @@ -926,6 +1005,12 @@ if [ "$SWARM_MODE" = "true" ] && [ "$UPGRADE_MODE" != "true" ]; then # Generate compose override so secrets are wired in automatically write_compose_secrets "external" + # Uncomment secrets blocks in swarm compose if it exists + if [ -f "$SWARM_COMPOSE" ]; then + activate_swarm_secrets "$SWARM_COMPOSE" + change "Activated secrets in $(basename "$SWARM_COMPOSE")" + fi + # Switch transactor to Swarm-compatible host binding. switch_transactor_host "swarm" set_env_val ALT_HOST datomic "$ENV_FILE" diff --git a/scripts/swarm.sh b/scripts/swarm.sh new file mode 100644 index 000000000..7cd45472e --- /dev/null +++ b/scripts/swarm.sh @@ -0,0 +1,679 @@ +#!/usr/bin/env bash +# +# Swarm compose generator — produces a standalone Swarm-ready compose file +# for import into Portainer or use with `docker stack deploy`. +# +# Sourced by the `run` script. Depends on: +# - SCRIPT_DIR, SCRIPT_NAME, ENV_FILE (set by run) +# - color_* variables (set by run) +# - info(), warn(), change(), error() helpers (set by run) +# - read_env_val() (set by run) + +SWARM_COMPOSE="${SCRIPT_DIR}/docker-compose.swarm.yaml" +SWARM_PORTAINER_ENV="${SCRIPT_DIR}/.env.portainer" + +# Additional color codes (extends run's palette) +color_bold=$'\033[1m' +color_dim=$'\033[2m' + +# --------------------------------------------------------------------------- +# extract_swarm_config — parse existing Swarm compose into shell variables +# --------------------------------------------------------------------------- +# Sets _swarm_* variables for use by generate_swarm_compose and +# print_swarm_summary. Returns 1 if no existing file found. + +extract_swarm_config() { + local existing="$1" + [ -f "$existing" ] || return 1 + + # Stack name + _swarm_stack_name=$(grep -E '^name:' "$existing" 2>/dev/null | head -1 | sed 's/^name:[[:space:]]*//' | tr -d '\r' || true) + + # Service names — top-level keys under services: + _swarm_datomic_svc=$(sed -n '/^services:/,/^[a-z]/{ /^ [a-z].*:$/p }' "$existing" | grep -i 'datomic' | head -1 | sed 's/://;s/^[[:space:]]*//' || true) + _swarm_app_svc=$(sed -n '/^services:/,/^[a-z]/{ /^ [a-z].*:$/p }' "$existing" | grep -iv 'datomic' | head -1 | sed 's/://;s/^[[:space:]]*//' || true) + + local d_svc="${_swarm_datomic_svc:-datomic}" + local a_svc="${_swarm_app_svc:-orcpub}" + + # Images + _swarm_datomic_image=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{ /image:/p }" "$existing" | head -1 | sed 's/.*image:[[:space:]]*//' | tr -d '\r' || true) + _swarm_app_image=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{ /image:/p }" "$existing" | head -1 | sed 's/.*image:[[:space:]]*//' | tr -d '\r' || true) + + # Traefik labels + _swarm_traefik_labels=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_traefik_labels+=("$line") + done < <(grep -E '^\s*-?\s*traefik\.' "$existing" 2>/dev/null | sed 's/^[[:space:]]*-[[:space:]]*//' || true) + + # Networks — top-level block + _swarm_networks=() + _swarm_networks_external=() + while IFS= read -r line; do + local net_name + net_name=$(echo "$line" | sed 's/://;s/^[[:space:]]*//') + [ -z "$net_name" ] && continue + _swarm_networks+=("$net_name") + done < <(sed -n '/^networks:/,/^[a-z]/{/^ [a-z]/p}' "$existing" 2>/dev/null || true) + + for net in "${_swarm_networks[@]}"; do + if sed -n "/^ ${net}:/,/^ [a-z]/p" "$existing" 2>/dev/null | grep -q 'external.*true'; then + _swarm_networks_external+=("$net") + fi + done + + # Volumes — top-level block + _swarm_volumes=() + _swarm_volumes_external=() + while IFS= read -r line; do + local vol_name + vol_name=$(echo "$line" | sed 's/://;s/^[[:space:]]*//') + [ -z "$vol_name" ] && continue + _swarm_volumes+=("$vol_name") + done < <(sed -n '/^volumes:/,/^[a-z]/{/^ [a-z]/p}' "$existing" 2>/dev/null || true) + + for vol in "${_swarm_volumes[@]}"; do + if sed -n "/^ ${vol}:/,/^ [a-z]/p" "$existing" 2>/dev/null | grep -q 'external.*true'; then + _swarm_volumes_external+=("$vol") + fi + done + + # Bind mounts (host path lines starting with /) + _swarm_bind_mounts_datomic=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_bind_mounts_datomic+=("$line") + done < <(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/volumes:/,/^[[:space:]]*[a-z]/{/^\s*-\s*\//p}}" "$existing" 2>/dev/null | sed 's/^[[:space:]]*-[[:space:]]*//' || true) + + _swarm_bind_mounts_app=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_bind_mounts_app+=("$line") + done < <(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/volumes:/,/^[[:space:]]*[a-z]/{/^\s*-\s*\//p}}" "$existing" 2>/dev/null | sed 's/^[[:space:]]*-[[:space:]]*//' || true) + + # Resource limits + _swarm_datomic_mem_limit=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/limits:/,/reservations:/{/memory:/p}}" "$existing" | head -1 | sed 's/.*memory:[[:space:]]*//' | tr -d '\r' || true) + _swarm_datomic_mem_reservation=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/reservations:/,/[a-z].*:/{/memory:/p}}" "$existing" | tail -1 | sed 's/.*memory:[[:space:]]*//' | tr -d '\r' || true) + _swarm_app_mem_limit=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/limits:/,/reservations:/{/memory:/p}}" "$existing" | head -1 | sed 's/.*memory:[[:space:]]*//' | tr -d '\r' || true) + _swarm_app_mem_reservation=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/reservations:/,/[a-z].*:/{/memory:/p}}" "$existing" | tail -1 | sed 's/.*memory:[[:space:]]*//' | tr -d '\r' || true) + + # JVM settings + _swarm_xms=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/XMS:/p}" "$existing" | head -1 | sed 's/.*XMS:[[:space:]]*//' | tr -d '\r' || true) + _swarm_xmx=$(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/XMX:/p}" "$existing" | head -1 | sed 's/.*XMX:[[:space:]]*//' | tr -d '\r' || true) + _swarm_java_opts=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/JAVA_OPTS:/p}" "$existing" | head -1 | sed 's/.*JAVA_OPTS:[[:space:]]*//' | tr -d '\r' || true) + + # Per-service env vars + _swarm_datomic_env=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_datomic_env+=("$line") + done < <(sed -n "/^ ${d_svc}:/,/^ [a-z]/{/environment:/,/^[[:space:]]*[a-z]/{/^[[:space:]]*[A-Z]/p}}" "$existing" 2>/dev/null | sed 's/^[[:space:]]*//' || true) + + _swarm_app_env=() + while IFS= read -r line; do + [ -n "$line" ] && _swarm_app_env+=("$line") + done < <(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/environment:/,/^[[:space:]]*[a-z]/{/^[[:space:]]*[A-Z]/p}}" "$existing" 2>/dev/null | sed 's/^[[:space:]]*//' || true) + + # Detect transactor.properties bind mount + _swarm_has_transactor_props=false + for m in "${_swarm_bind_mounts_datomic[@]}"; do + [[ "$m" == *transactor.properties* ]] && _swarm_has_transactor_props=true && break + done + + # Healthcheck timeout (app — for change detection) + _swarm_app_hc_timeout=$(sed -n "/^ ${a_svc}:/,/^ [a-z]/{/timeout:/p}" "$existing" | head -1 | sed 's/.*timeout:[[:space:]]*//' | tr -d '\r' || true) + + return 0 +} + +# --------------------------------------------------------------------------- +# generate_env_portainer — stripped .env for Portainer advanced mode paste +# --------------------------------------------------------------------------- + +generate_env_portainer() { + local src="$1" dst="$2" + local _seen_keys="" + : > "$dst" + while IFS= read -r line; do + # Strip comments, blanks, export prefix, quotes + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ -z "${line// /}" ]] && continue + line="${line#export }" + # Skip Compose-only vars (irrelevant for Swarm stack deploy) + local _key="${line%%=*}" + [[ "$_key" == "COMPOSE_FILE" ]] && continue + [[ "$_key" == "COMPOSE_PROJECT_NAME" ]] && continue + # Deduplicate (keep first occurrence) + if [[ "$_seen_keys" == *"|${_key}|"* ]]; then continue; fi + _seen_keys="${_seen_keys}|${_key}|" + # Strip surrounding quotes from value + local _val="${line#*=}" + _val="${_val#\'}" ; _val="${_val%\'}" + _val="${_val#\"}" ; _val="${_val%\"}" + printf '%s=%s\n' "$_key" "$_val" >> "$dst" + done < "$src" + chmod 600 "$dst" +} + +# --------------------------------------------------------------------------- +# generate_transactor_reference — resolve template with .env values +# --------------------------------------------------------------------------- +# When the admin bind-mounts their own transactor.properties, template changes +# are invisible. This generates a .reference file showing what the current +# template would produce, so they can diff against their custom file. + +generate_transactor_reference() { + local template="${SCRIPT_DIR}/docker/transactor.properties.template" + local output="${SCRIPT_DIR}/transactor.properties.reference" + [ -f "$template" ] || return 1 + + # Resolve ${VAR} and ${VAR:-default} from environment (sourced from .env) + local line + while IFS= read -r line; do + # Skip comments and empty lines — pass through + if [[ "$line" == \#* ]] || [ -z "$line" ]; then + printf '%s\n' "$line" + continue + fi + # Resolve ${VAR:-default} patterns + while [[ "$line" =~ \$\{([A-Za-z_][A-Za-z0-9_]*)(:-)([^}]*)\} ]]; do + local var="${BASH_REMATCH[1]}" def="${BASH_REMATCH[3]}" + local val="${!var:-$def}" + line="${line/\$\{${var}:-${def}\}/$val}" + done + # Resolve ${VAR} patterns (no default) + while [[ "$line" =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\} ]]; do + local var="${BASH_REMATCH[1]}" + local val="${!var:-}" + line="${line/\$\{${var}\}/$val}" + done + printf '%s\n' "$line" + done < "$template" > "$output" + + chmod 644 "$output" +} + +# --------------------------------------------------------------------------- +# activate_swarm_secrets — uncomment secrets blocks in generated compose +# --------------------------------------------------------------------------- +# Called after `docker secret create` succeeds. Uncomments: +# - Per-service secrets: references under each service +# - Top-level secrets: declaration block + +activate_swarm_secrets() { + local compose_file="$1" + [ -f "$compose_file" ] || return 1 + + # Uncomment service-level secrets (indented "# secrets:" and "# - name") + sed -i \ + -e 's/^ # secrets:$/ secrets:/' \ + -e 's/^ # - \(.*_password\)$/ - \1/' \ + -e 's/^ # - \(signature\)$/ - \1/' \ + "$compose_file" + + # Uncomment top-level secrets block + sed -i \ + -e 's/^# secrets:$/secrets:/' \ + -e 's/^# \(.*_password:\)$/ \1/' \ + -e 's/^# \(signature:\)$/ \1/' \ + -e 's/^# external: true$/ external: true/' \ + "$compose_file" +} + +# --------------------------------------------------------------------------- +# generate_swarm_compose — write the Swarm-ready YAML file +# --------------------------------------------------------------------------- +# Call after extract_swarm_config() (upgrade) or with defaults (fresh). + +generate_swarm_compose() { + local output="$1" + local is_upgrade="${2:-false}" + + # Initialize arrays if not set (fresh generation path) + _swarm_networks=("${_swarm_networks[@]+"${_swarm_networks[@]}"}") + _swarm_networks_external=("${_swarm_networks_external[@]+"${_swarm_networks_external[@]}"}") + _swarm_volumes=("${_swarm_volumes[@]+"${_swarm_volumes[@]}"}") + _swarm_volumes_external=("${_swarm_volumes_external[@]+"${_swarm_volumes_external[@]}"}") + _swarm_bind_mounts_datomic=("${_swarm_bind_mounts_datomic[@]+"${_swarm_bind_mounts_datomic[@]}"}") + _swarm_bind_mounts_app=("${_swarm_bind_mounts_app[@]+"${_swarm_bind_mounts_app[@]}"}") + _swarm_traefik_labels=("${_swarm_traefik_labels[@]+"${_swarm_traefik_labels[@]}"}") + _swarm_datomic_env=("${_swarm_datomic_env[@]+"${_swarm_datomic_env[@]}"}") + _swarm_app_env=("${_swarm_app_env[@]+"${_swarm_app_env[@]}"}") + + # Stack name — use if/else to avoid nested ${} expansion bugs + local stack_name datomic_svc app_svc datomic_image app_image + [ -n "${_swarm_stack_name:-}" ] && stack_name="$_swarm_stack_name" || stack_name='${COMPOSE_PROJECT_NAME:-orcpub}' + [ -n "${_swarm_datomic_svc:-}" ] && datomic_svc="$_swarm_datomic_svc" || datomic_svc="datomic" + [ -n "${_swarm_app_svc:-}" ] && app_svc="$_swarm_app_svc" || app_svc="orcpub" + [ -n "${_swarm_datomic_image:-}" ] && datomic_image="$_swarm_datomic_image" || datomic_image='${DATOMIC_IMAGE:-orcpub-datomic}' + [ -n "${_swarm_app_image:-}" ] && app_image="$_swarm_app_image" || app_image='${ORCPUB_IMAGE:-orcpub-app}' + + # Resource limits + local d_mem_limit d_mem_res a_mem_limit a_mem_res + [ -n "${_swarm_datomic_mem_limit:-}" ] && d_mem_limit="$_swarm_datomic_mem_limit" || d_mem_limit='${DATOMIC_MEMORY_LIMIT:-2G}' + [ -n "${_swarm_datomic_mem_reservation:-}" ] && d_mem_res="$_swarm_datomic_mem_reservation" || d_mem_res='${DATOMIC_MEMORY_RESERVATION:-1G}' + [ -n "${_swarm_app_mem_limit:-}" ] && a_mem_limit="$_swarm_app_mem_limit" || a_mem_limit='${APP_MEMORY_LIMIT:-2G}' + [ -n "${_swarm_app_mem_reservation:-}" ] && a_mem_res="$_swarm_app_mem_reservation" || a_mem_res='${APP_MEMORY_RESERVATION:-1G}' + + # JVM + local xms xmx java_opts + [ -n "${_swarm_xms:-}" ] && xms="$_swarm_xms" || xms='${XMS:--Xms1g}' + [ -n "${_swarm_xmx:-}" ] && xmx="$_swarm_xmx" || xmx='${XMX:--Xmx1g}' + [ -n "${_swarm_java_opts:-}" ] && java_opts="$_swarm_java_opts" || java_opts='${JAVA_OPTS:-}' + + # --- Build network blocks --- + local app_networks="" datomic_networks="" net_block="" + if [ "${#_swarm_networks[@]}" -gt 0 ] 2>/dev/null; then + for net in "${_swarm_networks[@]}"; do + app_networks="${app_networks} - ${net}\n" + # Datomic skips proxy/mail networks + case "$net" in + traefik*|postfix*|mail*) ;; + *) datomic_networks="${datomic_networks} - ${net}\n" ;; + esac + done + net_block="networks:" + for net in "${_swarm_networks[@]}"; do + local is_ext=false + for ext in "${_swarm_networks_external[@]}"; do + [ "$ext" = "$net" ] && is_ext=true && break + done + if [ "$is_ext" = "true" ]; then + net_block="${net_block}\n ${net}:\n external: true" + else + net_block="${net_block}\n ${net}:\n driver: overlay" + fi + done + else + app_networks=" - backend\n # - traefik-public # Uncomment if using Traefik" + datomic_networks=" - backend" + net_block="networks:\n backend:\n driver: overlay\n # traefik-public:\n # external: true" + fi + + # --- Build volume blocks --- + local d_vol_mounts="" vol_block="" + if [ "${#_swarm_volumes[@]}" -gt 0 ] 2>/dev/null; then + # Re-read actual volume mount lines from existing file for datomic + while IFS= read -r line; do + [ -n "$line" ] && d_vol_mounts="${d_vol_mounts} - ${line}\n" + done < <(sed -n "/^ ${datomic_svc}:/,/^ [a-z]/{/volumes:/,/^[[:space:]]*[a-z]/{/^\s*-\s*[a-z]/p}}" "$SWARM_COMPOSE" 2>/dev/null | sed 's/^[[:space:]]*-[[:space:]]*//' || true) + # Fallback if nothing found + [ -z "$d_vol_mounts" ] && d_vol_mounts=" - ${_swarm_volumes[0]}:/data\n" + + vol_block="volumes:" + for v in "${_swarm_volumes[@]}"; do + local is_ext=false + for ext in "${_swarm_volumes_external[@]}"; do + [ "$ext" = "$v" ] && is_ext=true && break + done + if [ "$is_ext" = "true" ]; then + vol_block="${vol_block}\n ${v}:\n external: true" + else + vol_block="${vol_block}\n ${v}:" + fi + done + else + d_vol_mounts=" - orcpub_data:/data\n - orcpub_logs:/log\n - orcpub_backups:/backups" + vol_block="volumes:\n orcpub_data:\n external: true\n orcpub_logs:\n external: true\n orcpub_backups:\n external: true" + fi + + # Bind mounts + local d_bind_mounts="" a_bind_mounts="" + for m in "${_swarm_bind_mounts_datomic[@]+"${_swarm_bind_mounts_datomic[@]}"}"; do + [ -n "$m" ] && d_bind_mounts="${d_bind_mounts} - ${m}\n" + done + for m in "${_swarm_bind_mounts_app[@]+"${_swarm_bind_mounts_app[@]}"}"; do + [ -n "$m" ] && a_bind_mounts="${a_bind_mounts} - ${m}\n" + done + + # --- Build env blocks --- + # Define canonical env vars for each service (name:default pairs) + local -a _d_env_defaults=( + "ADMIN_PASSWORD:\${ADMIN_PASSWORD}" + "DATOMIC_PASSWORD:\${DATOMIC_PASSWORD}" + "ALT_HOST:\${ALT_HOST:-datomic}" + "ENCRYPT_CHANNEL:\${ENCRYPT_CHANNEL:-true}" + "XMS:${xms}" + "XMX:${xmx}" + "TZ:\${TZ:-America/Chicago}" + ) + local -a _a_env_defaults=( + "PORT:\${PORT:-8890}" + "EMAIL_SERVER_URL:\${EMAIL_SERVER_URL:-}" + "EMAIL_ACCESS_KEY:\${EMAIL_ACCESS_KEY:-}" + "EMAIL_SECRET_KEY:\${EMAIL_SECRET_KEY:-}" + "EMAIL_SERVER_PORT:\${EMAIL_SERVER_PORT:-587}" + "EMAIL_FROM_ADDRESS:\${EMAIL_FROM_ADDRESS:-}" + "EMAIL_ERRORS_TO:\${EMAIL_ERRORS_TO:-}" + "EMAIL_SSL:\${EMAIL_SSL:-FALSE}" + "EMAIL_TLS:\${EMAIL_TLS:-FALSE}" + "SIGNATURE:\${SIGNATURE}" + "TZ:\${TZ:-America/Chicago}" + "DATOMIC_URL:\${DATOMIC_URL:-datomic:dev://datomic:4334/orcpub}" + "DATOMIC_PASSWORD:\${DATOMIC_PASSWORD}" + "JAVA_OPTS:${java_opts}" + "CSP_POLICY:\${CSP_POLICY:-strict}" + ) + + # Build env blocks: carry forward old vars, append any new canonical vars missing from old + local d_env_block="" a_env_block="" + + _build_env_block() { + local block="" var_name default_val + local -n _old_env=$1; shift + local -n _defaults=$1; shift + + # Start with old vars if upgrading + if [ "$is_upgrade" = "true" ] && [ "${#_old_env[@]}" -gt 0 ] 2>/dev/null; then + for e in "${_old_env[@]}"; do + block="${block} ${e}\n" + done + # Append new vars not in old + for pair in "${_defaults[@]}"; do + var_name="${pair%%:*}" + default_val="${pair#*:}" + local found=false + for e in "${_old_env[@]}"; do + [[ "$e" == "${var_name}:"* ]] && found=true && break + done + [ "$found" = "false" ] && block="${block} ${var_name}: ${default_val}\n" + done + else + for pair in "${_defaults[@]}"; do + var_name="${pair%%:*}" + default_val="${pair#*:}" + block="${block} ${var_name}: ${default_val}\n" + done + fi + printf '%s' "$block" + } + + d_env_block=$(_build_env_block _swarm_datomic_env _d_env_defaults) + a_env_block=$(_build_env_block _swarm_app_env _a_env_defaults) + + # Traefik labels + local traefik_block="" + if [ "${#_swarm_traefik_labels[@]}" -gt 0 ] 2>/dev/null; then + traefik_block=" labels:" + for label in "${_swarm_traefik_labels[@]}"; do + traefik_block="${traefik_block}\n - ${label}" + done + else + traefik_block=' # --- Traefik labels (uncomment and customize) --- + # labels: + # - traefik.enable=true + # - traefik.docker.network=traefik-public + # - traefik.http.routers.orcpub.rule=Host(`your.domain.com`) + # - traefik.http.routers.orcpub.entrypoints=websecure + # - traefik.http.routers.orcpub.tls=true + # - traefik.http.routers.orcpub.tls.certresolver=letsencrypt + # - traefik.http.services.orcpub.loadbalancer.server.port=${PORT:-8890}' + fi + + # --- Write the file --- + cat > "$output" <<SWARM_YAML +# Generated by ./${SCRIPT_NAME} --swarm on $(date -u +"%Y-%m-%d %H:%M:%S UTC") +# Import into Portainer or deploy with: docker stack deploy -c $(basename "$output") <stack> +name: ${stack_name} + +services: + ${datomic_svc}: + image: ${datomic_image} + environment: +$(printf '%b' "$d_env_block") + volumes: +$(printf '%b' "${d_bind_mounts}${d_vol_mounts}") + healthcheck: + test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 40s + networks: +$(printf '%b' "$datomic_networks") + deploy: + resources: + limits: + memory: ${d_mem_limit} + reservations: + memory: ${d_mem_res} + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 5 + window: 120s + update_config: + parallelism: 1 + delay: 10s + order: stop-first + failure_action: rollback + rollback_config: + parallelism: 1 + order: stop-first + + ${app_svc}: + image: ${app_image} + environment: +$(printf '%b' "$a_env_block") + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:\${PORT:-8890}/health"] + interval: 30s + timeout: 5s + retries: 30 + start_period: 60s$( + # Only emit volumes: block if there are bind mounts + if [ -n "$a_bind_mounts" ]; then + printf '\n volumes:\n%b' "$a_bind_mounts" + fi +) + networks: +$(printf '%b' "$app_networks") + deploy: + resources: + limits: + memory: ${a_mem_limit} + reservations: + memory: ${a_mem_res} + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 5 + window: 120s + update_config: + parallelism: 1 + delay: 10s + order: start-first + failure_action: rollback + rollback_config: + parallelism: 1 + order: stop-first +${traefik_block} + # secrets: + # - datomic_password + # - signature + +# --- Secrets (uncomment to use Swarm encrypted secrets) --- +# secrets: +# datomic_password: +# external: true +# admin_password: +# external: true +# signature: +# external: true + +$(printf '%b' "$vol_block") + +$(printf '%b' "$net_block") +SWARM_YAML + + chmod 644 "$output" +} + +# --------------------------------------------------------------------------- +# print_swarm_summary — colorized full-file CLI output +# --------------------------------------------------------------------------- + +print_swarm_summary() { + local compose_file="$1" + local is_upgrade="${2:-false}" + local backup_file="${3:-}" + + # Safe defaults for unset variables + : "${_swarm_has_transactor_props:=false}" + _swarm_volumes=("${_swarm_volumes[@]+"${_swarm_volumes[@]}"}") + + printf '\n%s ╔══════════════════════════════════════════════════════════╗%s\n' "$color_bold" "$color_reset" + printf '%s ║ Dungeon Master'\''s Vault — Swarm Compose Generator ║%s\n' "$color_bold" "$color_reset" + printf '%s ╚══════════════════════════════════════════════════════════╝%s\n\n' "$color_bold" "$color_reset" + + printf ' %s── Environment ──────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + printf ' %s✓%s .env loaded\n' "$color_green" "$color_reset" + if [ -n "$backup_file" ]; then + printf ' %s✓%s .env backed up\n' "$color_green" "$color_reset" + fi + echo + + if [ "$is_upgrade" = "true" ] && [ -n "$backup_file" ]; then + printf ' %s── Existing Swarm Compose Detected ──────────────────────────%s\n' "$color_dim" "$color_reset" + printf ' %s✓%s Backed up: %s%s%s\n' "$color_green" "$color_reset" "$color_dim" "$backup_file" "$color_reset" + echo + + printf ' %s── Legend ───────────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + printf ' %s│%s white %s│%s unchanged — your config, carried forward\n' "$color_dim" "$color_reset" "$color_dim" "$color_reset" + printf ' %s│%s %scyan%s %s│%s new line — upstream addition, default value\n' "$color_dim" "$color_reset" "$color_cyan" "$color_reset" "$color_dim" "$color_reset" + printf ' %s│%s %sgreen%s %s│%s new line — upstream addition, %syour%s value from .env\n' "$color_dim" "$color_reset" "$color_green" "$color_reset" "$color_dim" "$color_reset" "$color_bold" "$color_reset" + printf ' %s│%s %syellow%s %s│%s changed — value differs from your previous config\n' "$color_dim" "$color_reset" "$color_yellow" "$color_reset" "$color_dim" "$color_reset" + echo + fi + + printf ' %s── %s ────────────────────────────────────────%s\n' "$color_dim" "$(basename "$compose_file")" "$color_reset" + + # Print file with color coding + if [ "$is_upgrade" = "true" ] && [ -n "$backup_file" ] && [ -f "$backup_file" ]; then + # Line-by-line color comparison against backup (old) file + while IFS= read -r line; do + local trimmed + trimmed=$(echo "$line" | sed 's/^[[:space:]]*//') + + # Skip empty lines and comments — print dim + if [ -z "$trimmed" ] || [[ "$trimmed" == \#* ]]; then + printf ' %s%s%s\n' "$color_dim" "$line" "$color_reset" + continue + fi + + # Structural YAML keywords — print dim + if [[ "$trimmed" =~ ^(name:|services:|volumes:|networks:|secrets:) ]] || \ + [[ "$trimmed" =~ ^[a-z_]+:$ ]]; then + printf ' %s%s%s\n' "$color_dim" "$line" "$color_reset" + continue + fi + + # Check if line exists verbatim in old file → white (unchanged) + if grep -qFx "$line" "$backup_file" 2>/dev/null; then + printf ' %s\n' "$line" + continue + fi + + # Extract key and indentation for context-aware matching + local key="" indent="" + indent=$(echo "$line" | sed 's/[^ ].*//') + if [[ "$trimmed" =~ ^([A-Za-z_][A-Za-z0-9_-]*): ]]; then + key="${BASH_REMATCH[1]}" + fi + + # If we have a key, check if same key at same indent existed with different value → yellow + if [ -n "$key" ]; then + local old_line + old_line=$(grep -E "^${indent}${key}:" "$backup_file" 2>/dev/null | head -1 || true) + if [ -n "$old_line" ]; then + local old_val new_val + old_val=$(echo "$old_line" | sed "s/^[[:space:]]*${key}:[[:space:]]*//" | tr -d '\r') + new_val=$(echo "$trimmed" | sed "s/^${key}:[[:space:]]*//" | tr -d '\r') + if [ "$old_val" != "$new_val" ]; then + printf ' %s%s%s %s# CHANGED (was: %s)%s\n' "$color_yellow" "$line" "$color_reset" "$color_yellow" "$old_val" "$color_reset" + continue + fi + fi + fi + + # New line — check if it references an env var the admin has set → green, else → cyan + if [[ "$trimmed" =~ \$\{([A-Z_][A-Z0-9_]*)(:-)([^}]*)\} ]]; then + local var_name="${BASH_REMATCH[1]}" + local default_val="${BASH_REMATCH[3]}" + local env_val + env_val=$(read_env_val "$var_name" "$ENV_FILE" 2>/dev/null || true) + if [ -n "$env_val" ] && [ "$env_val" != "$default_val" ]; then + printf ' %s%s%s %s# NEW — set to %s in .env%s\n' "$color_green" "$line" "$color_reset" "$color_green" "$env_val" "$color_reset" + else + local resolved="${default_val}" + [ -n "$env_val" ] && resolved="$env_val" + printf ' %s%s%s %s# NEW — using default: %s%s\n' "$color_cyan" "$line" "$color_reset" "$color_cyan" "$resolved" "$color_reset" + fi + continue + fi + + # New line without env var reference → cyan + printf ' %s%s%s %s# NEW%s\n' "$color_cyan" "$line" "$color_reset" "$color_cyan" "$color_reset" + done < "$compose_file" + else + # Fresh generation — all lines are new (cyan) + while IFS= read -r line; do + printf ' %s%s%s\n' "$color_cyan" "$line" "$color_reset" + done < "$compose_file" + fi + echo + + # Warnings + if [ "$is_upgrade" = "true" ]; then + local has_warnings=false + + [ "$_swarm_has_transactor_props" = "true" ] 2>/dev/null && has_warnings=true + + if [ "$has_warnings" = "true" ]; then + printf ' %s── Warnings ────────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + echo + + if [ "$_swarm_has_transactor_props" = "true" ] 2>/dev/null; then + printf ' %s⚠%s %stransactor.properties%s is bind-mounted — template changes won'\''t apply.\n' "$color_yellow" "$color_reset" "$color_bold" "$color_reset" + printf ' See: %stransactor.properties.reference%s\n\n' "$color_dim" "$color_reset" + fi + fi + fi + + # Files + printf ' %s── Files ───────────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + printf ' %s%s%s %s← ready to deploy%s\n' "$color_bold" "$compose_file" "$color_reset" "$color_green" "$color_reset" + if [ -n "$backup_file" ]; then + printf ' %s%s%s\n' "$color_dim" "$backup_file" "$color_reset" + fi + if [ "$_swarm_has_transactor_props" = "true" ] 2>/dev/null; then + printf ' %s%s/transactor.properties.reference%s\n' "$color_dim" "$SCRIPT_DIR" "$color_reset" + fi + if [ -f "$SWARM_PORTAINER_ENV" ]; then + printf ' %s%s%s %s← paste into Portainer%s\n' "$color_bold" "$SWARM_PORTAINER_ENV" "$color_reset" "$color_green" "$color_reset" + fi + printf ' %s%s%s %s← full .env%s\n' "$color_bold" "$ENV_FILE" "$color_reset" "$color_green" "$color_reset" + echo + + # Deploy instructions (fresh only) + if [ "$is_upgrade" != "true" ]; then + printf ' %s── Deploy ───────────────────────────────────────────────────%s\n' "$color_dim" "$color_reset" + echo + printf ' %sPortainer:%s\n' "$color_bold" "$color_reset" + printf ' 1. Stacks → Add stack → Web editor\n' + printf ' 2. Paste contents of %s\n' "$(basename "$compose_file")" + printf ' 3. Environment variables → Advanced mode\n' + printf ' 4. Paste contents of %s\n' ".env.portainer" + echo + printf ' %sCLI:%s\n' "$color_bold" "$color_reset" + printf ' set -a; source .env; set +a\n' + printf ' docker stack deploy -c %s %s\n' "$(basename "$compose_file")" "${_swarm_stack_name:-orcpub}" + echo + + # Volume pre-creation reminder + if [ "${#_swarm_volumes[@]}" -eq 0 ] 2>/dev/null; then + printf ' %sPre-create volumes:%s\n' "$color_bold" "$color_reset" + printf ' docker volume create orcpub_data\n' + printf ' docker volume create orcpub_logs\n' + printf ' docker volume create orcpub_backups\n' + echo + fi + fi +} diff --git a/test/docker/test-upgrade.sh b/test/docker/test-upgrade.sh index 71911fe1b..17587ec88 100755 --- a/test/docker/test-upgrade.sh +++ b/test/docker/test-upgrade.sh @@ -282,6 +282,182 @@ run_secrets_test() { rm -rf "$tmpdir" } +# --------------------------------------------------------------------------- +# Swarm tests — generation, extraction, upgrade, .env.portainer +# --------------------------------------------------------------------------- + +run_swarm_tests() { + local label="[swarm] " + local tmpdir + tmpdir=$(mktemp -d) + + # Create a minimal .env + cat > "${tmpdir}/.env" <<'SWARMENV' +ADMIN_PASSWORD=swarm_admin_test +DATOMIC_PASSWORD=swarm_datomic_test +SIGNATURE=swarm_sig_test +PORT=8890 +ALT_HOST=datomic +ENCRYPT_CHANNEL=true +EMAIL_SSL=TRUE +CSP_POLICY=relaxed +SWARMENV + + # Source run's helpers (swarm.sh gets sourced by run) + SCRIPT_DIR="$tmpdir" + SCRIPT_NAME="run" + ENV_FILE="${tmpdir}/.env" + # Helpers needed by swarm.sh + color_green=$'\033[0;32m' + color_yellow=$'\033[1;33m' + color_red=$'\033[0;31m' + color_cyan=$'\033[0;36m' + color_magenta=$'\033[0;35m' + color_reset=$'\033[0m' + color_bold=$'\033[1m' + color_dim=$'\033[2m' + read_env_val() { grep -m1 "^${1}=" "$2" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true; } + + # Source swarm functions + # shellcheck source=../../scripts/swarm.sh + . "${PROJECT_ROOT}/scripts/swarm.sh" + SWARM_COMPOSE="${tmpdir}/docker-compose.swarm.yaml" + SWARM_PORTAINER_ENV="${tmpdir}/.env.portainer" + + # --- Test: Fresh generation --- + printf '\n%s--- swarm (fresh) ---%s\n' "$cyan" "$reset" + + generate_swarm_compose "$SWARM_COMPOSE" "false" + if [ -f "$SWARM_COMPOSE" ]; then + pass "${label}compose generated" + else + fail "${label}compose not generated" + fi + TESTS=$((TESTS + 1)) + + # File should contain service blocks + if grep -q '^ datomic:' "$SWARM_COMPOSE"; then + pass "${label}has datomic service" + else + fail "${label}missing datomic service" + fi + TESTS=$((TESTS + 1)) + + if grep -q '^ orcpub:' "$SWARM_COMPOSE"; then + pass "${label}has orcpub service" + else + fail "${label}missing orcpub service" + fi + TESTS=$((TESTS + 1)) + + # No double braces (regression test for nested ${} expansion bug) + if grep -q '}}' "$SWARM_COMPOSE"; then + fail "${label}double braces found (expansion bug)" + else + pass "${label}no double braces" + fi + TESTS=$((TESTS + 1)) + + # Has deploy sections + if grep -q 'restart_policy:' "$SWARM_COMPOSE"; then + pass "${label}has restart_policy" + else + fail "${label}missing restart_policy" + fi + TESTS=$((TESTS + 1)) + + if grep -q 'update_config:' "$SWARM_COMPOSE"; then + pass "${label}has update_config" + else + fail "${label}missing update_config" + fi + TESTS=$((TESTS + 1)) + + # --- Test: .env.portainer --- + generate_env_portainer "$ENV_FILE" "$SWARM_PORTAINER_ENV" + if [ -f "$SWARM_PORTAINER_ENV" ]; then + pass "${label}.env.portainer generated" + else + fail "${label}.env.portainer not generated" + fi + TESTS=$((TESTS + 1)) + + # No comments, no blank lines, no quotes in portainer env + if grep -q '^\s*#' "$SWARM_PORTAINER_ENV"; then + fail "${label}.env.portainer has comments" + else + pass "${label}.env.portainer clean (no comments)" + fi + TESTS=$((TESTS + 1)) + + if grep -q '^\s*$' "$SWARM_PORTAINER_ENV"; then + fail "${label}.env.portainer has blank lines" + else + pass "${label}.env.portainer clean (no blanks)" + fi + TESTS=$((TESTS + 1)) + + # --- Test: Extract + roundtrip --- + printf '\n%s--- swarm (upgrade roundtrip) ---%s\n' "$cyan" "$reset" + + extract_swarm_config "$SWARM_COMPOSE" + if [ $? -eq 0 ]; then + pass "${label}extraction succeeded" + else + fail "${label}extraction failed" + fi + TESTS=$((TESTS + 1)) + + # Backup and regenerate + local backup="${SWARM_COMPOSE}.backup" + cp "$SWARM_COMPOSE" "$backup" + generate_swarm_compose "$SWARM_COMPOSE" "true" + + # Regenerated file should still have services + if grep -q '^ datomic:' "$SWARM_COMPOSE" && grep -q '^ orcpub:' "$SWARM_COMPOSE"; then + pass "${label}upgrade preserves services" + else + fail "${label}upgrade lost services" + fi + TESTS=$((TESTS + 1)) + + # --- Test: Upgrade adds new env vars --- + # Remove CSP_POLICY from old file, verify it appears in regenerated + sed -i '/CSP_POLICY/d' "$backup" + extract_swarm_config "$backup" + generate_swarm_compose "$SWARM_COMPOSE" "true" + if grep -q 'CSP_POLICY:' "$SWARM_COMPOSE"; then + pass "${label}upgrade adds new env var (CSP_POLICY)" + else + fail "${label}upgrade missing new env var (CSP_POLICY)" + fi + TESTS=$((TESTS + 1)) + + # --- Test: activate_swarm_secrets --- + printf '\n%s--- swarm (secrets activation) ---%s\n' "$cyan" "$reset" + + # Start with fresh compose + generate_swarm_compose "$SWARM_COMPOSE" "false" + # Secrets should be commented + if grep -q '^# secrets:' "$SWARM_COMPOSE"; then + pass "${label}secrets initially commented" + else + fail "${label}secrets not commented" + fi + TESTS=$((TESTS + 1)) + + activate_swarm_secrets "$SWARM_COMPOSE" + # Top-level secrets: should now be uncommented + if grep -q '^secrets:' "$SWARM_COMPOSE"; then + pass "${label}secrets uncommented after activation" + else + fail "${label}secrets still commented after activation" + fi + TESTS=$((TESTS + 1)) + + rm -rf "$tmpdir" +} + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -290,6 +466,8 @@ run_secrets_test() { if [ $# -gt 0 ]; then if [ "$1" = "secrets" ]; then run_secrets_test + elif [ "$1" = "swarm" ]; then + run_swarm_tests else fixture="${FIXTURE_DIR}/env-${1}.env" if [ ! -f "$fixture" ]; then @@ -306,6 +484,7 @@ else run_fixture "$f" done run_secrets_test + run_swarm_tests fi # Summary From a4556c58de9b5b4581bb05dcfe6dbfe13b4b23a9 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Fri, 6 Mar 2026 03:30:02 +0000 Subject: [PATCH 49/50] docs: update DOCKER.md with Swarm/Portainer workflow Rewrite Docker Swarm Deployment section to cover: - ./run --swarm generates text files (no daemon dependency) - Compose vs Swarm comparison table - Quick start with CLI deploy - Portainer import workflow (Advanced mode paste) - Upgrade behavior with colorized diff - JVM memory guidance (MaxRAMPercentage vs explicit heap) - File-based vs Swarm Raft secrets as distinct paths - File inventory updated with new generated files --- docs/DOCKER.md | 141 ++++++++++++++++++++++++++++++------------------- 1 file changed, 87 insertions(+), 54 deletions(-) diff --git a/docs/DOCKER.md b/docs/DOCKER.md index b34542e68..ca988d12a 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -376,45 +376,77 @@ Rules of thumb (from Datomic capacity planning docs): ## Docker Swarm Deployment -The same `docker-compose.yaml` works for Swarm with two `.env` changes and -an optional `deploy:` section. +`./run --swarm` generates a Swarm-compatible compose file and a Portainer-ready +env file. It produces **text files only** — no Docker daemon required. -### What changes from single-host +### What changes from Compose -| Setting | Single-host (default) | Swarm | -|---------|-----------------------|-------| -| `ALT_HOST` | `127.0.0.1` | `datomic` | -| Network | bridge (auto) | overlay (auto with `docker stack deploy`) | -| Secrets | `.env` file (or `file:` secrets) | `.env` file, `file:` secrets, or `external` secrets | +| Concern | Compose (`docker-compose.yaml`) | Swarm (`docker-compose.swarm.yaml`) | +|---------|---------------------------------|-------------------------------------| +| Volumes | Bind mounts (`./data:/data`) | Named volumes (`orcpub_data`) with `external: true` | +| Dependencies | `depends_on` + healthchecks | Removed (Swarm ignores `depends_on`) | +| Restart | `restart: always` | `deploy.restart_policy` | +| Build | `build:` supported | Removed (Swarm needs pre-built images) | +| Networks | Default bridge | Explicit overlay (`backend`) | +| `.env` file | Auto-read by `docker compose` | **Not read** by `docker stack deploy` | -`host=datomic` in the transactor config already resolves via Docker DNS on -both bridge and overlay networks — no template change needed. - -### Steps +### Quick start ```sh -# 1. Initialize Swarm (once per cluster) -docker swarm init - -# 2. Edit .env — change ALT_HOST for multi-node overlay DNS -# ALT_HOST=datomic -# (everything else stays the same) +# 1. Generate .env (if you haven't already) +./run -# 3. (Optional) Move passwords into Swarm secrets +# 2. Generate Swarm compose + Portainer env file ./run --swarm -# This creates docker-compose.secrets.yaml and wires COMPOSE_FILE in .env -# 4. Build images (Swarm doesn't build — it needs pre-built images) -docker compose build +# 3. Pre-create named volumes (Swarm won't auto-create external volumes) +docker volume create orcpub_data +docker volume create orcpub_logs +docker volume create orcpub_backups -# 5. Deploy the stack -docker stack deploy -c docker-compose.yaml -c docker-compose.secrets.yaml orcpub +# 4. Deploy via CLI +set -a; source .env; set +a +docker stack deploy -c docker-compose.swarm.yaml orcpub -# 6. Check service status +# 5. Check status docker stack services orcpub docker service logs orcpub_orcpub --follow ``` +### Generated files + +| File | Purpose | +|------|---------| +| `docker-compose.swarm.yaml` | Swarm-ready compose — named volumes, deploy sections, overlay networks | +| `.env.portainer` | Flat `KEY=VALUE` file — no comments, no blank lines, no quotes. Paste into Portainer's "Advanced mode" env editor | +| `transactor.properties.reference` | Generated when you bind-mount a custom `transactor.properties`. Shows current template values so you can diff against your file | + +### Portainer import + +Portainer has no `.env` file upload. Use its "Advanced mode" for bulk env vars: + +1. **Stacks → Add stack** (or update existing) +2. Paste `docker-compose.swarm.yaml` into the compose editor +3. Click **Advanced mode** in the Environment variables section +4. Paste the contents of `.env.portainer` (one `KEY=VALUE` per line) +5. Deploy + +### Upgrading an existing Swarm deployment + +Running `./run --swarm` when `docker-compose.swarm.yaml` already exists: + +1. Backs up the existing file (timestamped `.bak`) +2. Extracts your customizations (Traefik labels, resource limits, network names, JVM settings, env var values, stack name) +3. Regenerates the compose with the latest template, preserving your customizations +4. Shows a colorized diff: + - **White** — unchanged lines + - **Cyan** — new upstream lines (using defaults) + - **Green** — new upstream lines where your `.env` value was applied + - **Yellow** — lines where the value changed from the old file + +New canonical env vars added upstream are appended to each service's +environment block — existing vars are never reordered or removed. + ### Scaling notes - **datomic**: Must be exactly 1 replica (Datomic transactor is a singleton). @@ -423,6 +455,21 @@ docker service logs orcpub_orcpub --follow - **web**: Can scale freely. Each replica proxies to any `orcpub` replica via Swarm's built-in load balancing. +### JVM memory guidance + +Do **not** set heap equal to the container memory limit — the JVM needs +headroom for off-heap memory (metaspace, thread stacks, NIO buffers). + +| Approach | Example | When to use | +|----------|---------|-------------| +| Auto-percentage (recommended) | `JAVA_OPTS=-XX:MaxRAMPercentage=75.0` | JDK 11+, lets JVM scale with container limit | +| Explicit heap | `XMS=-Xms1g` / `XMX=-Xmx1g` | When you need predictable fixed sizing | +| Default (no setting) | Leave `JAVA_OPTS`, `XMS`, `XMX` empty | Small deployments, JVM picks conservative defaults | + +The Swarm compose sets `deploy.resources.limits.memory` (hard ceiling) and +`deploy.resources.reservations.memory` (scheduling minimum). Configure these +in `.env` via `APP_MEMORY_LIMIT` and `APP_MEMORY_RESERVATION`. + ### Docker Secrets Docker secrets mount passwords as files at `/run/secrets/<name>` inside @@ -435,43 +482,26 @@ changes needed. #### File-based secrets (single server, no Swarm) -The setup script handles everything: - ```sh ./run --secrets ``` -This reads your passwords from `.env` (or shell env vars if you export -directly), creates a `secrets/` directory with one file per password, -generates `docker-compose.secrets.yaml`, and adds `COMPOSE_FILE` to -your `.env` so compose merges both files automatically. - -Under the hood it creates: -- `secrets/datomic_password` -- `secrets/admin_password` -- `secrets/signature` -- `docker-compose.secrets.yaml` - -Each file has `chmod 600` (only your user can read it). This is still a -file on your hard drive — not encrypted — but each password is isolated -with strict permissions instead of all sitting together in `.env`. +Creates a `secrets/` directory with one file per password (`chmod 600`), +generates `docker-compose.secrets.yaml`, and adds `COMPOSE_FILE` to `.env` +so compose merges both files automatically. -#### Swarm secrets (cluster) +#### Swarm Raft secrets (cluster) -If you're running Docker Swarm, passwords are stored encrypted inside -the cluster. When a container needs one, Swarm delivers it into memory — -the password is never saved to disk on the server running the container. +Passwords are stored encrypted in the Swarm Raft log. Containers receive +them via an in-memory tmpfs mount — never written to disk on worker nodes. ```sh -./run --swarm +./run --swarm --secrets ``` -This checks that your node is in Swarm mode, reads passwords from `.env` -(or shell env vars), runs `docker secret create` for each one, and -generates `docker-compose.secrets.yaml`. If you run `--secrets` instead, -it will ask if you're using Swarm and redirect you here automatically. - -Deploy with `docker stack deploy` instead of `docker compose up`. +This generates the Swarm compose (if not already present), then creates +Docker secrets via `docker secret create` and uncomments the `secrets:` +blocks in the generated compose file. Requires a running Swarm manager. #### What changes when using secrets @@ -495,8 +525,11 @@ trailing newlines defensively, but `printf` avoids the issue entirely. | `deploy/nginx.conf.template` | Nginx reverse proxy template (`envsubst` resolves `${ORCPUB_PORT}`) | | `deploy/snakeoil.sh` | Self-signed SSL certificate generator | | `docker-compose.yaml` | Compose file (pull or build-from-source) | -| `docker-compose.secrets.yaml` | Generated by `--secrets`/`--swarm` — merges secrets into compose | -| `run` | Interactive setup: generates `.env`, dirs, SSL certs, secrets | +| `docker-compose.secrets.yaml` | Generated by `--secrets` — merges file-based secrets into compose | +| `docker-compose.swarm.yaml` | Generated by `--swarm` — Swarm-ready compose (named volumes, deploy sections) | +| `.env.portainer` | Generated by `--swarm` — flat KEY=VALUE for Portainer's Advanced mode env editor | +| `run` | Interactive setup: generates `.env`, dirs, SSL certs, secrets, Swarm compose | +| `scripts/swarm.sh` | Swarm compose generation functions (sourced by `run`) | | `.env.example` | Environment variable reference with defaults | ## Security From 62c8212a12f6a25878b75b79fbacc1fd10ccbb80 Mon Sep 17 00:00:00 2001 From: codeGlaze <github@codeglaze.com> Date: Fri, 6 Mar 2026 03:34:43 +0000 Subject: [PATCH 50/50] =?UTF-8?q?fix:=20CI=20shellcheck=20=E2=80=94=20add?= =?UTF-8?q?=20-x=20flag=20and=20suppress=20info-level=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shellcheck needs -x to follow sourced files (scripts/swarm.sh from run). Use --severity=warning to skip intentional info-level findings: - SC2016: single-quoted ${VAR} templates are literal by design - SC2094: heredoc-to-file is not a read+write pipeline - SC2154: color_* vars defined by sourcing script, suppressed via directive Also adds scripts/swarm.sh to the lint targets. --- .github/workflows/docker-integration.yml | 2 +- scripts/swarm.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 9c4462f39..5f3d91390 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -65,7 +65,7 @@ jobs: - name: Lint shell scripts run: | sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck - shellcheck run docker-user.sh + shellcheck -x --severity=warning run docker-user.sh scripts/swarm.sh - name: Run run --auto run: | diff --git a/scripts/swarm.sh b/scripts/swarm.sh index 7cd45472e..f216f9f40 100644 --- a/scripts/swarm.sh +++ b/scripts/swarm.sh @@ -9,6 +9,8 @@ # - info(), warn(), change(), error() helpers (set by run) # - read_env_val() (set by run) +# shellcheck disable=SC2154 # color_*, SCRIPT_DIR etc. defined by sourcing script (run) + SWARM_COMPOSE="${SCRIPT_DIR}/docker-compose.swarm.yaml" SWARM_PORTAINER_ENV="${SCRIPT_DIR}/.env.portainer"