From e0d27098861960e394eaf6e067319c77a15ccbfa Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Wed, 18 Feb 2026 19:35:04 -0600 Subject: [PATCH 01/11] Add new feature - Allow users to update their email --- src/clj/orcpub/db/schema.clj | 3 ++ src/clj/orcpub/email.clj | 19 ++++--- src/clj/orcpub/routes.clj | 86 ++++++++++++++++++++++++++---- src/cljc/orcpub/route_map.cljc | 6 ++- src/cljs/orcpub/dnd/e5/events.cljs | 32 +++++++++++ src/cljs/orcpub/dnd/e5/subs.cljs | 15 ++++++ src/cljs/orcpub/dnd/e5/views.cljs | 78 +++++++++++++++++++++------ 7 files changed, 204 insertions(+), 35 deletions(-) diff --git a/src/clj/orcpub/db/schema.clj b/src/clj/orcpub/db/schema.clj index 463c369d5..06ed3c833 100644 --- a/src/clj/orcpub/db/schema.clj +++ b/src/clj/orcpub/db/schema.clj @@ -145,6 +145,9 @@ {:db/ident :orcpub.user/password-reset-key :db/valueType :db.type/string :db/cardinality :db.cardinality/one} + {:db/ident :orcpub.user/pending-email + :db/valueType :db.type/string + :db/cardinality :db.cardinality/one} {:db/ident :orcpub.user/following :db/valueType :db.type/ref :db/cardinality :db.cardinality/many}]) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 8af50a0ea..c8106962d 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -28,13 +28,18 @@ :content (hiccup/html (verification-email-html first-and-last-name username verification-url))}]) (defn email-cfg [] - {:user (environ/env :email-access-key) - :pass (environ/env :email-secret-key) - :host (environ/env :email-server-url) - :port (Integer/parseInt (or (environ/env :email-server-port) "587")) - :ssl (or (str/to-bool (environ/env :email-ssl)) nil) - :tls (or (str/to-bool (environ/env :email-tls)) nil) - }) + (let [cfg {:user (environ/env :email-access-key) + :pass (environ/env :email-secret-key) + :host (environ/env :email-server-url) + :port (Integer/parseInt (or (environ/env :email-server-port) "587")) + :ssl (or (str/to-bool (environ/env :email-ssl)) nil) + :tls (or (str/to-bool (environ/env :email-tls)) nil)}] + (println "[email-cfg] host=" (:host cfg) + "user=" (:user cfg) + "port=" (:port cfg) + "ssl=" (:ssl cfg) + "tls=" (:tls cfg)) + cfg)) (defn emailfrom [] (if (not (s/blank? (environ/env :email-from-address))) (environ/env :email-from-address) (str "no-reply@orcpub.com"))) diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 6bf5ae706..25822f617 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -200,9 +200,11 @@ (d/pull-many db '[:orcpub.user/username] ids))) (defn user-body [db user] - {:username (:orcpub.user/username user) - :email (:orcpub.user/email user) - :following (following-usernames db (map :db/id (:orcpub.user/following user)))}) + (cond-> {:username (:orcpub.user/username user) + :email (:orcpub.user/email user) + :following (following-usernames db (map :db/id (:orcpub.user/following user)))} + (:orcpub.user/pending-email user) + (assoc :pending-email (:orcpub.user/pending-email user)))) (defn bad-credentials-response [db username ip] (security/add-failed-login-attempt! username ip) @@ -330,16 +332,28 @@ (let [{:keys [:orcpub.user/verification-sent :orcpub.user/verified? :orcpub.user/username + :orcpub.user/pending-email :db/id] :as user} (user-for-verification-key (d/db conn) key)] (if username - (if verified? + (cond + (and verified? (nil? pending-email)) (redirect route-map/verify-success-route) - (if (or (nil? verification-sent) - (verification-expired? verification-sent)) - (redirect route-map/verify-failed-route) - (do (d/transact conn [{:db/id id - :orcpub.user/verified? true}]) - (redirect route-map/verify-success-route)))) + + (or (nil? verification-sent) + (verification-expired? verification-sent)) + (redirect route-map/verify-failed-route) + + pending-email + (do @(d/transact conn [{:db/id id + :orcpub.user/email pending-email + :orcpub.user/verified? true} + [:db/retract id :orcpub.user/pending-email pending-email]]) + (redirect route-map/verify-success-route)) + + :else + (do @(d/transact conn [{:db/id id + :orcpub.user/verified? true}]) + (redirect route-map/verify-success-route))) {:status 400})) {:status 400})) @@ -896,6 +910,56 @@ @(d/transact conn [[:db/retractEntity user]]) {:status 200})) +(defn email-change-rate-limited? [verification-sent pending-email] + ;; Only rate-limit if the last key was generated for a pending email change + ;; (not for initial registration verification). + ;; Allow at most one request per 5 minutes. + (and pending-email + verification-sent + (t/before? (-> 5 t/minutes t/ago) (tc/from-date verification-sent)))) + +(defn request-email-change [{:keys [transit-params db conn identity] :as request}] + (try + (let [new-email (s/lower-case (s/trim (str transit-params))) + username (:user identity) + {:keys [:db/id + :orcpub.user/email + :orcpub.user/pending-email + :orcpub.user/verification-sent] :as user} (find-user-by-username db username)] + (cond + (nil? id) + {:status 400 :body {:error :user-not-found}} + + (registration/bad-email? new-email) + {:status 400 :body {:error :invalid-email}} + + (= new-email (some-> email s/lower-case)) + {:status 400 :body {:error :same-as-current}} + + (email-change-rate-limited? verification-sent pending-email) + {:status 429 :body {:error :too-many-requests}} + + ;; Check no other account already owns this email + (seq (d/q email-query db new-email)) + {:status 400 :body {:error :email-taken}} + + :else + (let [verification-key (str (java.util.UUID/randomUUID)) + now (java.util.Date.)] + @(d/transact conn [{:db/id id + :orcpub.user/pending-email new-email + :orcpub.user/verification-key verification-key + :orcpub.user/verification-sent now}]) + (try + (send-verification-email request + {:email new-email + :first-and-last-name "OrcPub Patron"} + verification-key) + (catch Throwable e + (prn "Warning: email sending failed (check SMTP config):" (.getMessage e)))) + {:status 200}))) + (catch Throwable e (prn e) (throw e)))) + (defn character-summary-description [{:keys [::char5e/race-name ::char5e/subrace-name ::char5e/classes]}] (str race-name " " @@ -1043,6 +1107,8 @@ [(route-map/path-for route-map/user-route) ^:interceptors [check-auth] {:get `get-user :delete `delete-user}] + [(route-map/path-for route-map/user-email-route) ^:interceptors [check-auth] + {:put `request-email-change}] [(route-map/path-for route-map/follow-user-route :user ":user") ^:interceptors [check-auth] {:post `follow-user :delete `unfollow-user}] diff --git a/src/cljc/orcpub/route_map.cljc b/src/cljc/orcpub/route_map.cljc index 71062b06a..4e7eade74 100644 --- a/src/cljc/orcpub/route_map.cljc +++ b/src/cljc/orcpub/route_map.cljc @@ -103,6 +103,7 @@ (def check-email-route :check-email) (def check-username-route :check-username) (def user-route :user) +(def user-email-route :user-email) (def reset-password-page-route :reset-password-page) (def reset-password-route :reset-password) (def send-password-reset-route :send-password-reset) @@ -124,8 +125,9 @@ "re-verify" re-verify-route "register" register-route "login" login-route - "user" user-route - + "user" {"" user-route + "/email" user-email-route} + "character.pdf" character-pdf-route "check-email" check-email-route "check-username" check-username-route diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index 62b4097d6..a401baa7b 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -974,6 +974,38 @@ :headers (authorization-headers db) :url (backend-url path)}}))) +(reg-event-fx + :change-email + (fn [{:keys [db]} [_ new-email]] + {:db (dissoc db :email-change-sent? :email-change-error) + :http {:method :put + :headers (authorization-headers db) + :url (backend-url (routes/path-for routes/user-email-route)) + :transit-params new-email + :on-success [:change-email-success] + :on-failure [:change-email-failure]}})) + +(reg-event-db + :change-email-success + (fn [db _] + (assoc db :email-change-sent? true))) + +(reg-event-db + :change-email-failure + (fn [db [_ response]] + (assoc db :email-change-error + (case (-> response :body :error) + :email-taken "That email address is already in use by another account." + :invalid-email "Please enter a valid email address." + :same-as-current "That is already your current email address." + :too-many-requests "Please wait a few minutes before requesting another email change." + "There was an error updating your email. Please try again.")))) + +(reg-event-db + :change-email-clear + (fn [db _] + (dissoc db :email-change-sent? :email-change-error))) + (reg-event-fx :unfollow-user (fn [{:keys [db]} [_ username]] diff --git a/src/cljs/orcpub/dnd/e5/subs.cljs b/src/cljs/orcpub/dnd/e5/subs.cljs index b66bfd809..f0249d4ca 100644 --- a/src/cljs/orcpub/dnd/e5/subs.cljs +++ b/src/cljs/orcpub/dnd/e5/subs.cljs @@ -251,6 +251,21 @@ (fn [db _] (-> db :user-data :user-data :email))) +(reg-sub + :pending-email + (fn [db _] + (-> db :user-data :user-data :pending-email))) + +(reg-sub + :email-change-sent? + (fn [db _] + (:email-change-sent? db))) + +(reg-sub + :email-change-error + (fn [db _] + (:email-change-error db))) + (defn built-template [template selected-plugin-options] template #_(let [selected-plugins (map diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 1a5bb237d..667ba2bea 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -7501,22 +7501,68 @@ [my-content]]]) (defn my-account-page [] - [content-page - "My Account" - [{:title (str "Delete Account") - :icon "trash" - :on-click #(dispatch - [:show-confirmation - {:confirm-button-text "DELETE ACCOUNT" - :question "Are you sure you want to delete your account, characters, and associated data?" - :event [:delete-account]}])}] - [:div.f-s-24.p-10.white - [:div.p-5 - [:span.f-w-b "Username: "] - [:span @(subscribe [:username])]] - [:div.p-5 - [:span.f-w-b "Email: "] - [:span @(subscribe [:email])]]]]) + (r/with-let [editing? (r/atom false) + new-email (r/atom "")] + (let [current-email @(subscribe [:email]) + pending-email @(subscribe [:pending-email]) + sent? @(subscribe [:email-change-sent?]) + error @(subscribe [:email-change-error])] + [content-page + "My Account" + [{:title (str "Delete Account") + :icon "trash" + :on-click #(dispatch + [:show-confirmation + {:confirm-button-text "DELETE ACCOUNT" + :question "Are you sure you want to delete your account, characters, and associated data?" + :event [:delete-account]}])}] + [:div.f-s-24.p-10.white + [:div.p-5 + [:span.f-w-b "Username: "] + [:span @(subscribe [:username])]] + [:div.p-5 + [:span.f-w-b "Email: "] + (cond + sent? + [:div + [:span current-email] + [:div.m-t-5.f-s-14 "A verification email has been sent to your new address. Click the link in that email to confirm the change."] + [:button.link-button.m-t-5.f-s-14 + {:on-click #(do (reset! editing? true) + (reset! new-email "") + (dispatch [:change-email-clear]))} + "Change again"]] + + @editing? + [:div.m-t-5 + [:input.input + {:type :email + :value @new-email + :placeholder "New email address" + :on-change #(reset! new-email (event-value %))}] + [:div.m-t-5 + [:button.form-button + {:on-click #(dispatch [:change-email @new-email])} + "Save"] + [:button.link-button.m-l-10 + {:on-click #(do (reset! editing? false) + (reset! new-email "") + (dispatch [:change-email-clear]))} + "Cancel"]] + (when error + [:div.m-t-5.red error])] + + :else + [:div + [:span current-email] + (when pending-email + [:div.m-t-5.f-s-14 "Pending: " pending-email " — check your email to verify the change."]) + [:button.link-button.m-l-10 + {:on-click #(do (reset! editing? true) + (reset! new-email "") + (dispatch [:change-email-clear]))} + "Change"]])]]]))) + (defn newb-character-builder-page [] [content-page From 38f89c471daa375f9abd1975bbcd3d56f78de267 Mon Sep 17 00:00:00 2001 From: codeGlaze Date: Thu, 19 Feb 2026 03:43:15 +0000 Subject: [PATCH 02/11] Harden email change flow: fixes, rate limiting, tests, docs Review and hardening of PR #644 (allow users to update email): Server (routes.clj, email.clj): - Fix silent email send failure: full rollback of pending-email, verification-key, and verification-sent on send error - Case-insensitive email-query to guard against mixed-case legacy data - Race-condition guard at verify time: re-check email availability - Invalidate verification key after use (prevent link reuse) - Clean up all pending state on expired verification links - Separate email template for email-change vs registration - 3-zone rate limiting (0-1min blocked, 1-5min free resend, 5min+ open) - Return retry-after-secs in 429 responses for client countdown - Nil-username guard in request-email-change handler - Transit-params destructured as map (matches codebase convention) - Remove redundant verified? reassertion on email swap Client (events.cljs, views.cljs): - Add confirm-email field with client-side validation - Show contextual rate-limit messages with countdown - Display pending email address in sent confirmation - Resend button for pending verification (server rate-limited) - Use server-canonical email for display (lowercased/trimmed) Tests (email_change_test.clj): - 11 tests, 315 assertions covering: happy path, duplicate rejection, same-as-current, invalid format, nil/empty email, no auth, send failure rollback, expired verification, race condition, rate limiting (all 3 zones), and pending email replacement Docs (docs/email-system.md): - Full documentation of the email system: 4 flows, schema, rate limiting, expiration windows, file map, known issues --- docs/email-system.md | 155 +++++++++++++ src/clj/orcpub/email.clj | 42 +++- src/clj/orcpub/routes.clj | 170 ++++++++++---- src/cljs/orcpub/dnd/e5/events.cljs | 36 ++- src/cljs/orcpub/dnd/e5/views.cljs | 42 +++- test/clj/orcpub/email_change_test.clj | 313 ++++++++++++++++++++++++++ 6 files changed, 690 insertions(+), 68 deletions(-) create mode 100644 docs/email-system.md create mode 100644 test/clj/orcpub/email_change_test.clj diff --git a/docs/email-system.md b/docs/email-system.md new file mode 100644 index 000000000..579dc331f --- /dev/null +++ b/docs/email-system.md @@ -0,0 +1,155 @@ +# Email System + +Overview of email-related flows, schema, configuration, and behavior. + +## Configuration + +All email is sent via [postal](https://github.com/drewr/postal) using SMTP credentials from environment variables: + +| Env var | Purpose | +|---------|---------| +| `EMAIL_ACCESS_KEY` | SMTP username | +| `EMAIL_SECRET_KEY` | SMTP password | +| `EMAIL_SERVER_URL` | SMTP host | +| `EMAIL_SERVER_PORT` | SMTP port (default `587`) | +| `EMAIL_SSL` | Enable SSL (`true`/`false`) | +| `EMAIL_TLS` | Enable TLS (`true`/`false`) | +| `EMAIL_FROM_ADDRESS` | Sender address (default `no-reply@orcpub.com`) | +| `EMAIL_ERRORS_TO` | Address for error notification emails (optional) | + +Configuration is read at send-time by `email/email-cfg` (`src/clj/orcpub/email.clj`). + +## Schema + +User attributes related to email and verification (`src/clj/orcpub/db/schema.clj`): + +| Attribute | Type | Purpose | +|-----------|------|---------| +| `:orcpub.user/email` | string | Confirmed email address | +| `:orcpub.user/pending-email` | string | Requested new email (awaiting verification) | +| `:orcpub.user/verified?` | boolean | Whether the user has verified their email | +| `:orcpub.user/verification-key` | string | UUID used in verification links | +| `:orcpub.user/verification-sent` | instant | When the verification email was sent | +| `:orcpub.user/password-reset-key` | string | UUID used in password reset links | +| `:orcpub.user/password-reset-sent` | instant | When the password reset email was sent | +| `:orcpub.user/password-reset` | instant | When the password was actually reset | + +**Note:** `:orcpub.user/email` has no uniqueness constraint in the schema. Uniqueness is enforced at the application level via `email-query`. See the known issues section. + +## Flows + +### 1. Registration Verification + +**Trigger:** `POST /register` (via `routes/register`) + +1. Validate username, email, password +2. Check email/username not already taken (`email-query`, `username-query`) +3. Create user entity with `verified? false`, generate `verification-key`, set `verification-sent` +4. Send registration verification email (`email/send-verification-email`) +5. User clicks link → `GET /verify?key=...` → `routes/verify` + +**Verify behavior (registration path):** +- If already verified and no `pending-email` → redirect to success +- If `verification-sent` is nil or expired (24h) → redirect to failed +- Otherwise → set `verified? true`, redirect to success + +**Re-verify:** `GET /re-verify?email=...` (`routes/re-verify`) re-sends the verification email for unverified accounts. + +**Login gate:** Unverified users cannot log in. If the verification has expired, the login error tells them to re-register. + +**Files:** `routes.clj:register`, `routes.clj:do-verification`, `routes.clj:verify`, `email.clj:send-verification-email` + +### 2. Email Change + +**Trigger:** `PUT /user/email` (via `routes/request-email-change`, requires auth) + +1. Validate new email (format, not same as current, not already taken) +2. Check rate limit (see Rate Limiting below) +3. Store `pending-email`, generate new `verification-key`, set `verification-sent` +4. Send email-change verification to the **new** address (`email/send-email-change-verification`) +5. If send fails → full rollback (retract `pending-email`, `verification-key`, `verification-sent`), return 500 + +**Verify behavior (email-change path):** +- If expired (24h) → retract `pending-email`, `verification-key`, `verification-sent`; redirect to failed +- If `pending-email` exists → re-check email availability (race-condition guard): + - If email was claimed by another user since request → retract all pending state, redirect to failed + - Otherwise → swap `email` to `pending-email`, retract `pending-email`, `verification-key`, `verification-sent`; redirect to success +- Key is invalidated after use (retracted) — link cannot be reused + +**Free resend:** Within 1–5 minutes of the original request, resending the same email re-uses the existing `verification-key` and does not update `verification-sent` (no rolling window). See Rate Limiting. + +**Files:** `routes.clj:request-email-change`, `routes.clj:verify` (pending-email branch), `email.clj:send-email-change-verification`, `events.cljs:change-email`, `views.cljs:my-account-page` + +### 3. Password Reset + +**Trigger:** `GET /send-password-reset?email=...` (via `routes/send-password-reset`) + +1. Look up user by email +2. Generate `password-reset-key`, set `password-reset-sent` +3. Send password reset email (`email/send-reset-email`) + +**Reset behavior:** `POST /reset-password` (via `routes/reset-password`) +- Validates new password and password match +- Sets new password hash, sets `password-reset` timestamp, sets `verified? true` + +**Expiration:** `password-reset-expired?` checks if `password-reset-sent` is older than 24 hours. + +**Files:** `routes.clj:send-password-reset`, `routes.clj:do-send-password-reset`, `routes.clj:reset-password`, `email.clj:send-reset-email` + +### 4. Error Notification + +**Trigger:** Called from exception handlers (e.g., Pedestal error interceptor) + +- Sends a plaintext email with the request context and exception data +- Only sends if `EMAIL_ERRORS_TO` is set +- Uses `email/send-error-email` + +**Files:** `email.clj:send-error-email` + +## Rate Limiting (Email Change) + +Rate limiting is enforced by `routes/email-change-rate-limited?` based on `verification-sent` and whether the request is a resend (same email as `pending-email`). + +Three zones measured from `verification-sent`: + +``` +0 ──────── 1 min ──────── 5 min ──────── ∞ +│ BLOCKED │ FREE RESEND │ OPEN │ +│ (transit) │ (same email) │ (any email) │ +│ │ blocked for │ │ +│ │ diff email │ │ +``` + +- **0–1 min:** All requests blocked. Email is in transit. Client shows "Your email is on its way. You can resend in N seconds." +- **1–5 min:** Resend of same email allowed (free resend, no DB write, reuses existing key). Different email blocked. Client shows "Please wait N minutes before requesting another change." +- **5+ min:** Any request allowed. New `verification-key` generated, `verification-sent` updated. + +The 429 response includes `retry-after-secs` so the client can display a specific countdown. + +## Expiration Windows + +| Window | Duration | Function | +|--------|----------|----------| +| Verification link | 24 hours | `verification-expired?` | +| Password reset link | 24 hours | `password-reset-expired?` | +| Email change rate limit | 5 minutes | `email-change-rate-limited?` | +| Free resend grace | 1–5 minutes | `email-change-rate-limited?` + free resend branch | + +## File Map + +| File | Role | +|------|------| +| `src/clj/orcpub/email.clj` | Email templates and send functions (postal) | +| `src/clj/orcpub/routes.clj` | Server handlers: register, verify, email change, password reset | +| `src/clj/orcpub/db/schema.clj` | Datomic schema for user attributes | +| `src/cljc/orcpub/route_map.cljc` | Route definitions (shared server/client) | +| `src/cljs/orcpub/dnd/e5/events.cljs` | Re-frame events for email change UI | +| `src/cljs/orcpub/dnd/e5/views.cljs` | My Account page with email change form | +| `src/cljs/orcpub/dnd/e5/subs.cljs` | Subscriptions for pending-email, email-change state | +| `test/clj/orcpub/email_change_test.clj` | Email change tests (11 tests, datomock) | + +## Known Issues + +- **No uniqueness constraint on email in schema.** Uniqueness is enforced at the application level by `email-query` (at request time) and a race-condition guard (at verify time). A Datomic `:db.unique/value` constraint on `:orcpub.user/email` would be the proper fix but requires a data migration to handle any existing duplicates. + +- **Pending-email conflicts not checked.** Two users can simultaneously request the same new email. Both receive verification emails, but only the first to verify succeeds — the second is caught by the race-condition guard. The "loser" gets a confusing failure after clicking a valid-looking link. diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index c8106962d..071383231 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -27,6 +27,32 @@ [{:type "text/html" :content (hiccup/html (verification-email-html first-and-last-name username verification-url))}]) +(defn email-change-verification-html + "Email body for existing users changing their email (distinct from registration)." + [username verification-url] + [:div + "Dear OrcPub Patron," + [:br] + [:br] + "You requested to change the email address on your OrcPub account (" username "). " + "Please visit the following URL to confirm this change:" + [:br] + [:br] + [:a {:href verification-url} verification-url] + [:br] + [:br] + "If you did not request this change, you can safely ignore this email." + [:br] + [:br] + "Sincerely," + [:br] + [:br] + "The OrcPub Team"]) + +(defn email-change-verification-email [username verification-url] + [{:type "text/html" + :content (hiccup/html (email-change-verification-html username verification-url))}]) + (defn email-cfg [] (let [cfg {:user (environ/env :email-access-key) :pass (environ/env :email-secret-key) @@ -34,11 +60,6 @@ :port (Integer/parseInt (or (environ/env :email-server-port) "587")) :ssl (or (str/to-bool (environ/env :email-ssl)) nil) :tls (or (str/to-bool (environ/env :email-tls)) nil)}] - (println "[email-cfg] host=" (:host cfg) - "user=" (:user cfg) - "port=" (:port cfg) - "ssl=" (:ssl cfg) - "tls=" (:tls cfg)) cfg)) (defn emailfrom [] @@ -54,6 +75,17 @@ username (str base-url (routes/path-for routes/verify-route) "?key=" verification-key))})) +(defn send-email-change-verification + "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 "OrcPub Team <" (emailfrom) ">") + :to email + :subject "OrcPub 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 (str "Dear OrcPub Patron") diff --git a/src/clj/orcpub/routes.clj b/src/clj/orcpub/routes.clj index 25822f617..760dc648d 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -68,10 +68,13 @@ :in $ ?username :where [?e :orcpub.user/username ?username]]) +;; Case-insensitive email lookup to guard against mixed-case legacy data. +;; Callers must pass a lowercased email. (def email-query '[:find ?e :in $ ?email - :where [?e :orcpub.user/email ?email]]) + :where [?e :orcpub.user/email ?stored] + [(clojure.string/lower-case ?stored) ?email]]) (defn find-user-by-username-or-email [db username-or-email] (d/q @@ -269,6 +272,12 @@ params verification-key)) +(defn send-email-change-verification [request params verification-key] + (email/send-email-change-verification + (base-url request) + params + verification-key)) + (defn do-verification [request params conn & [tx-data]] (let [verification-key (str (java.util.UUID/randomUUID)) now (java.util.Date.)] @@ -341,14 +350,29 @@ (or (nil? verification-sent) (verification-expired? verification-sent)) - (redirect route-map/verify-failed-route) + ;; Clean up stale pending state so user can request a fresh change + (do (let [retractions (cond-> [[:db/retract id :orcpub.user/verification-key key] + [:db/retract id :orcpub.user/verification-sent verification-sent]] + pending-email + (conj [:db/retract id :orcpub.user/pending-email pending-email]))] + @(d/transact conn retractions)) + (redirect route-map/verify-failed-route)) pending-email - (do @(d/transact conn [{:db/id id - :orcpub.user/email pending-email - :orcpub.user/verified? true} - [:db/retract id :orcpub.user/pending-email pending-email]]) - (redirect route-map/verify-success-route)) + ;; Guard: re-check that the target email hasn't been claimed since request. + ;; All paths retract verification-key and verification-sent to prevent + ;; link reuse and avoid stale rate-limit data. + (if (seq (d/q email-query (d/db conn) pending-email)) + (do @(d/transact conn [[:db/retract id :orcpub.user/pending-email pending-email] + [:db/retract id :orcpub.user/verification-key key] + [:db/retract id :orcpub.user/verification-sent verification-sent]]) + (redirect route-map/verify-failed-route)) + (do @(d/transact conn [{:db/id id + :orcpub.user/email pending-email} + [:db/retract id :orcpub.user/pending-email pending-email] + [:db/retract id :orcpub.user/verification-key key] + [:db/retract id :orcpub.user/verification-sent verification-sent]]) + (redirect route-map/verify-success-route))) :else (do @(d/transact conn [{:db/id id @@ -609,7 +633,7 @@ (check-field username-query (:username query-params) db)) (defn check-email [{:keys [db query-params]}] - (check-field email-query (:email query-params) db)) + (check-field email-query (some-> (:email query-params) s/lower-case) db)) (defn character-for-id [db id] (d/pull db '[*] id)) @@ -910,54 +934,104 @@ @(d/transact conn [[:db/retractEntity user]]) {:status 200})) -(defn email-change-rate-limited? [verification-sent pending-email] +(defn rate-limit-remaining-secs + "Seconds until the user can act again. In the 0–1 min zone (email in transit) + returns time until the 1-min resend window opens. In the 1–5 min zone (for a + different email) returns time until the 5-min cooldown expires." + [verification-sent new-email pending-email] + (when verification-sent + (let [elapsed-ms (- (System/currentTimeMillis) (.getTime ^java.util.Date verification-sent)) + ;; If same email, they're waiting for the 1-min resend window to open. + ;; If different email, they're waiting for the full 5-min cooldown. + target-ms (if (= new-email pending-email) + (* 1 60 1000) + (* 5 60 1000)) + remaining-ms (- target-ms elapsed-ms)] + (when (pos? remaining-ms) + (int (Math/ceil (/ remaining-ms 1000.0))))))) + +(defn email-change-rate-limited? [verification-sent pending-email new-email] ;; Only rate-limit if the last key was generated for a pending email change ;; (not for initial registration verification). - ;; Allow at most one request per 5 minutes. + ;; Three zones from verification-sent: + ;; 0–1 min → too soon, email is in transit (always blocked) + ;; 1–5 min → free resend allowed for same email, otherwise blocked + ;; 5+ min → open for any request (and pending-email verification-sent - (t/before? (-> 5 t/minutes t/ago) (tc/from-date verification-sent)))) + (let [elapsed-ms (- (System/currentTimeMillis) (.getTime ^java.util.Date verification-sent)) + same-email? (= new-email pending-email)] + (cond + (>= elapsed-ms (* 5 60 1000)) false ;; past cooldown + (< elapsed-ms (* 1 60 1000)) true ;; too soon + :else (not same-email?)))) ;; 1-5 min: resend ok, new email blocked + ) (defn request-email-change [{:keys [transit-params db conn identity] :as request}] (try - (let [new-email (s/lower-case (s/trim (str transit-params))) - username (:user identity) - {:keys [:db/id - :orcpub.user/email - :orcpub.user/pending-email - :orcpub.user/verification-sent] :as user} (find-user-by-username db username)] - (cond - (nil? id) + ;; Client sends {:new-email "..."} (confirm-email is validated client-side only) + (let [new-email (s/lower-case (s/trim (str (:new-email transit-params)))) + username (:user identity)] + (if (nil? username) {:status 400 :body {:error :user-not-found}} - - (registration/bad-email? new-email) - {:status 400 :body {:error :invalid-email}} - - (= new-email (some-> email s/lower-case)) - {:status 400 :body {:error :same-as-current}} - - (email-change-rate-limited? verification-sent pending-email) - {:status 429 :body {:error :too-many-requests}} - - ;; Check no other account already owns this email - (seq (d/q email-query db new-email)) - {:status 400 :body {:error :email-taken}} - - :else - (let [verification-key (str (java.util.UUID/randomUUID)) - now (java.util.Date.)] - @(d/transact conn [{:db/id id - :orcpub.user/pending-email new-email - :orcpub.user/verification-key verification-key - :orcpub.user/verification-sent now}]) - (try - (send-verification-email request - {:email new-email - :first-and-last-name "OrcPub Patron"} - verification-key) - (catch Throwable e - (prn "Warning: email sending failed (check SMTP config):" (.getMessage e)))) - {:status 200}))) + (let [{:keys [:db/id + :orcpub.user/email + :orcpub.user/pending-email + :orcpub.user/verification-sent] :as user} (find-user-by-username db username)] + (cond + (nil? id) + {:status 400 :body {:error :user-not-found}} + + (registration/bad-email? new-email) + {:status 400 :body {:error :invalid-email}} + + (= new-email (some-> email s/lower-case)) + {:status 400 :body {:error :same-as-current}} + + (email-change-rate-limited? verification-sent pending-email new-email) + {:status 429 :body {:error :too-many-requests + :retry-after-secs (rate-limit-remaining-secs verification-sent new-email pending-email)}} + + ;; Check no other account already owns this email + (seq (d/q email-query db new-email)) + {:status 400 :body {:error :email-taken}} + + ;; Free resend: same email, 1–5 min after original send. Re-send with + ;; existing key and don't update verification-sent (no rolling window). + (and (= new-email pending-email) + verification-sent + (let [elapsed (- (System/currentTimeMillis) (.getTime ^java.util.Date verification-sent))] + (and (>= elapsed (* 1 60 1000)) + (< elapsed (* 5 60 1000))))) + (try + (send-email-change-verification request + {:email new-email :username username} + (:orcpub.user/verification-key user)) + {:status 200 :body {:pending-email new-email}} + (catch Throwable e + (prn "Email resend failed:" (.getMessage e)) + {:status 500 :body {:error :email-send-failed}})) + + :else + (let [verification-key (str (java.util.UUID/randomUUID)) + now (java.util.Date.)] + @(d/transact conn [{:db/id id + :orcpub.user/pending-email new-email + :orcpub.user/verification-key verification-key + :orcpub.user/verification-sent now}]) + ;; Roll back pending-email if verification email fails to send + (try + (send-email-change-verification request + {:email new-email :username username} + verification-key) + {:status 200 :body {:pending-email new-email}} + (catch Throwable e + (prn "Email send failed, rolling back pending state:" (.getMessage e)) + ;; Full rollback: retract all attributes set by the failed attempt + @(d/transact conn [[:db/retract id :orcpub.user/pending-email new-email] + [:db/retract id :orcpub.user/verification-key verification-key] + [:db/retract id :orcpub.user/verification-sent now]]) + {:status 500 :body {:error :email-send-failed}}))))))) (catch Throwable e (prn e) (throw e)))) (defn character-summary-description [{:keys [::char5e/race-name ::char5e/subrace-name ::char5e/classes]}] diff --git a/src/cljs/orcpub/dnd/e5/events.cljs b/src/cljs/orcpub/dnd/e5/events.cljs index a401baa7b..7f6133dea 100644 --- a/src/cljs/orcpub/dnd/e5/events.cljs +++ b/src/cljs/orcpub/dnd/e5/events.cljs @@ -981,25 +981,41 @@ :http {:method :put :headers (authorization-headers db) :url (backend-url (routes/path-for routes/user-email-route)) - :transit-params new-email + :transit-params {:new-email new-email} :on-success [:change-email-success] :on-failure [:change-email-failure]}})) (reg-event-db :change-email-success - (fn [db _] - (assoc db :email-change-sent? true))) + (fn [db [_ response]] + (-> db + (assoc :email-change-sent? true) + ;; Use server-canonical (lowercased/trimmed) email for display + (assoc-in [:user-data :user-data :pending-email] + (-> response :body :pending-email))))) (reg-event-db :change-email-failure (fn [db [_ response]] - (assoc db :email-change-error - (case (-> response :body :error) - :email-taken "That email address is already in use by another account." - :invalid-email "Please enter a valid email address." - :same-as-current "That is already your current email address." - :too-many-requests "Please wait a few minutes before requesting another email change." - "There was an error updating your email. Please try again.")))) + (let [body (:body response) + error (:error body)] + (assoc db :email-change-error + (case error + :email-taken "That email address is already in use by another account." + :invalid-email "Please enter a valid email address." + :same-as-current "That is already your current email address." + :too-many-requests + (let [secs (:retry-after-secs body)] + (if (and secs (pos? secs)) + (if (<= secs 60) + ;; 0–1 min zone: email is in transit, show short countdown + (str "Your email is on its way. You can resend in " secs " second" (when (> secs 1) "s") ".") + ;; 1–5 min zone for a different email: show minutes + (let [mins (.ceil js/Math (/ secs 60))] + (str "Please wait " mins " minute" (when (> mins 1) "s") " before requesting another change."))) + "Please wait a few minutes before requesting another email change.")) + :email-send-failed "Verification email could not be sent. Please try again later." + "There was an error updating your email. Please try again."))))) (reg-event-db :change-email-clear diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 667ba2bea..212d78b0c 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -7502,11 +7502,20 @@ (defn my-account-page [] (r/with-let [editing? (r/atom false) - new-email (r/atom "")] + new-email (r/atom "") + confirm-email (r/atom "")] (let [current-email @(subscribe [:email]) pending-email @(subscribe [:pending-email]) sent? @(subscribe [:email-change-sent?]) - error @(subscribe [:email-change-error])] + error @(subscribe [:email-change-error]) + ;; Client-side validation: format check + confirm match + bad-format? (and (seq @new-email) + (registration/bad-email? @new-email)) + emails-dont-match? (and (seq @confirm-email) + (not= @new-email @confirm-email)) + can-submit? (and (seq @new-email) + (not bad-format?) + (= @new-email @confirm-email))] [content-page "My Account" [{:title (str "Delete Account") @@ -7526,10 +7535,11 @@ sent? [:div [:span current-email] - [:div.m-t-5.f-s-14 "A verification email has been sent to your new address. Click the link in that email to confirm the change."] + [:div.m-t-5.f-s-14 "A verification email has been sent to " [:strong pending-email] ". Click the link in that email to confirm the change."] [:button.link-button.m-t-5.f-s-14 {:on-click #(do (reset! editing? true) (reset! new-email "") + (reset! confirm-email "") (dispatch [:change-email-clear]))} "Change again"]] @@ -7540,13 +7550,26 @@ :value @new-email :placeholder "New email address" :on-change #(reset! new-email (event-value %))}] + (when bad-format? + [:div.m-t-5.red "Not a valid email format"]) + ;; Confirm field to prevent typo-induced lockout + [:input.input.m-t-5 + {:type :email + :value @confirm-email + :placeholder "Confirm new email address" + :on-change #(reset! confirm-email (event-value %))}] + (when emails-dont-match? + [:div.m-t-5.red "Email addresses don't match"]) [:div.m-t-5 [:button.form-button - {:on-click #(dispatch [:change-email @new-email])} + {:disabled (not can-submit?) + :on-click #(when can-submit? + (dispatch [:change-email @new-email]))} "Save"] [:button.link-button.m-l-10 {:on-click #(do (reset! editing? false) (reset! new-email "") + (reset! confirm-email "") (dispatch [:change-email-clear]))} "Cancel"]] (when error @@ -7556,10 +7579,19 @@ [:div [:span current-email] (when pending-email - [:div.m-t-5.f-s-14 "Pending: " pending-email " — check your email to verify the change."]) + [:div.m-t-5.f-s-14 + "Pending: " pending-email " — check your email to verify the change. " + ;; Resend uses the same change-email flow; server enforces 3-zone rate limit + ;; (0–1 min blocked, 1–5 min free resend, 5+ min open) + [:button.link-button.f-s-14 + {: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"]])]]]))) diff --git a/test/clj/orcpub/email_change_test.clj b/test/clj/orcpub/email_change_test.clj new file mode 100644 index 000000000..37d7ff3d4 --- /dev/null +++ b/test/clj/orcpub/email_change_test.clj @@ -0,0 +1,313 @@ +(ns orcpub.email-change-test + "Tests for the email change flow (PR #644). + Uses datomock for in-memory Datomic and with-redefs to stub email sending." + (:require + [clojure.test :refer [deftest is testing]] + [datomic.api :as d] + [datomock.core :as dm] + [buddy.hashers :as hashers] + [orcpub.routes :as routes] + [orcpub.route-map :as route-map] + [orcpub.db.schema :as schema]) + (:import [java.util UUID])) + +(defmacro with-conn [conn-binding & body] + `(let [uri# (str "datomic:mem:email-change-test-" (UUID/randomUUID)) + ~conn-binding (do + (d/create-database uri#) + (d/connect uri#))] + (try ~@body + (finally (d/delete-database uri#))))) + +(defn seed-users + "Transact schema and seed two verified users for testing." + [conn] + @(d/transact conn schema/all-schemas) + @(d/transact conn + [{:orcpub.user/username "alice" + :orcpub.user/email "alice@test.com" + :orcpub.user/password (hashers/encrypt "pass123") + :orcpub.user/verified? true} + {:orcpub.user/username "bob" + :orcpub.user/email "bob@test.com" + :orcpub.user/password (hashers/encrypt "pass456") + :orcpub.user/verified? true}])) + +(defn make-request + "Build a minimal request map for request-email-change." + [conn new-email username] + {:transit-params {:new-email new-email} + :db (d/db conn) + :conn conn + :identity {:user username} + ;; send-email-change-verification reads scheme + headers for base-url + :scheme :https + :headers {"host" "localhost"}}) + +(defn find-user [db username] + (d/q '[:find (pull ?e [:orcpub.user/email + :orcpub.user/pending-email + :orcpub.user/verification-key + :orcpub.user/verification-sent + :orcpub.user/verified? + :db/id]) . + :in $ ?username + :where [?e :orcpub.user/username ?username]] + db username)) + +(def success-path (route-map/path-for route-map/verify-success-route)) +(def failed-path (route-map/path-for route-map/verify-failed-route)) + +(defn redirect-location + "Extract the Location header from a redirect response." + [resp] + (get-in resp [:headers "Location"])) + +;; ---------- Tests ---------- + +(deftest test-email-change-happy-path + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Request email change stores pending-email and returns 200" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "newalice@test.com" "alice"))] + (is (= 200 (:status resp))) + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "newalice@test.com" (:orcpub.user/pending-email user))) + (is (some? (:orcpub.user/verification-key user))) + ;; Original email unchanged until verified + (is (= "alice@test.com" (:orcpub.user/email user))) + + (testing "Verify link swaps email and clears pending-email" + (let [key (:orcpub.user/verification-key user) + vresp (routes/verify {:query-params {:key key} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 302 (:status vresp))) + ;; Must redirect to success, not failure + (is (= success-path (redirect-location vresp))) + (let [updated (find-user (d/db mocked-conn) "alice")] + (is (= "newalice@test.com" (:orcpub.user/email updated))) + (is (nil? (:orcpub.user/pending-email updated))) + ;; Verification key invalidated — link can't be reused + (is (nil? (:orcpub.user/verification-key updated))))))))))))) + +(deftest test-email-change-duplicate-rejected + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Changing to an already-taken email returns 400 :email-taken" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "bob@test.com" "alice"))] + (is (= 400 (:status resp))) + (is (= :email-taken (-> resp :body :error))) + ;; DB unchanged + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "alice@test.com" (:orcpub.user/email user))) + (is (nil? (:orcpub.user/pending-email user)))))))))) + +(deftest test-email-change-same-as-current + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Changing to your own current email returns 400 :same-as-current" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "alice@test.com" "alice"))] + (is (= 400 (:status resp))) + (is (= :same-as-current (-> resp :body :error))) + ;; DB unchanged + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "alice@test.com" (:orcpub.user/email user))) + (is (nil? (:orcpub.user/pending-email user)))))))))) + +(deftest test-email-change-invalid-format + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Badly formatted email returns 400 :invalid-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "not-an-email" "alice"))] + (is (= 400 (:status resp))) + (is (= :invalid-email (-> resp :body :error))) + ;; DB unchanged + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "alice@test.com" (:orcpub.user/email user))) + (is (nil? (:orcpub.user/pending-email user)))))))))) + +(deftest test-email-change-nil-email + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Nil new-email returns 400 :invalid-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn nil "alice"))] + (is (= 400 (:status resp))) + (is (= :invalid-email (-> resp :body :error)))))) + (testing "Empty string new-email returns 400 :invalid-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "" "alice"))] + (is (= 400 (:status resp))) + (is (= :invalid-email (-> resp :body :error))))))))) + +(deftest test-email-change-no-auth + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Request without identity returns 400 :user-not-found" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + {:transit-params {:new-email "x@test.com"} + :db (d/db mocked-conn) + :conn mocked-conn + :identity nil + :scheme :https + :headers {"host" "localhost"}})] + (is (= 400 (:status resp))) + (is (= :user-not-found (-> resp :body :error))))))))) + +(deftest test-email-send-failure-rolls-back + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "When email send fails, pending-email is retracted and returns 500" + (with-redefs [routes/send-email-change-verification + (fn [& _] (throw (Exception. "SMTP down")))] + (let [resp (routes/request-email-change + (make-request mocked-conn "newalice@test.com" "alice"))] + (is (= 500 (:status resp))) + (is (= :email-send-failed (-> resp :body :error))) + ;; Full rollback: pending-email, verification-key, and verification-sent all cleared + (let [user (find-user (d/db mocked-conn) "alice")] + (is (nil? (:orcpub.user/pending-email user))) + (is (nil? (:orcpub.user/verification-key user))) + (is (nil? (:orcpub.user/verification-sent user))) + (is (= "alice@test.com" (:orcpub.user/email user)))))))))) + +(deftest test-expired-verification-cleans-pending + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Expired verification link clears stale pending-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + ;; Request a change so pending-email is set + (routes/request-email-change + (make-request mocked-conn "newalice@test.com" "alice")) + (let [user (find-user (d/db mocked-conn) "alice") + key (:orcpub.user/verification-key user)] + ;; Force-expire by backdating verification-sent past the 24h window + @(d/transact mocked-conn + [{:db/id (:db/id user) + :orcpub.user/verification-sent + (java.util.Date. (- (System/currentTimeMillis) (* 25 60 60 1000)))}]) + (let [vresp (routes/verify {:query-params {:key key} + :db (d/db mocked-conn) + :conn mocked-conn})] + ;; Must redirect to failed, not success + (is (= 302 (:status vresp))) + (is (= failed-path (redirect-location vresp))) + ;; All pending state cleaned up, original email unchanged + (let [updated (find-user (d/db mocked-conn) "alice")] + (is (nil? (:orcpub.user/pending-email updated))) + (is (nil? (:orcpub.user/verification-key updated))) + (is (= "alice@test.com" (:orcpub.user/email updated))))))))))) + +(deftest test-race-condition-email-claimed-between-request-and-verify + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "If target email is claimed by another user before verify, swap is rejected" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + ;; Alice requests change to unclaimed@test.com + (routes/request-email-change + (make-request mocked-conn "unclaimed@test.com" "alice")) + (let [user (find-user (d/db mocked-conn) "alice") + key (:orcpub.user/verification-key user)] + ;; Meanwhile, someone else claims that email + @(d/transact mocked-conn + [{:orcpub.user/username "charlie" + :orcpub.user/email "unclaimed@test.com" + :orcpub.user/password (hashers/encrypt "pass789") + :orcpub.user/verified? true}]) + ;; Alice clicks verify — should be rejected + (let [vresp (routes/verify {:query-params {:key key} + :db (d/db mocked-conn) + :conn mocked-conn})] + (is (= 302 (:status vresp))) + (is (= failed-path (redirect-location vresp))) + ;; Alice's email unchanged, pending cleaned up + (let [updated (find-user (d/db mocked-conn) "alice")] + (is (= "alice@test.com" (:orcpub.user/email updated))) + (is (nil? (:orcpub.user/pending-email updated))) + (is (nil? (:orcpub.user/verification-key updated))))))))))) + +(deftest test-rate-limiting + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "Immediate resend of same email is blocked (email still in transit)" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp1 (routes/request-email-change + (make-request mocked-conn "new1@test.com" "alice"))] + (is (= 200 (:status resp1))) + ;; Immediate resend — within 1-min transit zone, should be blocked + (let [resp2 (routes/request-email-change + (make-request mocked-conn "new1@test.com" "alice"))] + (is (= 429 (:status resp2))) + ;; retry-after-secs counts down to 1-min mark (resend window) + (is (pos? (-> resp2 :body :retry-after-secs))) + (is (<= (-> resp2 :body :retry-after-secs) 60)))))) + + (testing "Different email within 5 minutes is rate-limited" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + (let [resp (routes/request-email-change + (make-request mocked-conn "new2@test.com" "alice"))] + (is (= 429 (:status resp))) + ;; retry-after-secs counts down to 5-min mark + (is (pos? (-> resp :body :retry-after-secs))) + ;; pending-email unchanged + (let [user (find-user (d/db mocked-conn) "alice")] + (is (= "new1@test.com" (:orcpub.user/pending-email user))))))) + + (testing "Resend of same email after 1 min is allowed (free resend)" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + ;; Backdate verification-sent to 2 minutes ago (past 1-min transit, within 5-min cooldown) + (let [user (find-user (d/db mocked-conn) "alice")] + @(d/transact mocked-conn + [{:db/id (:db/id user) + :orcpub.user/verification-sent + (java.util.Date. (- (System/currentTimeMillis) (* 2 60 1000)))}])) + (let [resp (routes/request-email-change + (make-request mocked-conn "new1@test.com" "alice"))] + (is (= 200 (:status resp))))))))) + +(deftest test-second-change-replaces-pending + (with-conn conn + (let [mocked-conn (dm/fork-conn conn)] + (seed-users mocked-conn) + (testing "After rate-limit window, a new request overwrites old pending-email" + (with-redefs [routes/send-email-change-verification (fn [& _] nil)] + ;; First request + (routes/request-email-change + (make-request mocked-conn "old-pending@test.com" "alice")) + (let [user (find-user (d/db mocked-conn) "alice") + old-key (:orcpub.user/verification-key user)] + ;; Backdate verification-sent past the 5-min rate limit window + @(d/transact mocked-conn + [{:db/id (:db/id user) + :orcpub.user/verification-sent + (java.util.Date. (- (System/currentTimeMillis) (* 6 60 1000)))}]) + ;; Second request should succeed and overwrite pending-email + (let [resp (routes/request-email-change + (make-request mocked-conn "new-pending@test.com" "alice"))] + (is (= 200 (:status resp))) + (let [updated (find-user (d/db mocked-conn) "alice")] + (is (= "new-pending@test.com" (:orcpub.user/pending-email updated))) + ;; Verification key should be replaced so old link is dead + (is (not= old-key (:orcpub.user/verification-key updated))))))))))) From 335d62d3e3075ccb6464a13b322af822de0d3e37 Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Thu, 19 Feb 2026 09:35:42 -0600 Subject: [PATCH 03/11] Fixed branding in emails, and footer. --- src/clj/orcpub/email.clj | 32 +++++++++++++++---------------- src/clj/orcpub/routes.clj | 6 +++--- src/cljs/orcpub/dnd/e5/views.cljs | 10 ++++++---- src/cljs/orcpub/ver.cljc | 2 +- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/clj/orcpub/email.clj b/src/clj/orcpub/email.clj index 071383231..5da538f8c 100644 --- a/src/clj/orcpub/email.clj +++ b/src/clj/orcpub/email.clj @@ -9,10 +9,10 @@ (defn verification-email-html [first-and-last-name username verification-url] [:div - (str "Dear OrcPub Patron,") + (str "Dear Dungeon Master's Vault Patron,") [:br] [:br] - "Your OrcPub 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:" + "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:" [:br] [:br] [:a {:href verification-url} verification-url] @@ -21,7 +21,7 @@ "Sincerely," [:br] [:br] - "The OrcPub Team"]) + "The Dungeon Master's Vault Team"]) (defn verification-email [first-and-last-name username verification-url] [{:type "text/html" @@ -31,10 +31,10 @@ "Email body for existing users changing their email (distinct from registration)." [username verification-url] [:div - "Dear OrcPub Patron," + "Dear Dungeon Master's Vault Patron," [:br] [:br] - "You requested to change the email address on your OrcPub account (" username "). " + "You requested to change the email address on your account (" username "). " "Please visit the following URL to confirm this change:" [:br] [:br] @@ -47,7 +47,7 @@ "Sincerely," [:br] [:br] - "The OrcPub Team"]) + "The Dungeon Master's Vault Team"]) (defn email-change-verification-email [username verification-url] [{:type "text/html" @@ -63,13 +63,13 @@ cfg)) (defn emailfrom [] - (if (not (s/blank? (environ/env :email-from-address))) (environ/env :email-from-address) (str "no-reply@orcpub.com"))) + (if (not (s/blank? (environ/env :email-from-address))) (environ/env :email-from-address) (str "no-reply@dungeonmastersvault.com"))) (defn send-verification-email [base-url {:keys [email username first-and-last-name]} verification-key] (postal/send-message (email-cfg) - {:from (str "OrcPub Team <" (emailfrom) ">") + {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") :to email - :subject "OrcPub Email Verification" + :subject "Dungeon Master's Vault Email Verification" :body (verification-email first-and-last-name username @@ -79,16 +79,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 "OrcPub Team <" (emailfrom) ">") + {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") :to email - :subject "OrcPub Email Change Verification" + :subject "Dungeon Master's Vault 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 - (str "Dear OrcPub Patron") + (str "Dear Dungeon Master's Vault Patron") [:br] [:br] "We received a request to reset your password, to do so please go to the following URL to complete the reset." @@ -103,7 +103,7 @@ "Sincerely," [:br] [:br] - "The OrcPub Team"]) + "The Dungeon Master's Vault Team"]) (defn reset-password-email [first-and-last-name reset-url] [{:type "text/html" @@ -111,9 +111,9 @@ (defn send-reset-email [base-url {:keys [email username first-and-last-name]} reset-key] (postal/send-message (email-cfg) - {:from (str "OrcPub Team <" (emailfrom) ">") + {:from (str "Dungeon Master's Vault Team <" (emailfrom) ">") :to email - :subject "OrcPub Password Reset" + :subject "Dungeon Master's Vault Password Reset" :body (reset-password-email first-and-last-name (str base-url (routes/path-for routes/reset-password-page-route) "?key=" reset-key))})) @@ -121,7 +121,7 @@ (defn send-error-email [context exception] (if (not-empty (environ/env :email-errors-to)) (postal/send-message (email-cfg) - {:from (str "OrcPub Errors <" (emailfrom) ">") + {:from (str "Dungeon Master's Vault 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 760dc648d..3fa7c84ac 100644 --- a/src/clj/orcpub/routes.clj +++ b/src/clj/orcpub/routes.clj @@ -390,7 +390,7 @@ (redirect route-map/verify-success-route) (do-verification request (merge query-params - {:first-and-last-name "OrcPub Patron"}) + {:first-and-last-name "DMV Patron"}) conn {:db/id id})))) @@ -403,7 +403,7 @@ :orcpub.user/password-reset-sent (java.util.Date.)}]) (email/send-reset-email (base-url request) - {:first-and-last-name "OrcPub Patron" + {:first-and-last-name "DMV Patron" :email email} key) {:status 200})) @@ -574,7 +574,7 @@ :where [?e :orcpub.user/password-reset-key ?key]]) (def default-title - "The New OrcPub: D&D 5e Character Builder/Generator") + "Dungeon Master's Vault: D&D 5e Character Builder/Generator") (def default-description "Dungeons & Dragons 5th Edition (D&D 5e) character builder/generator and digital character sheet far beyond any other in the multiverse.") diff --git a/src/cljs/orcpub/dnd/e5/views.cljs b/src/cljs/orcpub/dnd/e5/views.cljs index 212d78b0c..61be8ea71 100644 --- a/src/cljs/orcpub/dnd/e5/views.cljs +++ b/src/cljs/orcpub/dnd/e5/views.cljs @@ -1450,16 +1450,18 @@ [:div.content.f-w-n.f-s-12 [: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 "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-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.legal-footer - [:p "© 2025 " [:a.orange {:href "https://github.com/Orcpub/orcpub/" :target :_blank} "Orcpub"]] - [:p "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. OrcPub.com is not affiliated with, endorsed, sponsored, or specifically approved by Wizards of the Coast LLC."] - [:p "Version " (v/version) " (" (v/date) ")"]]] + [:p "© " (.getFullYear (js/Date.)) " " [:a.orange {:href "https://github.com/Orcpub/orcpub/" :target :_blank} "Orcpub"]] + [: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"]]] [debug-data]]]])]))}))) (def row-style diff --git a/src/cljs/orcpub/ver.cljc b/src/cljs/orcpub/ver.cljc index 500f7db79..7c75457a4 100644 --- a/src/cljs/orcpub/ver.cljc +++ b/src/cljs/orcpub/ver.cljc @@ -2,4 +2,4 @@ ; To be updated by build server (defn version [] "v2.5.0.28") (defn date [] "09-07-2025") -(defn description [] "Assault of the Last Stand edition") \ No newline at end of file +(defn description [] "Assault of the Last Stand") \ No newline at end of file From 1b1535f51cd1bb01c7c22f250e5ea6e97098e565 Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Thu, 19 Feb 2026 09:53:46 -0600 Subject: [PATCH 04/11] Remove dead code (native mobile app) --- .expo/packager-info.json | 8 - .expo/settings.json | 9 - exp.json | 26 -- main.js | 8 - native/cljs/orcpub/core.cljs | 32 -- native/cljs/orcpub/dnd/e5/native-views.cljs | 324 -------------------- native/cljs/orcpub/views.cljs | 22 -- package.json | 14 - 8 files changed, 443 deletions(-) delete mode 100644 .expo/packager-info.json delete mode 100644 .expo/settings.json delete mode 100644 exp.json delete mode 100644 main.js delete mode 100644 native/cljs/orcpub/core.cljs delete mode 100644 native/cljs/orcpub/dnd/e5/native-views.cljs delete mode 100644 native/cljs/orcpub/views.cljs delete mode 100644 package.json diff --git a/.expo/packager-info.json b/.expo/packager-info.json deleted file mode 100644 index 1938f58c8..000000000 --- a/.expo/packager-info.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "expoServerPort": 19000, - "packagerPort": 19001, - "packagerPid": 28823, - "expoServerNgrokUrl": "https://a2-dgb.larrychristensen.orcpub.exp.direct", - "packagerNgrokUrl": "https://packager.a2-dgb.larrychristensen.orcpub.exp.direct", - "ngrokPid": 28936 -} \ No newline at end of file diff --git a/.expo/settings.json b/.expo/settings.json deleted file mode 100644 index 310d971b0..000000000 --- a/.expo/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "hostType": "tunnel", - "lanType": "ip", - "dev": true, - "strict": false, - "minify": false, - "urlType": "exp", - "urlRandomness": "a2-dgb" -} \ No newline at end of file diff --git a/exp.json b/exp.json deleted file mode 100644 index 052401d96..000000000 --- a/exp.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "orcpub", - "description": "No description", - "slug": "orcpub", - "sdkVersion": "17.0.0", - "version": "1.0.0", - "orientation": "portrait", - "primaryColor": "#cccccc", - "privacy": "public", - "icon": "./assets/icons/app.png", - "notification": { - "icon": "./assets/icons/loading.png", - "color": "#000000" - }, - "loading": { - "icon": "https://s3.amazonaws.com/exp-brand-assets/ExponentEmptyManifest_192.png", - "hideExponentText": false - }, - "packagerOpts": { - "assetExts": ["ttf","otf"], - "nonPersistent": "" - }, - "ios": { - "supportsTablet": true - } -} diff --git a/main.js b/main.js deleted file mode 100644 index 5635ff4d8..000000000 --- a/main.js +++ /dev/null @@ -1,8 +0,0 @@ -'use strict'; - -// cljsbuild adds a preamble mentioning goog so hack around it -window.goog = { - provide() {}, - require() {}, -}; -require('./target/env/index.js'); diff --git a/native/cljs/orcpub/core.cljs b/native/cljs/orcpub/core.cljs deleted file mode 100644 index acbcb08a9..000000000 --- a/native/cljs/orcpub/core.cljs +++ /dev/null @@ -1,32 +0,0 @@ -(ns orcpub.core - (:require [reagent.core :as r :refer [atom]] - [re-frame.core :refer [subscribe dispatch dispatch-sync]] - [orcpub.dnd.e5.events] - [orcpub.dnd.e5.equipment-subs] - [orcpub.dnd.e5.subs] - [orcpub.dnd.e5.db] - [orcpub.views :refer [text view image touchable-without-feedback app-registry Alert]] - [orcpub.dnd.e5.native-views :as v5e])) - -(defn alert [title] - (.alert Alert title)) - -(defn hello-button [] - [touchable-without-feedback {:on-press #(alert "HELLO!")} - [view {:style {:border-width 1 :border-color "#f0a100" :padding 10 :border-radius 5}} - [text {:style {:color "white" :text-align "center" :font-weight "bold"}} "press me"]]]) - -(defn app-root [] - (prn "APP ROOT") - [view {:style {:flex 1}} - [view {:style {:background-color "#313a4d" - :padding-top 20 - :padding-bottom 5 - :padding-left 5 - :padding-right 5}} - [image {:source (js/require "./assets/images/dmv-logo.png")}]] - [v5e/character-builder]]) - -(defn init [] - (dispatch-sync [:initialize-db]) - (.registerComponent app-registry "main" #(r/reactify-component app-root))) diff --git a/native/cljs/orcpub/dnd/e5/native-views.cljs b/native/cljs/orcpub/dnd/e5/native-views.cljs deleted file mode 100644 index cb4f7a2fb..000000000 --- a/native/cljs/orcpub/dnd/e5/native-views.cljs +++ /dev/null @@ -1,324 +0,0 @@ -(ns orcpub.dnd.e5.native-views - (:require [orcpub.views :refer [view - scroll-view - text - touchable-without-feedback - main-text-color - light-text-color]] - [orcpub.entity :as entity] - [orcpub.views-aux :as views-aux] - [orcpub.template :as t] - [clojure.string :as s] - [reagent.core :as r] - [clojure.pprint :refer [pprint]] - [re-frame.core :refer [subscribe dispatch dispatch-sync]])) - -(def tab-style - {:padding 10 - :flex 1 - :border-bottom-color main-text-color - :opacity 0.4 - :height 45 - :flex-direction :row - :justify-content :space-around}) - -(def builder-tab-style - (merge - tab-style - {:border-bottom-width 5})) - -(def selected-tab-style - {:opacity 1}) - -(def selected-builder-tab-style - (merge - builder-tab-style - selected-tab-style)) - -(defn builder-tab [title key selected-tab] - [touchable-without-feedback {:on-press #(reset! selected-tab key)} - [view {:style (if (= key @selected-tab) - selected-builder-tab-style - builder-tab-style)} - [text {:style {:font-weight :bold - :color main-text-color - :font-size 18}} - title]]]) - -(def pages - [{:name "Race" - :icon "woman-elf-face" - :tags #{:race :subrace}} - {:name "Background" - :icon "ages" - :tags #{:background}} - {:name "Proficiencies" - :icon "juggler" - :tags #{:profs}}]) - -(def options-tab-style - (merge - tab-style - {:border-bottom-width 2 - :height 40})) - -(def selected-options-tab-style - (merge - options-tab-style - selected-tab-style)) - -(defn options-tab [title i selected-tab] - [touchable-without-feedback {:on-press #(do (prn "I" i) (reset! selected-tab i))} - [view {:style (if (= i @selected-tab) - selected-options-tab-style - options-tab-style)} - [text {:style {:font-weight :bold - :color main-text-color - :font-size 12}} - title]]]) - -(def unselected-option-style - {:padding 12 - :margin 2 - :border-width 2 - :border-radius 5 - :border-color light-text-color}) - -(def selected-option-style - (assoc - unselected-option-style - :border-width 5 - :border-color main-text-color)) - -(defn option-view [option-path - selection - disable-select-new? - homebrew? - option] - (let [{:keys [name - key - selected? - selectable? - multiselect? - option-path - select-fn - help - has-named-mods? - modifiers-str - failed-prereqs] :as data} - (views-aux/option-selector-data option-path - selection - disable-select-new? - homebrew? - option)] - [touchable-without-feedback - {:on-press select-fn} - [view {:style (if selected? - selected-option-style - unselected-option-style)} - [text name]]])) - -(defn selection-section-title [title] - (prn "SELETCION SECTION TITLE" title) - [view {:style {:margin-left 5}} - [text {:style {:font-size 16 - :font-weight :bold}} - title]]) - -(defn selection-section-parent-title [title] - (prn "SELECTION SECTION PARENT TITLE" title) - [view {:style {:margin-left 5 - :margin-bottom 2}} - [text {:style {:font-style :italic - :font-size 14 - :color light-text-color}} - title]]) - -(defn align-items-c [s] - (assoc s :align-items :center)) - -(defn h [s v] - (assoc s :height v)) - -(defn w [s v] - (assoc s :width v)) - -(defn i [s] - (assoc s :font-style :italic)) - -(defn remaining-bubble [value color left-offset top-offset] - [view {:style {:background-color color - :border-color color - :border-radius 12 - :border-width 12}} - [text - {:style {:position :absolute - :left left-offset - :top top-offset - :font-weight :bold - :font-size (or font-size 14) - :color :white}} - value]]) - -(defn remaining-indicator [remaining & [size font-size]] - (remaining-bubble remaining :red -4 -8)) - -(def remaining-text-style - {:margin-left 5 - :font-style :italic}) - -(def remaining-view-style - {:align-items :center - :flex-direction :row}) - -(defn remaining-component [max remaining] - [view {:style {:margin-left 10}} - (cond - (pos? remaining) - [view {:style remaining-view-style} - (remaining-indicator remaining) - [text {:style remaining-text-style} - "remaining"]] - - (or (zero? remaining) - (and (nil? max) - (neg? remaining))) - [view {:style remaining-view-style} - (remaining-bubble "\u2713" :green -6 -9) - [text {:style remaining-text-style} - "complete"]] - - (neg? remaining) - [view {:style remaining-view-style} - [text {:style {:font-style :italic - :margin-right 5}} - "remove"] - (remaining-bubble (Math/abs remaining) :red -4 -8)])]) - -(defn selection-section-base [] - (let [expanded? (r/atom false)] - (fn [{:keys [title path parent-title name icon help max min remaining body hide-lock? hide-homebrew?]}] - (let [locked? @(subscribe [:locked path]) - homebrew? @(subscribe [:homebrew? path])] - [view {:style {:padding 5 - :margin-bottom 20}} - (if (and (or title name) parent-title) - (selection-section-parent-title parent-title)) - [view - #_(if icon (views5e/svg-icon icon 24)) - (if (or title name) - (selection-section-title (or title name)) - (if parent-title - (selection-section-parent-title parent-title))) - #_(if (and path help) - [show-info-button expanded?]) - #_(if (not hide-lock?) - [:i.fa.f-s-16.m-l-10.m-r-5.pointer - {:class-name (if locked? "fa-lock" "fa-unlock-alt opacity-5 hover-opacity-full") - :on-click #(dispatch [:toggle-locked path])}]) - #_(if (not hide-homebrew?) - [:span.pointer - {:class-name (if (not homebrew?) "opacity-5 hover-opacity-full") - :on-click #(dispatch [:toggle-homebrew path])} - (views5e/svg-icon "beer-stein" 18)])] - #_(if (and help path @expanded?) - [help-section help]) - (if (int? min) - [view {:style {:flex-direction :row - :align-items :center - :padding-horizontal 5 - :justify-content :space-between}} - [text {:style {:font-style :italic}} - (str "select " (cond - (= min max) min - (zero? min) (if (nil? max) - "any number" - (str "up to " max)) - :else (str "at least " min)))] - (remaining-component max remaining)]) - body])))) - -(defn selection-view [path {:keys [::t/key ::t/name ::t/options] :as selection} _ _ _] - [view {:style {:margin-bottom 10}} - (doall - (map - (fn [{:keys [::t/key ::t/name] :as option}] - ^{:key key} - [option-view - path - selection - false - false - option]) - options))]) - -(defn selection-section [title built-template option-paths ui-fns selection num-columns remaining & [hide-homebrew?]] - (let [path (entity/actual-path selection) - {:keys [disable-select-new? homebrew?] :as data} - (views-aux/selection-section-data - title - built-template - option-paths - ui-fns - selection-view - selection - num-columns - remaining - hide-homebrew?)] - [selection-section-base data])) - -(defn build-view [] - (let [selected-tab-index (r/atom 0)] - (fn [] - (let [character @(subscribe [:character]) - built-template @(subscribe [:built-template]) - available-selections @(subscribe [:available-selections]) - option-paths @(subscribe [:option-paths]) - built-char @(subscribe [:built-character]) - {:keys [tags ui-fns components] :as page} (pages @selected-tab-index) - selections (entity/tagged-selections available-selections tags) - final-selections (entity/combine-selections selections)] - (pprint character) - [view {:style {:flex 1}} - [view {:style {:flex-direction :row}} - [view {:style {:flex-direction :row - :flex 1 - :padding 10 - :justify-content :space-around}} - (doall - (map-indexed - (fn [i {:keys [name icon tags]}] - ^{:key name} - [options-tab name i selected-tab-index]) - pages))]] - [scroll-view {:style {:padding 10}} - (doall - (map - (fn [{:keys [::t/key ::t/name] :as selection}] - (let [path (entity/actual-path selection)] - ^{:key (s/join "," path)} - [selection-section - name - built-template - option-paths - ui-fns - selection - 1 - (entity/count-remaining built-template character selection) - false])) - final-selections))]])))) - - -(defn character-builder [] - (let [selected-tab (r/atom :options)] - [view {:style {:align-items :center - :flex 1}} - [view {:style {:flex-direction :row - :margin-top 10 - :padding 10}} - [builder-tab "BUILD" :options selected-tab] - [builder-tab "DESCRIBE" :description selected-tab] - [builder-tab "VIEW" :sheet selected-tab]] - [view {:style {:flex 1 - :flex-direction :row}} - [build-view]]])) diff --git a/native/cljs/orcpub/views.cljs b/native/cljs/orcpub/views.cljs deleted file mode 100644 index 7dab2a6b3..000000000 --- a/native/cljs/orcpub/views.cljs +++ /dev/null @@ -1,22 +0,0 @@ -(ns orcpub.views - (:require [reagent.core :as r :refer [atom]])) - -(def ReactNative (js/require "react-native")) -#_(def FontAwesome (js/require "react-native-fontawesome")) -#_(def FontAwesomeIcons (.-Icons FontAwesome)) - -#_(def fa (r/adapt-react-class FontAwesome)) -#_(defn fa-icon [icon-name] - [fa (aget FontAwesomeIcons icon-name)]) - -(def app-registry (.-AppRegistry ReactNative)) -(def text (r/adapt-react-class (.-Text ReactNative))) -(def view (r/adapt-react-class (.-View ReactNative))) -(def scroll-view (r/adapt-react-class (.-ScrollView ReactNative))) -(def image (r/adapt-react-class (.-Image ReactNative))) -(def touchable-highlight (r/adapt-react-class (.-TouchableHighlight ReactNative))) -(def touchable-without-feedback (r/adapt-react-class (.-TouchableWithoutFeedback ReactNative))) -(def Alert (.-Alert ReactNative)) - -(def main-text-color "#727272") -(def light-text-color "#BBBBBB") diff --git a/package.json b/package.json deleted file mode 100644 index 5b30b93c2..000000000 --- a/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "orcpub", - "version": "0.0.1", - "description": "", - "author": "", - "private": true, - "main": "main.js", - "dependencies": { - "expo": "^17.0.0", - "react": "16.0.0-alpha.6", - "react-native": "https://github.com/expo/react-native/archive/sdk-17.0.0.tar.gz", - "react-native-fontawesome": "^5.7.0" - } -} From 11c67886164912f179bf36c798773b8d7b4c753f Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Thu, 19 Feb 2026 11:46:44 -0600 Subject: [PATCH 05/11] Fix docker image --- docker/orcpub/Dockerfile | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docker/orcpub/Dockerfile b/docker/orcpub/Dockerfile index b808e2f6e..1516082b4 100644 --- a/docker/orcpub/Dockerfile +++ b/docker/orcpub/Dockerfile @@ -1,5 +1,4 @@ -FROM clojure:openjdk-8-lein as builder -MAINTAINER daemonsthere@gmail.com +FROM openjdk:8u242-jre as builder # Build cache layer ADD ./lib/ /root/.m2/repository/ @@ -11,8 +10,7 @@ ADD ./ /orcpub RUN printenv &&\ lein uberjar -FROM openjdk:8-jre-alpine as runner -MAINTAINER daemonsthere@gmail.com +FROM openjdk:8u242-jre as runner COPY --from=builder /orcpub/target/orcpub.jar /orcpub.jar From e368d76f30fa8ab39e7580006505cf7b1be89617 Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Thu, 19 Feb 2026 11:59:58 -0600 Subject: [PATCH 06/11] fix for build --- docker/orcpub/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/orcpub/Dockerfile b/docker/orcpub/Dockerfile index 1516082b4..5231cd3a2 100644 --- a/docker/orcpub/Dockerfile +++ b/docker/orcpub/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:8u242-jre as builder +FROM clojure:lein as builder # Build cache layer ADD ./lib/ /root/.m2/repository/ From 53b221bf8b8a7aa169fef5e2f40b2a8ffebbeff6 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 19 Feb 2026 18:03:57 +0000 Subject: [PATCH 07/11] Update version to 2.4.0.28 --- src/cljs/orcpub/ver.cljc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cljs/orcpub/ver.cljc b/src/cljs/orcpub/ver.cljc index 7c75457a4..8505bdc17 100644 --- a/src/cljs/orcpub/ver.cljc +++ b/src/cljs/orcpub/ver.cljc @@ -1,5 +1,5 @@ (ns orcpub.ver) ; To be updated by build server -(defn version [] "v2.5.0.28") -(defn date [] "09-07-2025") +(defn version [] "2.4.0.28") +(defn date [] "$(date +%m-%d-%Y)") (defn description [] "Assault of the Last Stand") \ No newline at end of file From d365f7e8cd0bf33f90121f9e8f499caf311e4999 Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Thu, 19 Feb 2026 12:51:03 -0600 Subject: [PATCH 08/11] Fixing for builds, removes the 'v' need - simplifies build --- .github/workflows/docker.yaml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 74320d695..5f0d7a7fb 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -4,11 +4,11 @@ name: Docker Releases on: push: tags: - - 'v*' # e.g. v1.0, v2.3.4 + - '*' # e.g. 1.0, 2.3.4.4 workflow_dispatch: inputs: tag: - description: "Tag name to build (e.g., v1.2.3)" + description: "Tag name to build (e.g., 1.2.3.4)" required: true jobs: @@ -23,25 +23,26 @@ jobs: id: get_version run: | if [[ -n "${{ github.event.inputs.tag }}" ]]; then - ref="${{ github.event.inputs.tag }}" + VERSION="${{ github.event.inputs.tag }}" else - ref="${GITHUB_REF}" + VERSION="${GITHUB_REF#refs/tags/}" fi - echo "VERSION1=${ref#refs/*/}" >> $GITHUB_OUTPUT - echo "VERSION2=${ref#refs/*/v}" >> $GITHUB_OUTPUT + echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT # -------------------------------------------------------------------- # Checkout the repo (HTTPS – no SSH key needed) # -------------------------------------------------------------------- - name: Checkout uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.tag || github.ref }} # -------------------------------------------------------------------- # Update version/date in src/cljs/orcpub/ver.cljc # -------------------------------------------------------------------- - name: Update src/cljs/orcpub/ver.cljc run: | - sed -i 's/defn version \[\] ".*"/defn version [] "${{ steps.get_version.outputs.VERSION1 }}"/' src/cljs/orcpub/ver.cljc + sed -i 's/defn version \[\] ".*"/defn version [] "${{ steps.get_version.outputs.VERSION }}"/' src/cljs/orcpub/ver.cljc sed -i 's/defn date \[\] ".*"/defn date [] "$(date +%m-%d-%Y)"/' src/cljs/orcpub/ver.cljc cat src/cljs/orcpub/ver.cljc @@ -75,14 +76,14 @@ jobs: # -------------------------------------------------------------------- # Build & push the versioned image for orcpub # -------------------------------------------------------------------- - - name: Build and push ${{ steps.get_version.outputs.VERSION2 }} orcpub + - name: Build and push ${{ steps.get_version.outputs.VERSION }} orcpub uses: docker/build-push-action@v3 with: context: . file: ./docker/orcpub/Dockerfile platforms: linux/amd64 push: true - tags: orcpub/orcpub:release-${{ steps.get_version.outputs.VERSION2 }} + tags: orcpub/orcpub:release-${{ steps.get_version.outputs.VERSION }} # -------------------------------------------------------------------- # Build & push the “latest” image for datomic @@ -99,14 +100,14 @@ jobs: # -------------------------------------------------------------------- # Build & push the versioned image for datomic # -------------------------------------------------------------------- - - name: Build and push ${{ steps.get_version.outputs.VERSION2 }} datomic + - name: Build and push ${{ steps.get_version.outputs.VERSION }} datomic uses: docker/build-push-action@v3 with: context: . file: ./docker/datomic/Dockerfile platforms: linux/amd64 push: true - tags: orcpub/datomic:release-${{ steps.get_version.outputs.VERSION2 }} + tags: orcpub/datomic:release-${{ steps.get_version.outputs.VERSION }} # -------------------------------------------------------------------- # Commit the updated version file back to *develop* @@ -118,5 +119,5 @@ jobs: git fetch --all git checkout develop || git checkout -b develop git add src/cljs/orcpub/ver.cljc - git commit -m "Update version to ${{ steps.get_version.outputs.VERSION1 }}" + git commit -m "Update version to ${{ steps.get_version.outputs.VERSION }}" git push \ No newline at end of file From 4d4d44ce4338cc667c8a33202a534bf1ac3ec85a Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Thu, 19 Feb 2026 13:10:51 -0600 Subject: [PATCH 09/11] Fix for build --- .github/workflows/docker.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 5f0d7a7fb..7800a702d 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -35,7 +35,7 @@ jobs: - name: Checkout uses: actions/checkout@v3 with: - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ github.event.inputs.tag && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }} # -------------------------------------------------------------------- # Update version/date in src/cljs/orcpub/ver.cljc From 7f1d39718f474a14648a7dd09f3d987f4307280d Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Thu, 19 Feb 2026 13:21:08 -0600 Subject: [PATCH 10/11] Swap image for eclipse-temurin:8-jre-alpine --- docker/orcpub/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/orcpub/Dockerfile b/docker/orcpub/Dockerfile index 5231cd3a2..8c0f1cfc5 100644 --- a/docker/orcpub/Dockerfile +++ b/docker/orcpub/Dockerfile @@ -10,7 +10,7 @@ ADD ./ /orcpub RUN printenv &&\ lein uberjar -FROM openjdk:8u242-jre as runner +FROM eclipse-temurin:8-jre-alpine as runner COPY --from=builder /orcpub/target/orcpub.jar /orcpub.jar From 8687c62a2d6e58e29eb97ccb3ebcbf4a61d83d6b Mon Sep 17 00:00:00 2001 From: DatDamnZotz Date: Thu, 19 Feb 2026 13:24:48 -0600 Subject: [PATCH 11/11] Switch image to eclipse-temurin:8-jre-alpine --- docker/datomic/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/datomic/Dockerfile b/docker/datomic/Dockerfile index 9629c455b..d3dda3182 100644 --- a/docker/datomic/Dockerfile +++ b/docker/datomic/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:8u242-jre +FROM eclipse-temurin:8-jre-alpine ENV DATOMIC_VERSION 0.9.5703