From fa6f54b5ffc008a13b43826444ebb3e9707fb0c5 Mon Sep 17 00:00:00 2001 From: Andy Smith Date: Wed, 3 Dec 2025 10:40:35 -0600 Subject: [PATCH 01/88] Better mini-bubble position for the Maybe column --- app/assets/stylesheets/card-columns.css | 53 +++++++++++++------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 3b2faca60..8b2e373ff 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -682,38 +682,39 @@ /* -------------------------------------------------------------------------- */ /* Surface a mini bubble if there are cards with bubbles inside */ - .cards--considering:has(.bubble:not([hidden])), - .cards--doing.is-collapsed:has(.bubble:not([hidden])) { - .cards__transition-container { - --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); - --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; + .cards--considering:has(.bubble:not([hidden])) .cards__expander-title, + .cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container { + --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); + --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; + + &:before { + background: radial-gradient( + color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%, + color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100% + ); + block-size: 1em; + border-radius: var(--bubble-shape); + content: ""; + inline-size: 1em; + inset: 0 0 auto auto; + position: absolute; + translate: 20% -20%; + z-index: 1; + } + + /* Maybe column: position bubble relative to the title, not the container */ + .cards--considering & { + overflow: visible; + position: relative; &:before { - background: radial-gradient( - color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%, - color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100% - ); - block-size: 1em; - border-radius: var(--bubble-shape); - content: ""; - inline-size: 1em; - inset: 0 0 auto auto; - position: absolute; - translate: 20% -20%; - z-index: 1; - } - - .cards--considering &:before { - inset: 0 auto auto 0; - translate: 100% 75%; + inset-block-start: 50%; + translate: 125% -75%; + z-index: -1; } } } - .cards--considering:has(.bubble:not([hidden])) .cards__expander-title:before { - translate: 120% 50%; - } - /* Card column indicators /* -------------------------------------------------------------------------- */ From 2120d001868e107a9e088261b6d61751b30e4f25 Mon Sep 17 00:00:00 2001 From: Bruno Canepa <8711973+bruncanepa@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:36:16 -0300 Subject: [PATCH 02/88] Add Theme Switcher --- app/assets/stylesheets/_global.css | 111 +++++++++++++++++- .../controllers/theme_controller.js | 48 ++++++++ app/views/layouts/application.html.erb | 18 ++- 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 app/javascript/controllers/theme_controller.js diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 414d4c696..865be918c 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -234,8 +234,117 @@ --color-gradient-2: oklch(var(--lch-pink-lighter)); --color-gradient-3: oklch(var(--lch-purple-lighter)); --color-gradient-4: var(--color-canvas); +} - @media (prefers-color-scheme: dark) { +/* Dark mode - explicit theme choice overrides system preference */ +html[data-theme="dark"] { + --lch-canvas: 20% 0.0195 232.58; + --lch-ink-inverted: var(--lch-black); + + --lch-ink-darkest: 96.02% 0.0034 260; + --lch-ink-darker: 86% 0.0061 260; + --lch-ink-dark: 73.97% 0.009 260; + --lch-ink-medium: 62% 0.0122 260; + --lch-ink-light: 40% 0.0148 260; + --lch-ink-lighter: 30% 0.0178 260; + --lch-ink-lightest: 25% 0.0204 260; + + --lch-uncolor-darkest: 96.09% 0.0076 100; + --lch-uncolor-darker: 86% 0.021 90; + --lch-uncolor-dark: 73.93% 0.041 80; + --lch-uncolor-medium: 62% 0.0552 70; + --lch-uncolor-light: 40% 0.0387 60; + --lch-uncolor-lighter: 30% 0.012 50; + --lch-uncolor-lightest: 25% 0.0017 40; + + --lch-red-darkest: 95.85% 0.0218 46; + --lch-red-darker: 86% 0.086 44; + --lch-red-dark: 73.95% 0.139 42; + --lch-red-medium: 62% 0.154 40; + --lch-red-light: 40% 0.088 38; + --lch-red-lighter: 30% 0.032 36; + --lch-red-lightest: 25% 0.011 34; + + --lch-yellow-darkest: 96% 0.056 100; + --lch-yellow-darker: 86% 0.103 90; + --lch-yellow-dark: 74.06% 0.136 80; + --lch-yellow-medium: 62.1% 0.146 70; + --lch-yellow-light: 40% 0.0736 60; + --lch-yellow-lighter: 30% 0.026 50; + --lch-yellow-lightest: 25% 0.01 40; + + --lch-lime-darkest: 96.04% 0.066 115; + --lch-lime-darker: 86% 0.098 114; + --lch-lime-dark: 73.97% 0.121 113; + --lch-lime-medium: 62% 0.128 112; + --lch-lime-light: 40% 0.0637 111; + --lch-lime-lighter: 30% 0.024 110; + --lch-lime-lightest: 25% 0.012 109; + + --lch-green-darkest: 96.12% 0.035 143; + --lch-green-darker: 86% 0.082 144; + --lch-green-dark: 73.99% 0.117 145; + --lch-green-medium: 62% 0.1261 146; + --lch-green-light: 40% 0.065 147; + --lch-green-lighter: 30% 0.03 148; + --lch-green-lightest: 25% 0.018 149; + + --lch-aqua-darkest: 96.15% 0.0244 202; + --lch-aqua-darker: 86% 0.06 204; + --lch-aqua-dark: 73.92% 0.095 206; + --lch-aqua-medium: 62% 0.106 208; + --lch-aqua-light: 40% 0.0594 210; + --lch-aqua-lighter: 30% 0.028 212; + --lch-aqua-lightest: 25% 0.017 214; + + --lch-blue-darkest: 95.93% 0.0217 252; + --lch-blue-darker: 86% 0.068 254; + --lch-blue-dark: 74% 0.1293 256; + --lch-blue-medium: 62% 0.159 258; + --lch-blue-light: 40% 0.094 260; + --lch-blue-lighter: 30% 0.0452 262; + --lch-blue-lightest: 25% 0.0318 264; + + --lch-violet-darkest: 95.97% 0.019 280; + --lch-violet-darker: 86% 0.068 282; + --lch-violet-dark: 74.08% 0.142 284; + --lch-violet-medium: 62% 0.184 286; + --lch-violet-light: 40% 0.108 288; + --lch-violet-lighter: 30% 0.048 290; + --lch-violet-lightest: 25% 0.025 292; + + --lch-purple-darkest: 95.99% 0.0217 302; + --lch-purple-darker: 86% 0.068 304; + --lch-purple-dark: 73.98% 0.141 306; + --lch-purple-medium: 62% 0.177 308; + --lch-purple-light: 40% 0.099 310; + --lch-purple-lighter: 30% 0.04 312; + --lch-purple-lightest: 25% 0.017 314; + + --lch-pink-darkest: 95.84% 0.0308 336; + --lch-pink-darker: 86% 0.074 338; + --lch-pink-dark: 74.04% 0.1294 340; + --lch-pink-medium: 62% 0.166 342; + --lch-pink-light: 40% 0.085 344; + --lch-pink-lighter: 30% 0.03 346; + --lch-pink-lightest: 25% 0.011 348; + + --color-terminal-bg: var(--color-canvas); + --color-terminal-text-light: oklch(var(--lch-green-dark)); + --color-golden: oklch(var(--lch-blue-medium)); + --color-highlight: oklch(var(--lch-blue-lighter)); + + --shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42), + 0 0.2em 1.6em -0.8em oklch(var(--lch-black) / 0.6), + 0 0.4em 2.4em -1em oklch(var(--lch-black) / 0.7), + 0 0.4em 0.8em -1.2em oklch(var(--lch-black) / 0.8), + 0 0.8em 1.2em -1.6em oklch(var(--lch-black) / 0.9), + 0 1.2em 1.6em -2em oklch(var(--lch-black) / 1); +} + +/* Fallback to system preference when no explicit theme is set */ +@media (prefers-color-scheme: dark) { + html:not([data-theme]) { --lch-canvas: 20% 0.0195 232.58; --lch-ink-inverted: var(--lch-black); diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js new file mode 100644 index 000000000..c5476c06c --- /dev/null +++ b/app/javascript/controllers/theme_controller.js @@ -0,0 +1,48 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["sunIcon", "moonIcon"] + + connect() { + this.applyStoredTheme() + this.updateIcon() + } + + toggle() { + const currentTheme = document.documentElement.getAttribute("data-theme") + // If no theme is set, check system preference or default to light + let newTheme + if (!currentTheme) { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches + newTheme = prefersDark ? "light" : "dark" + } else { + newTheme = currentTheme === "dark" ? "light" : "dark" + } + this.setTheme(newTheme) + this.updateIcon() + } + + setTheme(theme) { + document.documentElement.setAttribute("data-theme", theme) + localStorage.setItem("theme", theme) + } + + applyStoredTheme() { + const storedTheme = localStorage.getItem("theme") + if (storedTheme) { + document.documentElement.setAttribute("data-theme", storedTheme) + } + } + + updateIcon() { + const currentTheme = document.documentElement.getAttribute("data-theme") + const isDark = currentTheme === "dark" || + (!currentTheme && window.matchMedia("(prefers-color-scheme: dark)").matches) + + // Show moon icon in light mode, sun icon in dark mode + if (this.hasSunIconTarget && this.hasMoonIconTarget) { + this.sunIconTarget.style.display = isDark ? "block" : "none" + this.moonIconTarget.style.display = isDark ? "none" : "block" + } + } +} diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d2af3e959..a71c3fc49 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -2,11 +2,27 @@ <%= render "layouts/shared/head" %> - + <%= render "layouts/shared/flash" %> From 2864e32efa3f9a192728d42b636259c6cdb6ccc4 Mon Sep 17 00:00:00 2001 From: Bruno Canepa <8711973+bruncanepa@users.noreply.github.com> Date: Thu, 4 Dec 2025 17:01:14 -0300 Subject: [PATCH 03/88] Move theme button into the user profile page --- app/views/layouts/application.html.erb | 16 ---------------- app/views/users/_theme.html.erb | 16 ++++++++++++++++ app/views/users/show.html.erb | 4 ++++ 3 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 app/views/users/_theme.html.erb diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index a71c3fc49..6aef1f1bc 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -7,22 +7,6 @@ Skip to main content <%= render "my/menu" if Current.user %> <%= yield :header %> - <%= render "layouts/shared/flash" %> diff --git a/app/views/users/_theme.html.erb b/app/views/users/_theme.html.erb new file mode 100644 index 000000000..6ba153326 --- /dev/null +++ b/app/views/users/_theme.html.erb @@ -0,0 +1,16 @@ + diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index eec6444a0..cb95a38fd 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,6 +1,10 @@ <% @page_title = @user.name %> <% me_or_you = Current.user == @user ? "me" : @user.first_name %> +<% if Current.user == @user %> + <%= render "users/theme" %> +<% end %> +
From eca8e32ad0eb73f374ff0fdc441088473e1f357f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Soares?= Date: Fri, 5 Dec 2025 20:20:13 -0300 Subject: [PATCH 04/88] Hide range thumb that shows through knob on iOS Safari --- app/assets/stylesheets/knobs.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/knobs.css b/app/assets/stylesheets/knobs.css index cf73d217b..640314bee 100644 --- a/app/assets/stylesheets/knobs.css +++ b/app/assets/stylesheets/knobs.css @@ -28,6 +28,10 @@ translate: -50% -50%; z-index: 1; + @supports (-webkit-touch-callout: none) { + opacity: 0; + } + &::-moz-range-track { block-size: var(--knob-size); cursor: grab; From 7328e03d3f7239569f2c32625770c1bc4317b843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Soares?= Date: Sat, 6 Dec 2025 13:27:39 -0300 Subject: [PATCH 05/88] Replaces @supports (-webkit-touch-callout: none) hack with a straightforward opacity: 0 on the slider element --- app/assets/stylesheets/knobs.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/assets/stylesheets/knobs.css b/app/assets/stylesheets/knobs.css index 640314bee..0605b12cb 100644 --- a/app/assets/stylesheets/knobs.css +++ b/app/assets/stylesheets/knobs.css @@ -24,14 +24,11 @@ block-size: var(--knob-size); inline-size: var(--knob-size); inset: 50% auto auto 50%; + opacity: 0; position: absolute; translate: -50% -50%; z-index: 1; - @supports (-webkit-touch-callout: none) { - opacity: 0; - } - &::-moz-range-track { block-size: var(--knob-size); cursor: grab; From 6ae9486b4d0ba8a048e297287cf1d372b85cc61b Mon Sep 17 00:00:00 2001 From: Bruno Canepa <8711973+bruncanepa@users.noreply.github.com> Date: Sat, 6 Dec 2025 14:29:49 -0300 Subject: [PATCH 06/88] add 3 toggle botons --- .../controllers/theme_controller.js | 67 ++++++++++++------- app/views/users/_theme.html.erb | 52 +++++++++----- app/views/users/show.html.erb | 6 +- 3 files changed, 79 insertions(+), 46 deletions(-) diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index c5476c06c..150864622 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -1,48 +1,63 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["sunIcon", "moonIcon"] + static targets = ["lightBtn", "darkBtn", "autoBtn"] connect() { this.applyStoredTheme() - this.updateIcon() + this.updateButtons() } - toggle() { - const currentTheme = document.documentElement.getAttribute("data-theme") - // If no theme is set, check system preference or default to light - let newTheme - if (!currentTheme) { - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches - newTheme = prefersDark ? "light" : "dark" - } else { - newTheme = currentTheme === "dark" ? "light" : "dark" - } - this.setTheme(newTheme) - this.updateIcon() + setLight() { + this.setTheme("light") + document.documentElement.setAttribute("data-theme", "light") + this.updateButtons() + } + + setDark() { + this.setTheme("dark") + document.documentElement.setAttribute("data-theme", "dark") + this.updateButtons() + } + + setAuto() { + this.setTheme("auto") + document.documentElement.removeAttribute("data-theme") + this.updateButtons() } setTheme(theme) { - document.documentElement.setAttribute("data-theme", theme) localStorage.setItem("theme", theme) } applyStoredTheme() { - const storedTheme = localStorage.getItem("theme") - if (storedTheme) { - document.documentElement.setAttribute("data-theme", storedTheme) + const storedTheme = localStorage.getItem("theme") || "auto" + + if (storedTheme === "light") { + document.documentElement.setAttribute("data-theme", "light") + } else if (storedTheme === "dark") { + document.documentElement.setAttribute("data-theme", "dark") + } else { + // auto - don't set data-theme, let CSS media query handle it + document.documentElement.removeAttribute("data-theme") } } - updateIcon() { - const currentTheme = document.documentElement.getAttribute("data-theme") - const isDark = currentTheme === "dark" || - (!currentTheme && window.matchMedia("(prefers-color-scheme: dark)").matches) + updateButtons() { + const storedTheme = localStorage.getItem("theme") || "auto" - // Show moon icon in light mode, sun icon in dark mode - if (this.hasSunIconTarget && this.hasMoonIconTarget) { - this.sunIconTarget.style.display = isDark ? "block" : "none" - this.moonIconTarget.style.display = isDark ? "none" : "block" + // Reset all buttons + if (this.hasLightBtnTarget) this.lightBtnTarget.removeAttribute("aria-selected") + if (this.hasDarkBtnTarget) this.darkBtnTarget.removeAttribute("aria-selected") + if (this.hasAutoBtnTarget) this.autoBtnTarget.removeAttribute("aria-selected") + + // Highlight active button + if (storedTheme === "light" && this.hasLightBtnTarget) { + this.lightBtnTarget.setAttribute("aria-selected", "true") + } else if (storedTheme === "dark" && this.hasDarkBtnTarget) { + this.darkBtnTarget.setAttribute("aria-selected", "true") + } else if (this.hasAutoBtnTarget) { + this.autoBtnTarget.setAttribute("aria-selected", "true") } } } diff --git a/app/views/users/_theme.html.erb b/app/views/users/_theme.html.erb index 6ba153326..86cf694e2 100644 --- a/app/views/users/_theme.html.erb +++ b/app/views/users/_theme.html.erb @@ -1,16 +1,36 @@ - +
+ Appearance + + +
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index cb95a38fd..e59e9cad8 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,10 +1,6 @@ <% @page_title = @user.name %> <% me_or_you = Current.user == @user ? "me" : @user.first_name %> -<% if Current.user == @user %> - <%= render "users/theme" %> -<% end %> -
@@ -43,6 +39,8 @@
<%= render "users/transfer", user: @user %> + <%= render "users/theme" %> +
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %> Sign out From f419ba5a13b2cdbd83ef433aa10241fe937f6e11 Mon Sep 17 00:00:00 2001 From: Bruno Canepa <8711973+bruncanepa@users.noreply.github.com> Date: Sun, 7 Dec 2025 20:07:06 -0300 Subject: [PATCH 07/88] refactor from PR feedback: update theme controller to use private methods for theme management --- .../controllers/theme_controller.js | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index 150864622..26ab67817 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -4,34 +4,38 @@ export default class extends Controller { static targets = ["lightBtn", "darkBtn", "autoBtn"] connect() { - this.applyStoredTheme() - this.updateButtons() + this.#applyStoredTheme() + this.#updateButtons() } setLight() { - this.setTheme("light") + this.#setTheme("light") document.documentElement.setAttribute("data-theme", "light") - this.updateButtons() + this.#updateButtons() } setDark() { - this.setTheme("dark") + this.#setTheme("dark") document.documentElement.setAttribute("data-theme", "dark") - this.updateButtons() + this.#updateButtons() } setAuto() { - this.setTheme("auto") + this.#setTheme("auto") document.documentElement.removeAttribute("data-theme") - this.updateButtons() + this.#updateButtons() } - setTheme(theme) { + get #storedTheme() { + return localStorage.getItem("theme") || "auto" + } + + #setTheme(theme) { localStorage.setItem("theme", theme) } - applyStoredTheme() { - const storedTheme = localStorage.getItem("theme") || "auto" + #applyStoredTheme() { + const storedTheme = this.#storedTheme if (storedTheme === "light") { document.documentElement.setAttribute("data-theme", "light") @@ -43,8 +47,8 @@ export default class extends Controller { } } - updateButtons() { - const storedTheme = localStorage.getItem("theme") || "auto" + #updateButtons() { + const storedTheme = this.#storedTheme // Reset all buttons if (this.hasLightBtnTarget) this.lightBtnTarget.removeAttribute("aria-selected") From 5175353979aa6c7c82759e10f292bfbc7b5086be Mon Sep 17 00:00:00 2001 From: Bruno Canepa <8711973+bruncanepa@users.noreply.github.com> Date: Mon, 8 Dec 2025 18:40:27 -0300 Subject: [PATCH 08/88] Split svg into files and add a css file --- app/assets/images/monitor.svg | 1 + app/assets/images/moon.svg | 1 + app/assets/images/sun.svg | 1 + app/assets/stylesheets/icons.css | 3 + app/assets/stylesheets/theme-switcher.css | 67 +++++++++++++++++++++++ app/views/users/_theme.html.erb | 30 +++------- 6 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 app/assets/images/monitor.svg create mode 100644 app/assets/images/moon.svg create mode 100644 app/assets/images/sun.svg create mode 100644 app/assets/stylesheets/theme-switcher.css diff --git a/app/assets/images/monitor.svg b/app/assets/images/monitor.svg new file mode 100644 index 000000000..a1140a2a8 --- /dev/null +++ b/app/assets/images/monitor.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/moon.svg b/app/assets/images/moon.svg new file mode 100644 index 000000000..8db6c1b09 --- /dev/null +++ b/app/assets/images/moon.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/sun.svg b/app/assets/images/sun.svg new file mode 100644 index 000000000..6dd6e54c8 --- /dev/null +++ b/app/assets/images/sun.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 8e9f19e89..2e269b9d0 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -72,6 +72,8 @@ .icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); } .icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); } .icon--minus { --svg: url("minus.svg "); } + .icon--monitor { --svg: url("monitor.svg "); } + .icon--moon { --svg: url("moon.svg "); } .icon--move { --svg: url("move.svg "); } .icon--notification-bell-access-only { --svg: url("bell.svg "); } .icon--notification-bell-watching { --svg: url("bell-off.svg "); } @@ -96,6 +98,7 @@ .icon--settings { --svg: url("settings.svg "); } .icon--share { --svg: url("share.svg "); } .icon--sliders { --svg: url("sliders.svg "); } + .icon--sun { --svg: url("sun.svg "); } .icon--switch { --svg: url("switch.svg "); } .icon--tag { --svg: url("tag.svg "); } .icon--tag-outline { --svg: url("tag-outline.svg "); } diff --git a/app/assets/stylesheets/theme-switcher.css b/app/assets/stylesheets/theme-switcher.css new file mode 100644 index 000000000..1e35ca990 --- /dev/null +++ b/app/assets/stylesheets/theme-switcher.css @@ -0,0 +1,67 @@ +@layer components { + .theme-switcher { + --gap: 8px; + + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--gap); + inline-size: 100%; + justify-content: center; + margin: 1rem auto; + max-inline-size: 100%; + } + + .theme-switcher__btn { + --btn-border-radius: 0.4em; + + align-items: center; + aspect-ratio: 5/3; + background-color: var(--color-ink-lightest); + border: none; + border-radius: 0.5em; + color: inherit; + cursor: pointer; + display: flex; + flex-basis: calc((100% - var(--gap) * 2) / 3); + flex-direction: column; + font-size: var(--text-small); + line-height: 1; + justify-content: center; + overflow: hidden; + padding: 0.5em; + position: relative; + row-gap: 0.3lh; + text-align: center; + + .icon { + --icon-size: 2em; + } + + span { + display: block; + text-wrap: nowrap; + } + + @media (max-width: 639px) { + font-size: var(--text-x-small); + font-weight: 500; + } + + @media (any-hover: hover) { + &:hover { + background-color: var(--color-ink-lighter); + } + } + + &[aria-selected="true"] { + background-color: var(--color-selected); + + @media (any-hover: hover) { + &:hover { + background-color: var(--color-selected); + } + } + } + } +} diff --git a/app/views/users/_theme.html.erb b/app/views/users/_theme.html.erb index 86cf694e2..ce4e91429 100644 --- a/app/views/users/_theme.html.erb +++ b/app/views/users/_theme.html.erb @@ -1,35 +1,19 @@
Appearance - From 6e19e17ca475d4653d26f03ec875193e9c5bbf48 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 8 Dec 2025 17:46:10 -0600 Subject: [PATCH 09/88] Need to account for theme setting when using `prefers-color-scheme: dark` --- app/assets/stylesheets/bar.css | 8 +++++++- app/assets/stylesheets/base.css | 8 +++++++- app/assets/stylesheets/buttons.css | 8 +++++++- app/assets/stylesheets/card-columns.css | 8 +++++++- app/assets/stylesheets/cards.css | 8 +++++++- app/assets/stylesheets/circled-text.css | 8 +++++++- app/assets/stylesheets/inputs.css | 8 +++++++- app/assets/stylesheets/lexxy.css | 9 ++++++++- app/assets/stylesheets/markdown.css | 9 ++++++++- app/assets/stylesheets/nav.css | 8 +++++++- app/assets/stylesheets/reactions.css | 9 ++++++++- app/assets/stylesheets/syntax.css | 18 +++++++++++++++++- app/assets/stylesheets/trays.css | 9 ++++++++- 13 files changed, 105 insertions(+), 13 deletions(-) diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/bar.css index 86183ccb0..e13badb7b 100644 --- a/app/assets/stylesheets/bar.css +++ b/app/assets/stylesheets/bar.css @@ -19,10 +19,16 @@ view-transition-name: bar; z-index: var(--z-bar); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { border-block: 1px solid var(--color-ink-lighter); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + border-block: 1px solid var(--color-ink-lighter); + } + } + &:has(.bar__placeholder[hidden]) { padding-inline: 1ch; } diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css index 31c4180e1..05d2b016e 100644 --- a/app/assets/stylesheets/base.css +++ b/app/assets/stylesheets/base.css @@ -55,9 +55,15 @@ ::selection { background: var(--color-selected); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { background-color: var(--color-selected-dark); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + background-color: var(--color-selected-dark); + } + } } :where(ul, ol):where([role="list"]) { diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index 274d80ceb..4ee5f199e 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -27,10 +27,16 @@ } } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --btn-hover-brightness: 1.25; } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --btn-hover-brightness: 1.25; + } + } + &[disabled], &:has([disabled]), [disabled] &[type=submit], diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index ac690ac30..19084f65c 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -179,9 +179,15 @@ outline: var(--focus-ring-size) solid var(--color-selected-dark); outline-offset: var(--focus-ring-offset); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { outline-color: oklch(var(--lch-blue-medium)); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + outline-color: oklch(var(--lch-blue-medium)); + } + } } } diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 9fe9c6601..3fb0a7f4d 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -27,10 +27,16 @@ text-align: start; z-index: 1; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + box-shadow: 0 0 0 1px var(--color-ink-lighter); + } + } + .popup { inline-size: 260px; } diff --git a/app/assets/stylesheets/circled-text.css b/app/assets/stylesheets/circled-text.css index 9c3e87f48..dcb971d51 100644 --- a/app/assets/stylesheets/circled-text.css +++ b/app/assets/stylesheets/circled-text.css @@ -12,9 +12,15 @@ opacity: 0.5; mix-blend-mode: multiply; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { mix-blend-mode: screen; } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + mix-blend-mode: screen; + } + } } span::before, diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 109bfbb26..366983d00 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -113,10 +113,16 @@ background-repeat: no-repeat; text-align: start; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); + } + } + option { background-color: var(--color-canvas); color: var(--color-ink); diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index e3f8f8cbd..a0100ca1b 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -188,10 +188,17 @@ padding: 0.5em 1.8em 0.5em 1.2em; text-align: start; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); + } + } + + option { background-color: var(--color-canvas); color: var(--color-ink); diff --git a/app/assets/stylesheets/markdown.css b/app/assets/stylesheets/markdown.css index 5dfac832c..f6aef0e39 100644 --- a/app/assets/stylesheets/markdown.css +++ b/app/assets/stylesheets/markdown.css @@ -24,8 +24,15 @@ } } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { filter: invert(1); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + filter: invert(1); + } + } + } } diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index bcbe83088..bcc60c85e 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -23,9 +23,15 @@ background: var(--color-ink-lighter); } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --input-background: var(--color-ink-lighter); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --input-background: var(--color-ink-lighter); + } + } } } diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index ed6187bae..797e9a7cb 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -95,9 +95,16 @@ &:not(.expanded):hover { filter: brightness(var(--reaction-hover-brightness)); - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { --reaction-hover-brightness: 1.25; } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + --reaction-hover-brightness: 1.25; + } + } + } } } diff --git a/app/assets/stylesheets/syntax.css b/app/assets/stylesheets/syntax.css index 7ddc2cf42..7da03d67b 100644 --- a/app/assets/stylesheets/syntax.css +++ b/app/assets/stylesheets/syntax.css @@ -14,7 +14,7 @@ --markup-deleted: lch(39.64 68.17 31.45); /* Redefine named color values for dark mode */ - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] { --keyword: lch(67.63 58.99 30.64); --entity: lch(75.13 46.73 306.74); --constant: lch(74.9 39.71 255.53); @@ -28,6 +28,22 @@ --markup-deleted: lch(73.8% 65 29.18); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) { + --keyword: lch(67.63 58.99 30.64); + --entity: lch(75.13 46.73 306.74); + --constant: lch(74.9 39.71 255.53); + --string: lch(74.9 39.71 255.53); + --variable: lch(76.17 61.1 61.97); + --comment: lch(60.83 6.66 254.46); + --entity-tag: lch(83.65 59.31 141.61); + --markup-heading: lch(47.93 71.67 280.72); + --markup-list: lch(83.84 57.9 85.03); + --markup-inserted: lch(83.65 59.31 141.61); + --markup-deleted: lch(73.8% 65 29.18); + } + } + color: var(--color-ink); .w { diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 46c4b74b7..3cefc1ecc 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -300,9 +300,16 @@ border-radius: 0; } - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { box-shadow: 0 0 0 1px var(--color-ink-lighter); } + + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + box-shadow: 0 0 0 1px var(--color-ink-lighter); + } + } + } .card__background { From 18c0a7875999be01d4e11883ea6bdab2c37b06a7 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 8 Dec 2025 17:53:40 -0600 Subject: [PATCH 10/88] Prefer component and utility classes when possible --- app/assets/stylesheets/theme-switcher.css | 62 +++++------------------ app/views/users/_theme.html.erb | 20 ++++---- 2 files changed, 22 insertions(+), 60 deletions(-) diff --git a/app/assets/stylesheets/theme-switcher.css b/app/assets/stylesheets/theme-switcher.css index 1e35ca990..2b35308d1 100644 --- a/app/assets/stylesheets/theme-switcher.css +++ b/app/assets/stylesheets/theme-switcher.css @@ -1,58 +1,20 @@ @layer components { .theme-switcher { - --gap: 8px; - - align-items: center; - display: flex; - flex-wrap: wrap; - gap: var(--gap); - inline-size: 100%; - justify-content: center; - margin: 1rem auto; - max-inline-size: 100%; - } - - .theme-switcher__btn { - --btn-border-radius: 0.4em; - - align-items: center; - aspect-ratio: 5/3; - background-color: var(--color-ink-lightest); - border: none; - border-radius: 0.5em; - color: inherit; - cursor: pointer; - display: flex; - flex-basis: calc((100% - var(--gap) * 2) / 3); - flex-direction: column; - font-size: var(--text-small); - line-height: 1; - justify-content: center; - overflow: hidden; - padding: 0.5em; - position: relative; - row-gap: 0.3lh; - text-align: center; - - .icon { - --icon-size: 2em; - } - - span { - display: block; - text-wrap: nowrap; - } - @media (max-width: 639px) { - font-size: var(--text-x-small); - font-weight: 500; + flex-direction: column; } + } + .theme-switcher__btn { + --btn-background: var(--color-ink-lightest); + --btn-border-radius: 0.4em; + --btn-border-size: 0; + --btn-gap: 0.3lh; + --icon-size: 2em; - @media (any-hover: hover) { - &:hover { - background-color: var(--color-ink-lighter); - } - } + flex: 1 0 33%; + flex-direction: column; + position: relative; + white-space: nowrap; &[aria-selected="true"] { background-color: var(--color-selected); diff --git a/app/views/users/_theme.html.erb b/app/views/users/_theme.html.erb index ce4e91429..01d2c479e 100644 --- a/app/views/users/_theme.html.erb +++ b/app/views/users/_theme.html.erb @@ -1,20 +1,20 @@ -
- Appearance +
+ Appearance -
- - -
-
+
From 2b99c09089e23d041d74d3fbbd132ecbde053ad0 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 8 Dec 2025 17:54:06 -0600 Subject: [PATCH 11/88] Rework settings for clarity, separation, and consistency --- app/views/users/_transfer.html.erb | 9 +++++---- app/views/users/show.html.erb | 9 ++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index 8befea9ce..489d9765a 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -1,10 +1,11 @@ -
+
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
From 3f312897800f0ebeb6c6f6531c1b14616e3deae9 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 8 Dec 2025 18:03:26 -0600 Subject: [PATCH 12/88] Account for user color theme preference --- app/assets/stylesheets/lexxy.css | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index cc79ca76a..1b00668dd 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -75,10 +75,16 @@ padding-inline: 1.5ch 1.8em; text-align: start; - @media (prefers-color-scheme: dark) { + html[data-theme="dark"] & { background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); } + @media (prefers-color-scheme: dark) { + html:not([data-theme]) & { + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); + } + } + option { background-color: var(--color-canvas); color: var(--color-ink); From 0780b5168f4b11071811d498dbf1c0a282a1063b Mon Sep 17 00:00:00 2001 From: Adam Haris Date: Tue, 9 Dec 2025 15:40:01 +0100 Subject: [PATCH 13/88] add env variable to disable multi-tenancy --- README.md | 2 ++ app/controllers/concerns/authorization.rb | 5 +++++ app/controllers/signups_controller.rb | 5 +++++ app/models/signup.rb | 5 +++++ app/views/sessions/magic_links/show.html.erb | 7 ++++++- app/views/sessions/new.html.erb | 6 +++++- config/deploy.yml | 1 + test/controllers/signups_controller_test.rb | 15 +++++++++++++++ 8 files changed, 44 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 27440196e..00b8e76ae 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ We've added comments to that file to highlight what each setting needs to be, bu - `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. - `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. - `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this. +- `env/clear/SINGLE_TENANT`: Set to `true` to enable single-tenant mode (disable multi-account signups). + Fizzy also requires a few environment variables to be set up, some of which contain secrets. The simplest way to do this is to put them in a file called `.kamal/secrets`. diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 81bd0c781..3b3e17a14 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -4,6 +4,7 @@ module Authorization included do before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated? + helper_method :single_tenant? end class_methods do @@ -37,4 +38,8 @@ module Authorization def redirect_existing_user redirect_to root_path if Current.user end + + def single_tenant? + ENV.fetch("SINGLE_TENANT", "false") == "true" + end end diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index 776421201..ec39063ce 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -3,6 +3,7 @@ class SignupsController < ApplicationController allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } before_action :redirect_authenticated_user + before_action :prevent_signup_when_users_exist, if: -> { single_tenant? && User.any? } layout "public" @@ -27,4 +28,8 @@ class SignupsController < ApplicationController def signup_params params.expect signup: :email_address end + + def prevent_signup_when_users_exist + redirect_to new_session_url, alert: "Your account hasn’t been set up yet. Please request an invitation from one of your coworkers." + end end diff --git a/app/models/signup.rb b/app/models/signup.rb index 110c253ef..4409c589c 100644 --- a/app/models/signup.rb +++ b/app/models/signup.rb @@ -16,6 +16,7 @@ class Signup end def create_identity + return if single_tenant? @identity = Identity.find_or_create_by!(email_address: email_address) @identity.send_magic_link for: :sign_up end @@ -96,4 +97,8 @@ class Signup attributes[:referrer] = Current.referrer end end + + def single_tenant? + ENV.fetch("SINGLE_TENANT", "false") == "true" + end end diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index 4e94f411e..810b3527a 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -3,7 +3,12 @@
">

<%= @page_title %>

-

Then enter the verification code included in the email below:

+

+ <% if single_tenant? %> + iIf your email belongs to an existing account. + <% end %> + Then enter the verification code included in the email below: +

<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index fc89bf681..643c9b66c 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -10,7 +10,11 @@
-

New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we’ll get you signed in.

+ <% if single_tenant? %> +

Enter your email and we’ll get you signed in.

+ <% else %> +

New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we’ll get you signed in.

+ <% end %>
- <% if single_tenant? %> -

Enter your email and we’ll get you signed in.

- <% else %> + <% if Account.accepting_signups? %>

New here? <%= link_to "Sign up", new_signup_path %> to create an account. Already have an account? Enter your email and we’ll get you signed in.

+ <% else %> +

Enter your email and we’ll get you signed in.

<% end %>
<% else %> -
- Nothing new for you. +
+ Nothing new for you
<% end %> From afc59cd7c9e5bc78795b3fef8c74bd7b18a3d0ef Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Wed, 10 Dec 2025 20:25:58 +0100 Subject: [PATCH 24/88] Hide Previously Seen section when empty --- .../index/_read_notifications.html.erb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/views/notifications/index/_read_notifications.html.erb b/app/views/notifications/index/_read_notifications.html.erb index c5e282237..04c2e3c48 100644 --- a/app/views/notifications/index/_read_notifications.html.erb +++ b/app/views/notifications/index/_read_notifications.html.erb @@ -1,7 +1,9 @@ -
-

Previously seen

+<% if page.records.any? %> +
+

Previously seen

-
- <%= render partial: "notifications/index/notification", collection: page.records, cached: true %> -
-
+
+ <%= render partial: "notifications/index/notification", collection: page.records, cached: true %> +
+
+<% end %> From 78b62e692eae61e69a5626835923f3ccbf5f05bf Mon Sep 17 00:00:00 2001 From: Alexander Zaytsev Date: Wed, 10 Dec 2025 20:26:44 +0100 Subject: [PATCH 25/88] Apply blank slate styles to `.search__empty` --- app/assets/stylesheets/search.css | 4 ++-- app/views/searches/_results.html.erb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css index c5390e793..96368841e 100644 --- a/app/assets/stylesheets/search.css +++ b/app/assets/stylesheets/search.css @@ -69,8 +69,8 @@ summary { .search__empty { margin-block: 3em; - opacity: 0.66; - text-align: center; + margin-inline: auto; + inline-size: fit-content; } .search__excerpt { diff --git a/app/views/searches/_results.html.erb b/app/views/searches/_results.html.erb index ca82d65ee..602878c65 100644 --- a/app/views/searches/_results.html.erb +++ b/app/views/searches/_results.html.erb @@ -1,7 +1,7 @@ From 04d3ac50b21c811ef9dc41f079af64480380e164 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 10 Dec 2025 16:46:58 -0600 Subject: [PATCH 29/88] Clean-up post merge and rework layout to accomodate API and Appearance blocks --- app/views/users/_access_tokens.html.erb | 4 ++++ app/views/users/_transfer.html.erb | 7 +------ app/views/users/show.html.erb | 28 ++++++++----------------- 3 files changed, 14 insertions(+), 25 deletions(-) create mode 100644 app/views/users/_access_tokens.html.erb diff --git a/app/views/users/_access_tokens.html.erb b/app/views/users/_access_tokens.html.erb new file mode 100644 index 000000000..285f87149 --- /dev/null +++ b/app/views/users/_access_tokens.html.erb @@ -0,0 +1,4 @@ +
+

Developer

+

Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.

+
\ No newline at end of file diff --git a/app/views/users/_transfer.html.erb b/app/views/users/_transfer.html.erb index 533693ea8..13f895ebc 100644 --- a/app/views/users/_transfer.html.erb +++ b/app/views/users/_transfer.html.erb @@ -1,15 +1,10 @@
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %> -
-

Link a device

-

Use this link to sign-in on another device

-
-
<% end %> + + <% if Current.user == @user %> + + + <%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> + Sign out of Fizzy on this device + <% end %> + <% end %>
@@ -44,25 +52,7 @@
<%= render "users/theme" %> <%= render "users/transfer", user: @user %> - - - - <%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> - Sign out of Fizzy on this device -
-
-

API

-
- -
- <%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %> -
-
- -
- <%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %> - Sign out of Fizzy - <% end %> + <%= render "users/access_tokens" %>
<% end %>
From 5ee10800a284e7dcab913d353920c37b7fa8b6ef Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Dec 2025 11:52:38 +0100 Subject: [PATCH 30/88] Allow direct uploads via API --- config/initializers/active_storage.rb | 10 +++ .../direct_uploads_controller_test.rb | 72 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 test/controllers/active_storage/direct_uploads_controller_test.rb diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index e647a72cf..5c83b18f5 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -35,6 +35,16 @@ module ActiveStorageControllerExtensions end end +module ActiveStorageDirectUploadsControllerExtensions + extend ActiveSupport::Concern + + included do + include Authentication + skip_forgery_protection if: :authenticate_by_bearer_token + end +end + Rails.application.config.to_prepare do ActiveStorage::BaseController.include ActiveStorageControllerExtensions + ActiveStorage::DirectUploadsController.include ActiveStorageDirectUploadsControllerExtensions end diff --git a/test/controllers/active_storage/direct_uploads_controller_test.rb b/test/controllers/active_storage/direct_uploads_controller_test.rb new file mode 100644 index 000000000..cdb218be2 --- /dev/null +++ b/test/controllers/active_storage/direct_uploads_controller_test.rb @@ -0,0 +1,72 @@ +require "test_helper" + +class ActiveStorage::DirectUploadsControllerTest < ActionDispatch::IntegrationTest + setup do + @blob_params = { + blob: { + filename: "screenshot.png", + byte_size: 12345, + checksum: "GQ5SqLsM7ylnji0Wgd9wNC==", + content_type: "image/png" + } + } + end + + test "create" do + sign_in_as :david + + post rails_direct_uploads_path, + params: @blob_params, + headers: bearer_token_header(identity_access_tokens(:davids_api_token).token), + as: :json + + assert_response :success + assert_includes response.parsed_body.keys, "direct_upload" + end + + test "create with valid access token" do + post rails_direct_uploads_path, + params: @blob_params, + headers: bearer_token_header(identity_access_tokens(:davids_api_token).token), + as: :json + + assert_response :success + assert_includes response.parsed_body.keys, "direct_upload" + end + + test "create with read-only access token" do + post rails_direct_uploads_path, + params: @blob_params, + headers: bearer_token_header(identity_access_tokens(:jasons_api_token).token), + as: :json + + assert_response :unauthorized + end + + test "create with invalid access token" do + post rails_direct_uploads_path, + params: @blob_params, + headers: bearer_token_header("invalid_token"), + as: :json + + assert_response :unauthorized + end + + test "create unauthenticated" do + post rails_direct_uploads_path, + params: @blob_params.merge(authenticity_token: csrf_token), + as: :json + + assert_response :redirect + end + + private + def bearer_token_header(token) + { "Authorization" => "Bearer #{token}" } + end + + def csrf_token + get new_session_url + response.body[/name="csrf-token" content="([^"]+)"/, 1] + end +end From fb77d85b0d9468a075820be85ee0cd99ea887643 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Dec 2025 12:00:51 +0100 Subject: [PATCH 31/88] Explain that the upload URL is account-scope --- docs/API.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 3710e582f..318e7a1dd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -178,10 +178,11 @@ curl -X POST \ "content_type": "image/png" } }' \ - https://app.fizzy.do/rails/active_storage/direct_uploads + https://app.fizzy.do/123456/rails/active_storage/direct_uploads ``` The `checksum` is a Base64-encoded MD5 hash of the file content. +The direct upload endpoint is scoped to your account (replace `/123456` with your account slug). __Response:__ From a5580666a7180d2b4e0c881c1d8e2281090852d2 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Wed, 10 Dec 2025 17:15:26 +0000 Subject: [PATCH 32/88] Single tenant by default & update tests --- app/models/concerns/multi_tenant.rb | 2 +- test/controllers/signups_controller_test.rb | 13 +++++-------- test/test_helper.rb | 2 ++ test/test_helpers/session_test_helper.rb | 8 ++++++++ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/app/models/concerns/multi_tenant.rb b/app/models/concerns/multi_tenant.rb index 07d98025d..4a9c194f2 100644 --- a/app/models/concerns/multi_tenant.rb +++ b/app/models/concerns/multi_tenant.rb @@ -3,7 +3,7 @@ module MultiTenant class_methods do def accepting_signups? - ENV.fetch("MULTI_TENANT", "true") == "true" || Account.none? + ENV.fetch("MULTI_TENANT", "false") == "true" || Account.none? end end end diff --git a/test/controllers/signups_controller_test.rb b/test/controllers/signups_controller_test.rb index a56446463..230064515 100644 --- a/test/controllers/signups_controller_test.rb +++ b/test/controllers/signups_controller_test.rb @@ -65,17 +65,14 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest end test "redirects to session#new when single_tenant and user exists" do - previous_multi_tenant = ENV["MULTI_TENANT"] - ENV["MULTI_TENANT"] = "false" - users(:david) - untenanted do - get new_signup_path + in_single_tenant_mode do + untenanted do + get new_signup_path - assert_redirected_to new_session_url + assert_redirected_to new_session_url + end end - ensure - ENV["MULTI_TENANT"] = previous_multi_tenant end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 12ab491ed..36738664e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,4 +1,6 @@ ENV["RAILS_ENV"] ||= "test" +ENV["MULTI_TENANT"] ||= "true" + require_relative "../config/environment" require "rails/test_help" diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index 927123bcf..b7f369036 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -57,4 +57,12 @@ module SessionTestHelper ensure integration_session.default_url_options[:script_name] = original_script_name end + + def in_single_tenant_mode + previous_multi_tenant = ENV["MULTI_TENANT"] + ENV["MULTI_TENANT"] = "false" + yield + ensure + ENV["MULTI_TENANT"] = previous_multi_tenant + end end From 0e443d360299f8e8656f8a1b5b2eae6fd8c85301 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Wed, 10 Dec 2025 17:26:53 +0000 Subject: [PATCH 33/88] Use initializer to configure tenant mode --- app/models/concerns/multi_tenant.rb | 6 +++++- config/environments/test.rb | 3 +++ config/initializers/multi_tenant.rb | 3 +++ test/controllers/signups_controller_test.rb | 2 +- test/models/account/multi_tenant_test.rb | 22 +++++++++++++++++++++ test/test_helper.rb | 2 -- test/test_helpers/session_test_helper.rb | 8 ++++---- 7 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 config/initializers/multi_tenant.rb create mode 100644 test/models/account/multi_tenant_test.rb diff --git a/app/models/concerns/multi_tenant.rb b/app/models/concerns/multi_tenant.rb index 4a9c194f2..28c16b2c4 100644 --- a/app/models/concerns/multi_tenant.rb +++ b/app/models/concerns/multi_tenant.rb @@ -1,9 +1,13 @@ module MultiTenant extend ActiveSupport::Concern + included do + cattr_accessor :multi_tenant, default: false + end + class_methods do def accepting_signups? - ENV.fetch("MULTI_TENANT", "false") == "true" || Account.none? + multi_tenant || Account.none? end end end diff --git a/config/environments/test.rb b/config/environments/test.rb index 4be27d242..0bfb2e0f0 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -67,4 +67,7 @@ Rails.application.configure do # Load test helpers config.autoload_paths += %w[ test/test_helpers ] + + # Enable multi-tenant mode for tests + config.x.multi_tenant = true end diff --git a/config/initializers/multi_tenant.rb b/config/initializers/multi_tenant.rb new file mode 100644 index 000000000..0b581ef9d --- /dev/null +++ b/config/initializers/multi_tenant.rb @@ -0,0 +1,3 @@ +Rails.application.config.after_initialize do + Account.multi_tenant = ENV["MULTI_TENANT"] == "true" || Rails.configuration.x.multi_tenant == true +end diff --git a/test/controllers/signups_controller_test.rb b/test/controllers/signups_controller_test.rb index 230064515..d5cbef51f 100644 --- a/test/controllers/signups_controller_test.rb +++ b/test/controllers/signups_controller_test.rb @@ -67,7 +67,7 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest test "redirects to session#new when single_tenant and user exists" do users(:david) - in_single_tenant_mode do + with_multi_tenant_mode(false) do untenanted do get new_signup_path diff --git a/test/models/account/multi_tenant_test.rb b/test/models/account/multi_tenant_test.rb new file mode 100644 index 000000000..fe5ebde00 --- /dev/null +++ b/test/models/account/multi_tenant_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class MultiTenantTest < ActiveSupport::TestCase + test "accepting_signups? is true when multi_tenant is enabled" do + with_multi_tenant_mode(true) do + assert Account.accepting_signups? + end + end + + test "accepting_signups? is false when multi_tenant is disabled and accounts exist" do + with_multi_tenant_mode(false) do + assert_not Account.accepting_signups? + end + end + + test "accepting_signups? is true when multi_tenant is disabled but no accounts exist" do + with_multi_tenant_mode(false) do + Account.delete_all + assert Account.accepting_signups? + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 36738664e..12ab491ed 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,4 @@ ENV["RAILS_ENV"] ||= "test" -ENV["MULTI_TENANT"] ||= "true" - require_relative "../config/environment" require "rails/test_help" diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index b7f369036..57d697f75 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -58,11 +58,11 @@ module SessionTestHelper integration_session.default_url_options[:script_name] = original_script_name end - def in_single_tenant_mode - previous_multi_tenant = ENV["MULTI_TENANT"] - ENV["MULTI_TENANT"] = "false" + def with_multi_tenant_mode(enabled) + previous = Account.multi_tenant + Account.multi_tenant = enabled yield ensure - ENV["MULTI_TENANT"] = previous_multi_tenant + Account.multi_tenant = previous end end From cad1e97e8a6ec0aab05becba34c3a35a06ca85e9 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 08:54:28 +0000 Subject: [PATCH 34/88] Don't need to call this out --- app/views/sessions/magic_links/show.html.erb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index e4c439803..947b3c426 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -4,9 +4,6 @@

<%= @page_title %>

- <% unless Account.accepting_signups? %> - If your email belongs to an existing account. - <% end %> Then enter the verification code included in the email below:

From 5d2ca6759d9ccc18cf72e313f176b94ab58fc659 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 09:00:51 +0000 Subject: [PATCH 35/88] Naming --- app/controllers/signups_controller.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index b9e12d759..56614158f 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -3,7 +3,7 @@ class SignupsController < ApplicationController allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } before_action :redirect_authenticated_user - before_action :prevent_signup_when_users_exist + before_action :enforce_tenant_limit layout "public" @@ -25,11 +25,11 @@ class SignupsController < ApplicationController redirect_to new_signup_completion_path if authenticated? end + def enforce_tenant_limit + redirect_to new_session_url unless Account.accepting_signups? + end + def signup_params params.expect signup: :email_address end - - def prevent_signup_when_users_exist - redirect_to new_session_url unless Account.accepting_signups? - end end From dece2d08d102380428bcf880413ee8d085541524 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 09:18:36 +0000 Subject: [PATCH 36/88] Update namespacing & naming --- app/models/account.rb | 2 +- .../{concerns/multi_tenant.rb => account/multi_tenantable.rb} | 2 +- .../account/{multi_tenant_test.rb => multi_tenantable_test.rb} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename app/models/{concerns/multi_tenant.rb => account/multi_tenantable.rb} (86%) rename test/models/account/{multi_tenant_test.rb => multi_tenantable_test.rb} (90%) diff --git a/app/models/account.rb b/app/models/account.rb index 761efd2fa..df0b845b0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,5 @@ class Account < ApplicationRecord - include Account::Storage, Entropic, MultiTenant, Seedeable + include Account::Storage, Entropic, MultiTenantable, Seedeable has_one :join_code has_many :users, dependent: :destroy diff --git a/app/models/concerns/multi_tenant.rb b/app/models/account/multi_tenantable.rb similarity index 86% rename from app/models/concerns/multi_tenant.rb rename to app/models/account/multi_tenantable.rb index 28c16b2c4..7d1ef5d39 100644 --- a/app/models/concerns/multi_tenant.rb +++ b/app/models/account/multi_tenantable.rb @@ -1,4 +1,4 @@ -module MultiTenant +module Account::MultiTenantable extend ActiveSupport::Concern included do diff --git a/test/models/account/multi_tenant_test.rb b/test/models/account/multi_tenantable_test.rb similarity index 90% rename from test/models/account/multi_tenant_test.rb rename to test/models/account/multi_tenantable_test.rb index fe5ebde00..ae1b189a3 100644 --- a/test/models/account/multi_tenant_test.rb +++ b/test/models/account/multi_tenantable_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class MultiTenantTest < ActiveSupport::TestCase +class Account::MultiTenantableTest < ActiveSupport::TestCase test "accepting_signups? is true when multi_tenant is enabled" do with_multi_tenant_mode(true) do assert Account.accepting_signups? From 06bfe2c9824e29037adbe16ce4970654cadde5f1 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 11:28:32 +0000 Subject: [PATCH 37/88] Scope the config option Also use a `configure` block to set it up. --- config/environments/test.rb | 2 +- config/initializers/multi_tenant.rb | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/config/environments/test.rb b/config/environments/test.rb index 0bfb2e0f0..157e4b00b 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -69,5 +69,5 @@ Rails.application.configure do config.autoload_paths += %w[ test/test_helpers ] # Enable multi-tenant mode for tests - config.x.multi_tenant = true + config.x.multi_tenant.enabled = true end diff --git a/config/initializers/multi_tenant.rb b/config/initializers/multi_tenant.rb index 0b581ef9d..0bf19fa65 100644 --- a/config/initializers/multi_tenant.rb +++ b/config/initializers/multi_tenant.rb @@ -1,3 +1,5 @@ -Rails.application.config.after_initialize do - Account.multi_tenant = ENV["MULTI_TENANT"] == "true" || Rails.configuration.x.multi_tenant == true +Rails.application.configure do + config.after_initialize do + Account.multi_tenant = ENV["MULTI_TENANT"] == "true" || config.x.multi_tenant.enabled == true + end end From 3f2938dd8ee8174e064d1a2eb079089a9ced1fec Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 12:19:15 +0000 Subject: [PATCH 38/88] Bump fizzy-saas to set config option --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 91c11943f..cc5d1ab5d 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: a14df11b57818697df4b2cc7b6a43e762ebaa196 + revision: fb30201b9b2752f9e9cff2f9f36b698b13856404 specs: fizzy-saas (0.1.0) audits1984 From 05d176cc314026a78b7f07ca2b8e311c35b79e83 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 12:49:03 +0000 Subject: [PATCH 39/88] Test that sign in respects single tenant state --- test/controllers/sessions_controller_test.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 7ebe4d518..f1d6b65c9 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -36,6 +36,21 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest end end + test "create for a new user when single tenant mode already has a tenant" do + with_multi_tenant_mode(false) do + untenanted do + assert_no_difference -> { MagicLink.count } do + assert_no_difference -> { Identity.count } do + post session_path, + params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" } + end + end + + assert_redirected_to session_magic_link_path + end + end + end + test "create with invalid email address" do # Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form # field validation that should normally prevent this from occurring, so I'm not worried about From 36419c3600d71bb8f085852e1a714ad16f3c20eb Mon Sep 17 00:00:00 2001 From: Thiago Youssef <43591948+thiagoyoussef@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:15:54 -0300 Subject: [PATCH 40/88] Bump rails version to remove svg renderer patch (#2081) --- Gemfile | 2 +- Gemfile.lock | 16 ++++++++-------- .../action_controller_svg_renderer.rb | 7 ------- 3 files changed, 9 insertions(+), 16 deletions(-) delete mode 100644 config/initializers/action_controller_svg_renderer.rb diff --git a/Gemfile b/Gemfile index e8a5f8514..8c6d39d0f 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ source "https://rubygems.org" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } -gem "rails", github: "rails/rails", branch: "ast-immediate-variants-process-locally" +gem "rails", github: "rails/rails", branch: "main" # Assets & front end gem "importmap-rails" diff --git a/Gemfile.lock b/Gemfile.lock index d99375463..b0ef0a648 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,8 +13,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 - branch: ast-immediate-variants-process-locally + revision: 4f4e0acaf558f6adf7df3ac23a51beb470463901 + branch: main specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) @@ -172,7 +172,7 @@ GEM bigdecimal rexml crass (1.0.6) - date (3.5.0) + date (3.5.1) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) @@ -223,7 +223,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.17.1) + json (2.18.0) jwt (3.1.2) base64 kamal (2.9.0) @@ -329,7 +329,7 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.2.6) + psych (5.3.0) date stringio public_suffix (7.0.0) @@ -345,7 +345,7 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) @@ -356,7 +356,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (6.16.1) + rdoc (6.17.0) erb psych (>= 4.0.0) tsort @@ -455,7 +455,7 @@ GEM thruster (0.1.16-arm64-darwin) thruster (0.1.16-x86_64-darwin) thruster (0.1.16-x86_64-linux) - timeout (0.4.4) + timeout (0.5.0) trilogy (2.9.0) tsort (0.2.0) turbo-rails (2.0.20) diff --git a/config/initializers/action_controller_svg_renderer.rb b/config/initializers/action_controller_svg_renderer.rb deleted file mode 100644 index c10e16d20..000000000 --- a/config/initializers/action_controller_svg_renderer.rb +++ /dev/null @@ -1,7 +0,0 @@ -# FIXME: Upstream to Rails -Rails.application.config.after_initialize do - ActionController.add_renderer :svg do |svg, options| - self.content_type = Mime[:svg] if media_type.nil? - svg - end -end From 21586291facfdcd660ae86f4b2fcb270de4389f4 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 11 Dec 2025 08:59:13 -0600 Subject: [PATCH 41/88] Prefer normal if/else conditional --- app/javascript/controllers/theme_controller.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index 9c3c9841f..fbc93a44f 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -26,7 +26,11 @@ export default class extends Controller { #setTheme(theme) { localStorage.setItem("theme", theme) - theme === "auto" ? document.documentElement.removeAttribute("data-theme") : document.documentElement.setAttribute("data-theme", theme) + if (theme === "auto") { + document.documentElement.removeAttribute("data-theme") + } else { + document.documentElement.setAttribute("data-theme", theme) + } this.#updateButtons() } From 2a0cf4efe9f56a626cd5bdb5c37865b8fda1c5b8 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 11 Dec 2025 09:31:12 -0600 Subject: [PATCH 42/88] Turn `#theme` into a proper setter --- app/javascript/controllers/theme_controller.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index fbc93a44f..30e296dca 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -8,22 +8,22 @@ export default class extends Controller { } setLight() { - this.#setTheme("light") + this.#theme = "light" } setDark() { - this.#setTheme("dark") + this.#theme = "dark" } setAuto() { - this.#setTheme("auto") + this.#theme = "auto" } get #storedTheme() { return localStorage.getItem("theme") || "auto" } - #setTheme(theme) { + set #theme(theme) { localStorage.setItem("theme", theme) if (theme === "auto") { @@ -36,7 +36,7 @@ export default class extends Controller { } #applyStoredTheme() { - this.#setTheme(this.#storedTheme) + this.#theme = this.#storedTheme } #updateButtons() { From 941ce1f775bd63797a403334f62eeb89121f9a14 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 11 Dec 2025 09:38:34 -0600 Subject: [PATCH 43/88] Need to use `has*Target` to check for optional static targets to prevent console errors --- app/javascript/controllers/theme_controller.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index 30e296dca..154bf4fc0 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -42,8 +42,8 @@ export default class extends Controller { #updateButtons() { const storedTheme = this.#storedTheme - if (this.lightButtonTarget) { this.lightButtonTarget.checked = (storedTheme === "light") } - if (this.darkButtonTarget) { this.darkButtonTarget.checked = (storedTheme === "dark") } - if (this.autoButtonTarget) { this.autoButtonTarget.checked = (storedTheme !== "light" && storedTheme !== "dark") } + if (this.hasLightButtonTarget) { this.lightButtonTarget.checked = (storedTheme === "light") } + if (this.hasDarkButtonTarget) { this.darkButtonTarget.checked = (storedTheme === "dark") } + if (this.hasAutoButtonTarget) { this.autoButtonTarget.checked = (storedTheme !== "light" && storedTheme !== "dark") } } } From 4edd49374a5464a35218dd4b09908aa4b216befd Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 16:04:55 +0000 Subject: [PATCH 44/88] Update Gemfile.saas.lock To bring in line with change in [36419c3600d7] --- Gemfile.saas.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index cc5d1ab5d..bcfabb4b6 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -82,8 +82,8 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751 - branch: ast-immediate-variants-process-locally + revision: 4f4e0acaf558f6adf7df3ac23a51beb470463901 + branch: main specs: actioncable (8.2.0.alpha) actionpack (= 8.2.0.alpha) From 154a3d79ee2206673c7cddb3979e1e382e0e01db Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 15:47:05 +0000 Subject: [PATCH 45/88] Add `DSIABLE_SSL` env option `assume_ssl` and `force_ssl` are often used together: - When running behind a terminating proxy (including Thruster) you'll want both. - When running without SSL (like on localhost) you'll want neither. To simplify setup for those cases, we add the `DISABLE_SSL` option. When set to `true` all SSL-related behaviour is turned off. When left as the default, it's on. We can still use `ASSUME_SSL` and `FORCE_SSL` to set those separately if required. --- config/environments/production.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 6398d14d0..031c9d2cb 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -60,12 +60,15 @@ Rails.application.configure do # config.action_cable.url = "wss://example.com/cable" # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + # Set DISABLE_SSL=true to disable all SSL options, rather than specify each individually + ssl_enabled = "true" unless ENV["DISABLE_SSL"] == "true" + # Assume all access to the app is happening through a SSL-terminating reverse proxy. # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. - config.assume_ssl = ENV.fetch("ASSUME_SSL", "true") == "true" + config.assume_ssl = ENV.fetch("ASSUME_SSL", ssl_enabled) == "true" # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true" + config.force_ssl = ENV.fetch("FORCE_SSL", ssl_enabled) == "true" # Log to STDOUT by default config.logger = ActiveSupport::Logger.new(STDOUT) From 5dab9f8a3c609de7d514f1c0f798624a730c2794 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 11 Dec 2025 11:42:30 -0500 Subject: [PATCH 46/88] Fix view-transition-name typo in public card show Note: This transition name isn't paired with any other element, so could be removed entirely if not planned for future use. --- app/views/public/cards/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/public/cards/show.html.erb b/app/views/public/cards/show.html.erb index ae2e1fb1a..aa08fe149 100644 --- a/app/views/public/cards/show.html.erb +++ b/app/views/public/cards/show.html.erb @@ -14,7 +14,7 @@ <% content_for :header do %>
- <%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click", style: "view-transistion-name: card-board-title;" %> + <%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click", style: "view-transition-name: card-board-title;" %>
<% end %> From 761fd8ccfbd7128cc4834ab70d7cb85d683a793b Mon Sep 17 00:00:00 2001 From: Zoltan Hosszu Date: Thu, 11 Dec 2025 18:09:06 +0100 Subject: [PATCH 47/88] Make list style same for every indentation Adds 'list-style: disc' to ordered and unordered lists in rich-text-content.css to ensure consistent bullet styling. --- app/assets/stylesheets/rich-text-content.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index c9877f4bf..70f095bca 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -29,6 +29,7 @@ } :where(ol, ul) { + list-style: disc; padding-inline-start: 3ch; } From ef3c49d9ad8cb803b1282d948565bfc854e70dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20=C3=81lvarez?= Date: Thu, 11 Dec 2025 18:25:59 +0100 Subject: [PATCH 48/88] Bump fizzy-saas Related to updating URL on staging environment. --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index bcfabb4b6..9153eb725 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: fb30201b9b2752f9e9cff2f9f36b698b13856404 + revision: 8f144d20a0f84cfe56e8ad66ed999455744a89d0 specs: fizzy-saas (0.1.0) audits1984 From 3d523b84fc4bc793da76f82b2c1abd7d6f4a6d24 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 11 Dec 2025 12:52:59 -0500 Subject: [PATCH 49/88] Explicitly use main_app for URL helpers in controller concerns that are mixed into other engines' controllers. This prevents errors like: > NoMethodError: undefined method 'session_menu_url' for an instance of ActiveStorage::DirectUploadsController See also 912bb8a8 and 5ee10800 --- app/controllers/concerns/authentication.rb | 8 ++++---- app/helpers/login_helper.rb | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 30f54820b..1b057a19d 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -37,7 +37,7 @@ module Authentication def require_account unless Current.account.present? - redirect_to session_menu_url(script_name: nil) + redirect_to main_app.session_menu_url(script_name: nil) end end @@ -78,11 +78,11 @@ module Authentication end def redirect_authenticated_user - redirect_to root_url if authenticated? + redirect_to main_app.root_url if authenticated? end def redirect_tenanted_request - redirect_to root_url if Current.account.present? + redirect_to main_app.root_url if Current.account.present? end def start_new_session_for(identity) @@ -110,7 +110,7 @@ module Authentication def redirect_to_session_magic_link(magic_link, return_to: nil) serve_development_magic_link(magic_link) session[:return_to_after_authenticating] = return_to if return_to - redirect_to session_magic_link_url(script_name: nil) + redirect_to main_app.session_magic_link_url(script_name: nil) end def serve_development_magic_link(magic_link) diff --git a/app/helpers/login_helper.rb b/app/helpers/login_helper.rb index f5e89c8d1..6004282a6 100644 --- a/app/helpers/login_helper.rb +++ b/app/helpers/login_helper.rb @@ -1,7 +1,5 @@ module LoginHelper def login_url - # Use main_app because this helper may be invoked from an engine controller - # that inherits from AdminController. main_app.new_session_path(script_name: nil) end From 679a073c6bd54b88620ded20d472efcf467a888f Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 11 Dec 2025 13:02:54 -0500 Subject: [PATCH 50/88] Add test coverage for the require_unauthenticated_access redirect --- test/controllers/sessions_controller_test.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index f1d6b65c9..74b336a4f 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -9,6 +9,15 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "new redirects authenticated users" do + sign_in_as :kevin + + untenanted do + get new_session_path + assert_redirected_to root_url + end + end + test "create" do identity = identities(:kevin) From b6edb93c472541033245104949894d860687362a Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Dec 2025 17:18:47 +0100 Subject: [PATCH 51/88] Pin sign in attempts to the current session This makes it way more difficult to brute-force a magic link. Original implementation by udiudi. Ref: https://github.com/udiudi/fizzy/pull/1/files Discussion: https://github.com/basecamp/fizzy/discussions/1981 Co-Authored-By: Udi --- app/controllers/concerns/authentication.rb | 12 ++++++++++++ .../sessions/magic_links_controller.rb | 12 ++++++++++-- app/controllers/sessions_controller.rb | 4 ++-- .../sessions/magic_links_controller_test.rb | 16 ++++++++++++++++ test/test_helpers/session_test_helper.rb | 1 + 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 30f54820b..fbe2c6b79 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -107,8 +107,20 @@ module Authentication end end + def email_address_pending_authentication_matches?(email_address) + pending_email_address = session.fetch(:email_address_pending_authentication, "") + + if ActiveSupport::SecurityUtils.secure_compare(email_address, pending_email_address) + session.delete(:email_address_pending_authentication) + true + else + false + end + end + def redirect_to_session_magic_link(magic_link, return_to: nil) serve_development_magic_link(magic_link) + session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link session[:return_to_after_authenticating] = return_to if return_to redirect_to session_magic_link_url(script_name: nil) end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 71fa1b9ac..1608e9fbb 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -10,14 +10,22 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - start_new_session_for magic_link.identity - redirect_to after_sign_in_url(magic_link) + authenticate_with magic_link else redirect_to session_magic_link_path, flash: { shake: true } end end private + def authenticate_with(magic_link) + if email_address_pending_authentication_matches?(magic_link.identity.email_address) + start_new_session_for magic_link.identity + redirect_to after_sign_in_url(magic_link) + else + redirect_to new_session_path, alert: "Authentication failed. Please try again." + end + end + def code params.expect(:code) end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index e32cf3829..f5044bd2a 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -14,8 +14,8 @@ class SessionsController < ApplicationController else signup = Signup.new(email_address: email_address) if signup.valid?(:identity_creation) - identity = signup.create_identity if Account.accepting_signups? - redirect_to_session_magic_link identity + magic_link = signup.create_identity if Account.accepting_signups? + redirect_to_session_magic_link magic_link else head :unprocessable_entity end diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb index dbde3723e..f2316ed51 100644 --- a/test/controllers/sessions/magic_links_controller_test.rb +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -14,6 +14,7 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest magic_link = MagicLink.create!(identity: identity) untenanted do + post session_path, params: { email_address: identity.email_address } post session_magic_link_url, params: { code: magic_link.code } assert_response :redirect @@ -28,6 +29,7 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest magic_link = MagicLink.create!(identity: identity, purpose: :sign_up) untenanted do + post session_path, params: { email_address: identity.email_address } post session_magic_link_url, params: { code: magic_link.code } assert_response :redirect @@ -37,6 +39,20 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest end end + test "create with cross-user code" do + identity = identities(:kevin) + other_identity = identities(:jason) + magic_link = MagicLink.create!(identity: other_identity) + + untenanted do + post session_path, params: { email_address: identity.email_address } + post session_magic_link_url, params: { code: magic_link.code } + + assert_redirected_to new_session_path + assert_not cookies[:session_token].present? + end + end + test "create with invalid code" do identity = identities(:kevin) magic_link = MagicLink.create!(identity: identity) diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb index 57d697f75..d07d6612a 100644 --- a/test/test_helpers/session_test_helper.rb +++ b/test/test_helpers/session_test_helper.rb @@ -18,6 +18,7 @@ module SessionTestHelper magic_link = identity.magic_links.order(id: :desc).first untenanted do + post session_path, params: { email_address: identity.email_address } post session_magic_link_url, params: { code: magic_link.code } end From abaed12124467141452636405153de2d4ed6fefb Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Dec 2025 19:17:39 +0100 Subject: [PATCH 52/88] Prohibit access to magic links unless an email address --- app/controllers/concerns/authentication.rb | 9 ++++++--- app/controllers/sessions/magic_links_controller.rb | 7 +++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index fbe2c6b79..f8ee24642 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -6,6 +6,7 @@ module Authentication before_action :require_authentication after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? + helper_method :email_address_pending_authentication etag { Current.identity.id if authenticated? } @@ -108,9 +109,7 @@ module Authentication end def email_address_pending_authentication_matches?(email_address) - pending_email_address = session.fetch(:email_address_pending_authentication, "") - - if ActiveSupport::SecurityUtils.secure_compare(email_address, pending_email_address) + if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "") session.delete(:email_address_pending_authentication) true else @@ -118,6 +117,10 @@ module Authentication end end + def email_address_pending_authentication + session[:email_address_pending_authentication] + end + def redirect_to_session_magic_link(magic_link, return_to: nil) serve_development_magic_link(magic_link) session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 1608e9fbb..c0632407a 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -2,6 +2,7 @@ class Sessions::MagicLinksController < ApplicationController disallow_account_scope require_unauthenticated_access rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" } + before_action :ensure_that_email_address_pending_authentication_exists layout "public" @@ -17,6 +18,12 @@ class Sessions::MagicLinksController < ApplicationController end private + def ensure_that_email_address_pending_authentication_exists + unless email_address_pending_authentication.present? + redirect_to new_session_path, alert: "Enter your email address to sign in." + end + end + def authenticate_with(magic_link) if email_address_pending_authentication_matches?(magic_link.identity.email_address) start_new_session_for magic_link.identity From 8a66369d641be115d7cc0ee8a7032d59776c111c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Thu, 11 Dec 2025 19:18:01 +0100 Subject: [PATCH 53/88] Show the email address you are signing in with --- app/views/sessions/magic_links/show.html.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index 947b3c426..a08372af8 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -9,6 +9,7 @@ <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %> +

<%= email_address_pending_authentication %>

<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large", autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true, autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code], From f193f473764ed7c56178c163d712be6cee7aa27f Mon Sep 17 00:00:00 2001 From: Brian Bailey Date: Thu, 11 Dec 2025 13:50:34 -0500 Subject: [PATCH 54/88] Fix typo in _entropy.html.erb --- app/views/account/settings/_entropy.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/account/settings/_entropy.html.erb b/app/views/account/settings/_entropy.html.erb index 53f5064ce..daac70a4a 100644 --- a/app/views/account/settings/_entropy.html.erb +++ b/app/views/account/settings/_entropy.html.erb @@ -1,6 +1,6 @@

Auto close

-

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for specific period of time. This is the default, global setting — you can override it on each board.

+

Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. This is the default, global setting — you can override it on each board.

<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %> From 9ca5e792f1bc96b23152433325aa010b7aa414d4 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 11 Dec 2025 13:51:18 -0500 Subject: [PATCH 55/88] Add QrCodesController test --- test/controllers/qr_codes_controller_test.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 test/controllers/qr_codes_controller_test.rb diff --git a/test/controllers/qr_codes_controller_test.rb b/test/controllers/qr_codes_controller_test.rb new file mode 100644 index 000000000..3a47240f4 --- /dev/null +++ b/test/controllers/qr_codes_controller_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class QrCodesControllerTest < ActionDispatch::IntegrationTest + test "show" do + join_code = account_join_codes(:"37s") + account = accounts("37s") + url = join_url(code: join_code.code, script_name: account.slug, host: "app.fizzy.do") + signed_token = QrCodeLink.new(url).signed + + get qr_code_path(signed_token) + + assert_response :success + assert_match %r{image/svg\+xml}, response.content_type + assert_includes response.body, " Date: Thu, 11 Dec 2025 13:59:31 -0500 Subject: [PATCH 56/88] =?UTF-8?q?Fix=20duplicate=20word:=20use=20use=20?= =?UTF-8?q?=E2=86=92=20use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/user_mailer/email_change_confirmation.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/user_mailer/email_change_confirmation.html.erb b/app/views/user_mailer/email_change_confirmation.html.erb index 8ab21b43b..30d58983e 100644 --- a/app/views/user_mailer/email_change_confirmation.html.erb +++ b/app/views/user_mailer/email_change_confirmation.html.erb @@ -1,6 +1,6 @@

Confirm your email address change

-<%= link_to "Yes use use this email address", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: "btn" %> +<%= link_to "Yes use this email address", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: "btn" %>

If you didn’t request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.

From cbcd433ad89a58e99f7314af35ee17ebc5f903db Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 11 Dec 2025 13:59:06 -0500 Subject: [PATCH 57/88] =?UTF-8?q?Fix=20typo:=20minues=20=E2=86=92=20minute?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/email_addresses/confirmations/invalid_token.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/email_addresses/confirmations/invalid_token.html.erb b/app/views/users/email_addresses/confirmations/invalid_token.html.erb index ec3950efb..d6c6d8120 100644 --- a/app/views/users/email_addresses/confirmations/invalid_token.html.erb +++ b/app/views/users/email_addresses/confirmations/invalid_token.html.erb @@ -5,7 +5,7 @@ <%= @page_title %>

- That email confirmation link is no longer valid—they expire after 30 minues. You’ll have to try again. + That email confirmation link is no longer valid—they expire after 30 minutes. You’ll have to try again.

<%= link_to "Change my email address", new_user_email_address_path(user_id: @user, script_name: @user.account.slug), class: "btn btn--link center" %> From 271636876958940dbc7a8146a6278ddc2065275b Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Thu, 11 Dec 2025 17:28:22 -0500 Subject: [PATCH 58/88] Remove the rails credentials from .gitattributes because they make historical diffs very slow when searching history with git pickaxe et al --- .gitattributes | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index 8dc432343..31eeee0b6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,5 +5,3 @@ db/schema.rb linguist-generated # Mark any vendored files as having been vendored. vendor/* linguist-vendored -config/credentials/*.yml.enc diff=rails_credentials -config/credentials.yml.enc diff=rails_credentials From eb06884ea7299606bbc1e487b3c8eb4ca33b53cc Mon Sep 17 00:00:00 2001 From: Matthew Kent Date: Thu, 11 Dec 2025 16:19:06 -0800 Subject: [PATCH 59/88] Bump fizzy-saas to pickup another staging change. --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 9153eb725..0a40c4ecd 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: 8f144d20a0f84cfe56e8ad66ed999455744a89d0 + revision: 9c356dfeedbf5309f1a36ee3371f49604c9be188 specs: fizzy-saas (0.1.0) audits1984 From 0dccd7e19c0d8c932c7b5f1cacbe2c3fc6d2f220 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 11 Dec 2025 19:31:20 -0600 Subject: [PATCH 60/88] Move email address into hint line --- app/views/sessions/magic_links/show.html.erb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index a08372af8..26a28da9d 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -9,14 +9,13 @@ <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %> -

<%= email_address_pending_authentication %>

<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large", autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true, autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code], data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %> <% end %> -

The code you receive will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

+

The code sent to <%= email_address_pending_authentication %> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.

<% if Rails.env.development? && flash[:magic_link_code].present? %> From 78fc80cf35d6173d7358d8c51a0261a3947bcdf1 Mon Sep 17 00:00:00 2001 From: Jankees van Woezik Date: Fri, 12 Dec 2025 04:06:03 +0100 Subject: [PATCH 61/88] API: Allow updates to `last_active_at` (#2076) * Allow Card#last_updated_at to be set This is useful when doing an import from another system. I'm currently working on a script to import our Github issues into Fizzy. This is discussed in https://github.com/basecamp/fizzy/pull/2056#discussion_r2609560246 * Add nil fallback and expand test coverage for last_active_at Adds a safety fallback to Time.current if created_at is unexpectedly nil during card creation. Test coverage to verify: * last_active_at defaults to created_at when not provided * last_active_at can be updated via API on existing cards * import workflow where last_active_at is restored after comments * publishing doesn't overwrite explicit last_active_at values --------- Co-authored-by: Jeremy Daer --- app/controllers/cards_controller.rb | 2 +- app/models/card/eventable.rb | 4 +- docs/API.md | 2 + test/controllers/cards_controller_test.rb | 92 ++++++++++++++++++++--- test/models/card/eventable_test.rb | 42 ++++++++++- 5 files changed, 125 insertions(+), 17 deletions(-) diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 8223d3801..83706ddf4 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -61,6 +61,6 @@ class CardsController < ApplicationController end def card_params - params.expect(card: [ :status, :title, :description, :image, :created_at, tag_ids: [] ]) + params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ]) end end diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb index 520fd95d4..94544656d 100644 --- a/app/models/card/eventable.rb +++ b/app/models/card/eventable.rb @@ -4,7 +4,7 @@ module Card::Eventable include ::Eventable included do - before_create { self.last_active_at = Time.current } + before_create { self.last_active_at ||= created_at || Time.current } after_save :track_title_change, if: :saved_change_to_title? end @@ -12,7 +12,7 @@ module Card::Eventable def event_was_created(event) transaction do create_system_comment_for(event) - touch_last_active_at + touch_last_active_at unless was_just_published? end end diff --git a/docs/API.md b/docs/API.md index 318e7a1dd..8f85eec6a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -496,6 +496,7 @@ Creates a new card in a board. | `image` | file | No | Header image for the card | | `tag_ids` | array | No | Array of tag IDs to apply to the card | | `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) | +| `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) | __Request:__ @@ -523,6 +524,7 @@ Updates a card. | `status` | string | No | Card status: `drafted`, `published` | | `image` | file | No | Header image for the card | | `tag_ids` | array | No | Array of tag IDs to apply to the card | +| `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) | __Request:__ diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index a22018a46..635b2d2e0 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -21,8 +21,9 @@ class CardsControllerTest < ActionDispatch::IntegrationTest end card = Card.last - assert card.drafted? assert_redirected_to card + + assert card.drafted? end test "create resumes existing draft if it exists" do @@ -30,9 +31,8 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_no_difference -> { Card.count } do post board_cards_path(boards(:writebook)) + assert_redirected_to draft end - - assert_redirected_to draft end test "show" do @@ -68,6 +68,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest boards(:writebook).update! all_access: false boards(:writebook).accesses.revoke_from users(:kevin) + get card_path(cards(:logo)) assert_response :not_found end @@ -75,6 +76,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest test "admins can see delete button on any card" do get card_path(cards(:logo)) assert_response :success + assert_match "Delete this card", response.body end @@ -83,6 +85,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest get card_path(cards(:logo)) assert_response :success + assert_match "Delete this card", response.body end @@ -91,6 +94,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest get card_path(cards(:logo)) assert_response :success + assert_no_match "Delete this card", response.body end @@ -124,10 +128,9 @@ class CardsControllerTest < ActionDispatch::IntegrationTest test "show card with comment containing malformed remote image attachment" do card = cards(:logo) - card.comments.create!( + card.comments.create! \ creator: users(:kevin), body: '' - ) get card_path(card) assert_response :success @@ -140,6 +143,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest get card_path(card), as: :json assert_response :success + assert_equal card.title, @response.parsed_body["title"] assert_equal 2, @response.parsed_body["steps"].size end @@ -149,12 +153,12 @@ class CardsControllerTest < ActionDispatch::IntegrationTest post board_cards_path(boards(:writebook)), params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } }, as: :json + assert_response :created end - assert_response :created - assert_equal card_path(Card.last, format: :json), @response.headers["Location"] - card = Card.last + assert_equal card_path(card, format: :json), @response.headers["Location"] + assert_equal "My new card", card.title assert_equal "Big if true", card.description.to_plain_text assert_equal [ tags(:mobile), tags(:web) ].sort, card.tags.sort @@ -167,25 +171,91 @@ class CardsControllerTest < ActionDispatch::IntegrationTest post board_cards_path(boards(:writebook)), params: { card: { title: "Backdated card", created_at: custom_time } }, as: :json + assert_response :created end - assert_response :created assert_equal custom_time, Card.last.created_at end + test "create as JSON with custom last_active_at" do + created_time = Time.utc(2024, 1, 15, 10, 30, 0) + last_active_time = Time.utc(2024, 6, 1, 12, 0, 0) + + assert_difference -> { Card.count }, +1 do + post board_cards_path(boards(:writebook)), + params: { card: { title: "Card with activity", created_at: created_time, last_active_at: last_active_time } }, + as: :json + assert_response :created + end + + card = Card.last + assert_equal created_time, card.created_at + assert_equal last_active_time, card.last_active_at + end + + test "create as JSON defaults last_active_at to created_at when not provided" do + created_time = Time.utc(2024, 1, 15, 10, 30, 0) + + assert_difference -> { Card.count }, +1 do + post board_cards_path(boards(:writebook)), + params: { card: { title: "Backdated card without last_active_at", created_at: created_time } }, + as: :json + assert_response :created + end + + card = Card.last + assert_equal created_time, card.created_at + assert_equal created_time, card.last_active_at + end + + test "update as JSON with custom last_active_at" do + card = cards(:logo) + custom_time = Time.utc(2024, 3, 15, 14, 0, 0) + + put card_path(card, format: :json), params: { card: { last_active_at: custom_time } } + + assert_response :success + assert_equal custom_time, card.reload.last_active_at + end + + test "update as JSON can restore last_active_at after comments overwrite it" do + created_time = Time.utc(2024, 1, 15, 10, 30, 0) + last_active_time = Time.utc(2024, 6, 1, 12, 0, 0) + + # Create a card with custom timestamps (simulating import) + post board_cards_path(boards(:writebook)), + params: { card: { title: "Imported card", created_at: created_time, last_active_at: last_active_time } }, + as: :json + assert_response :created + + card = Card.last + + # Adding a comment overwrites last_active_at (this is expected) + card.comments.create!(creator: users(:kevin), body: "Imported comment") + assert_not_equal last_active_time, card.reload.last_active_at + + # After import, restore the correct last_active_at + put card_path(card, format: :json), params: { card: { last_active_at: last_active_time } } + assert_response :success + + assert_equal last_active_time, card.reload.last_active_at + end + test "update as JSON" do card = cards(:logo) - put card_path(card, format: :json), params: { card: { title: "Update test" } } + put card_path(card, format: :json), params: { card: { title: "Update test" } } assert_response :success + assert_equal "Update test", card.reload.title end test "delete as JSON" do card = cards(:logo) - delete card_path(card, format: :json) + delete card_path(card, format: :json) assert_response :no_content + assert_not Card.exists?(card.id) end end diff --git a/test/models/card/eventable_test.rb b/test/models/card/eventable_test.rb index 0c39ffc91..a92d71f6c 100644 --- a/test/models/card/eventable_test.rb +++ b/test/models/card/eventable_test.rb @@ -5,11 +5,47 @@ class Card::EventableTest < ActiveSupport::TestCase Current.session = sessions(:david) end - test "new cards get the current time as the last activity time" do + test "new cards default last_active_at to created_at" do freeze_time - card = boards(:writebook).cards.create!(title: "Some card card", creator: users(:david)) - assert_equal Time.current, card.last_active_at + card = boards(:writebook).cards.create!(title: "Some card", creator: users(:david)) + assert_equal card.created_at, card.last_active_at + end + + test "new cards with custom created_at default last_active_at to that time" do + custom_time = 1.week.ago.change(usec: 0) + + card = boards(:writebook).cards.create!(title: "Backdated card", creator: users(:david), created_at: custom_time) + assert_equal custom_time, card.created_at + assert_equal custom_time, card.last_active_at + end + + test "new cards preserve explicit last_active_at" do + created_time = 2.weeks.ago.change(usec: 0) + last_active_time = 1.week.ago.change(usec: 0) + + card = boards(:writebook).cards.create! \ + title: "Card with explicit timestamps", + creator: users(:david), + created_at: created_time, + last_active_at: last_active_time + + assert_equal created_time, card.created_at + assert_equal last_active_time, card.last_active_at + end + + test "publishing a card does not overwrite last_active_at" do + created_time = 2.weeks.ago.change(usec: 0) + last_active_time = 1.week.ago.change(usec: 0) + + card = boards(:writebook).cards.create! \ + title: "Published card", + creator: users(:david), + status: :published, + created_at: created_time, + last_active_at: last_active_time + + assert_equal last_active_time, card.last_active_at end test "tracking events update the last activity time" do From 2e17b78e08aff01870ab6c778b70b1413bffca82 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 11 Dec 2025 20:26:06 -0800 Subject: [PATCH 62/88] bin/bundle-both (#2100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convenience for bundling both OSS and SaaS in lock-step: ```bash > bin/bundle-both install ▸ OSS: Gemfile bundle install Bundle complete! 45 Gemfile dependencies, 161 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed. ▸ SaaS: Gemfile.saas bundle install Bundle complete! 63 Gemfile dependencies, 187 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed. ``` --- bin/bundle-both | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100755 bin/bundle-both diff --git a/bin/bundle-both b/bin/bundle-both new file mode 100755 index 000000000..73de660a2 --- /dev/null +++ b/bin/bundle-both @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo +gum style --foreground 135 --bold "▸ OSS: Gemfile" +gum style --foreground 240 "bundle $*" +BUNDLE_GEMFILE=Gemfile bundle "$@" + +echo +gum style --foreground 135 --bold "▸ SaaS: Gemfile.saas" +gum style --foreground 240 "bundle $*" +BUNDLE_GEMFILE=Gemfile.saas bundle "$@" From 0833c52aa08cbb86322eeb6f02c1ab76c1d72a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Matos?= Date: Fri, 12 Dec 2025 01:35:38 -0300 Subject: [PATCH 63/88] Refactor: use idiomatic .last instead of .order(:desc).first (#2098) Simplifies the last_event method in ActivitySpike::Detector by using the more idiomatic Rails pattern .order(:created_at).last instead of .order(created_at: :desc).first. Both generate the same SQL query but .last is more readable and conventional in Rails codebases. --- app/models/card/activity_spike/detector.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/card/activity_spike/detector.rb b/app/models/card/activity_spike/detector.rb index b533318e3..1ba44f501 100644 --- a/app/models/card/activity_spike/detector.rb +++ b/app/models/card/activity_spike/detector.rb @@ -51,6 +51,6 @@ class Card::ActivitySpike::Detector end def last_event - card.events.order(created_at: :desc).first + card.events.order(:created_at).last end end From f2a28783822eedc8d96e66051dc18e45e9df95e4 Mon Sep 17 00:00:00 2001 From: Anthony <5428510+anthony-robin@users.noreply.github.com> Date: Fri, 12 Dec 2025 05:36:54 +0100 Subject: [PATCH 64/88] Fix typo in translate property in card columns CSS (#2090) Rename incorrect CSS property from `translaate` to `translate` --- app/assets/stylesheets/card-columns.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 19084f65c..10197aec2 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -764,7 +764,7 @@ block-size: 1px; inline-size: 100%; inset: 50% 0 auto; - translaate: 0 -50%; + translate: 0 -50%; } &:after { From 8f68e13707228d3b6cf5ed8f78119c3e7a677deb Mon Sep 17 00:00:00 2001 From: Javier Valencia Date: Fri, 12 Dec 2025 05:37:28 +0100 Subject: [PATCH 65/88] Fix indentation in multi_db.rb initializer (#2082) --- config/initializers/multi_db.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/initializers/multi_db.rb b/config/initializers/multi_db.rb index f71495004..8253cce89 100644 --- a/config/initializers/multi_db.rb +++ b/config/initializers/multi_db.rb @@ -2,9 +2,9 @@ require "deployment" require_relative "extensions" if ActiveRecord::Base.replica_configured? -Rails.application.configure do - config.active_record.database_selector = { delay: 0.seconds } - config.active_record.database_resolver = Deployment::DatabaseResolver - config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session + Rails.application.configure do + config.active_record.database_selector = { delay: 0.seconds } + config.active_record.database_resolver = Deployment::DatabaseResolver + config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session end end From 2e33262960967e8e795a7784e752776218e31715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Matos?= Date: Fri, 12 Dec 2025 01:40:31 -0300 Subject: [PATCH 66/88] Refactor: Use Rails range syntax in ActivitySpike query (#2080) Replace SQL string syntax with Rails range syntax for date filtering in the ActivitySpike::Detector. This improves code readability and follows Rails idioms. Changed from: .where("created_at >= ?", recent_period.seconds.ago) To: .where(created_at: recent_period.seconds.ago..) This modernizes the codebase while maintaining the same functionality. --- app/models/card/activity_spike/detector.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/card/activity_spike/detector.rb b/app/models/card/activity_spike/detector.rb index 1ba44f501..143561c39 100644 --- a/app/models/card/activity_spike/detector.rb +++ b/app/models/card/activity_spike/detector.rb @@ -27,7 +27,7 @@ class Card::ActivitySpike::Detector def multiple_people_commented?(minimum_comments: 3, minimum_participants: 2) card.comments - .where("created_at >= ?", recent_period.seconds.ago) + .where(created_at: recent_period.seconds.ago..) .group(:card_id) .having("COUNT(*) >= ?", minimum_comments) .having("COUNT(DISTINCT creator_id) >= ?", minimum_participants) From 586015c3f9ca67dad78f2e21f7c111b7c16c59d5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 11 Dec 2025 21:32:34 -0800 Subject: [PATCH 67/88] Bundle drift detection and correction (#2101) Gemfile.saas evals Gemfile, so shared gems should have identical versions in both lockfiles. This adds bin/bundle-drift to detect and fix drift: * `bin/bundle-drift check` compares shared gem versions * `bin/bundle-drift correct` seeds Gemfile.lock from Gemfile.saas.lock and re-locks, letting Bundler prune SaaS-only gems while preserving shared versions Adds drift check to bin/ci and GitHub CI. Corrects existing drift. --- .github/workflows/ci-checks.yml | 14 ++++ Gemfile.lock | 65 ++++++---------- bin/bundle-drift | 128 ++++++++++++++++++++++++++++++++ config/ci.rb | 1 + 4 files changed, 165 insertions(+), 43 deletions(-) create mode 100755 bin/bundle-drift diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index d097b9590..773674233 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -7,6 +7,20 @@ permissions: contents: read jobs: + gemfile-drift: + name: Gemfile drift + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Check for lockfile drift + run: bin/bundle-drift check + security: name: Security runs-on: ubuntu-latest diff --git a/Gemfile.lock b/Gemfile.lock index b0ef0a648..3a9430a61 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -117,8 +117,8 @@ GEM specs: action_text-trix (2.1.15) railties - addressable (2.8.8) - public_suffix (>= 2.0.2, < 8.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) @@ -151,8 +151,8 @@ GEM brakeman (7.1.1) racc builder (3.3.0) - bundler-audit (0.9.3) - bundler (>= 1.2.0) + bundler-audit (0.9.2) + bundler (>= 1.2.0, < 3) thor (~> 1.0) capybara (3.40.0) addressable @@ -172,7 +172,7 @@ GEM bigdecimal rexml crass (1.0.6) - date (3.5.1) + date (3.5.0) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) @@ -185,15 +185,11 @@ GEM tzinfo faker (3.5.2) i18n (>= 1.8.11, < 2) - ffi (1.17.2) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) - ffi (1.17.2-x86-linux-gnu) - ffi (1.17.2-x86-linux-musl) - ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) fugit (1.12.1) @@ -223,7 +219,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.18.0) + json (2.17.1) jwt (3.1.2) base64 kamal (2.9.0) @@ -260,7 +256,6 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - mini_portile2 (2.8.9) minitest (5.26.2) mission_control-jobs (1.1.0) actioncable (>= 7.1) @@ -272,7 +267,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.1) + mittens (0.3.0) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -293,9 +288,6 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.5) - nokogiri (1.18.10) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-aarch64-linux-musl) @@ -306,8 +298,6 @@ GEM racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-darwin) - racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) @@ -329,10 +319,10 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.3.0) + psych (5.2.6) date stringio - public_suffix (7.0.0) + public_suffix (6.0.2) puma (7.1.0) nio4r (~> 2.0) raabro (1.4.0) @@ -345,7 +335,7 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.3.1) + rackup (2.2.1) rack (>= 3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) @@ -356,7 +346,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (6.17.0) + rdoc (6.16.1) erb psych (>= 4.0.0) tsort @@ -366,10 +356,10 @@ GEM io-console (~> 0.5) rexml (3.4.4) rouge (4.6.1) - rqrcode (3.1.1) + rqrcode (3.1.0) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.1) + rqrcode_core (2.0.0) rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -388,7 +378,7 @@ GEM lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.47.1, < 2.0) - rubocop-rails (2.34.1) + rubocop-rails (2.34.0) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -427,18 +417,13 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.8.1) - mini_portile2 (~> 2.8.0) - sqlite3 (2.8.1-aarch64-linux-gnu) - sqlite3 (2.8.1-aarch64-linux-musl) - sqlite3 (2.8.1-arm-linux-gnu) - sqlite3 (2.8.1-arm-linux-musl) - sqlite3 (2.8.1-arm64-darwin) - sqlite3 (2.8.1-x86-linux-gnu) - sqlite3 (2.8.1-x86-linux-musl) - sqlite3 (2.8.1-x86_64-darwin) - sqlite3 (2.8.1-x86_64-linux-gnu) - sqlite3 (2.8.1-x86_64-linux-musl) + sqlite3 (2.8.0-aarch64-linux-gnu) + sqlite3 (2.8.0-aarch64-linux-musl) + sqlite3 (2.8.0-arm-linux-gnu) + sqlite3 (2.8.0-arm-linux-musl) + sqlite3 (2.8.0-arm64-darwin) + sqlite3 (2.8.0-x86_64-linux-gnu) + sqlite3 (2.8.0-x86_64-linux-musl) sshkit (1.24.0) base64 logger @@ -453,9 +438,8 @@ GEM thruster (0.1.16) thruster (0.1.16-aarch64-linux) thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-darwin) thruster (0.1.16-x86_64-linux) - timeout (0.5.0) + timeout (0.4.4) trilogy (2.9.0) tsort (0.2.0) turbo-rails (2.0.20) @@ -497,11 +481,6 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin - arm64-linux - ruby - x86-linux-gnu - x86-linux-musl - x86_64-darwin x86_64-linux x86_64-linux-gnu x86_64-linux-musl diff --git a/bin/bundle-drift b/bin/bundle-drift new file mode 100755 index 000000000..64fd23292 --- /dev/null +++ b/bin/bundle-drift @@ -0,0 +1,128 @@ +#!/usr/bin/env ruby +# Checks that Gemfile.lock and Gemfile.saas.lock are in sync for shared dependencies. +# Since Gemfile.saas evals Gemfile, shared gems should have identical versions. +# +# Usage: +# bin/bundle-drift [check] # check for drift (default subcommand) +# bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative) +require "bundler" +require "fileutils" + +GEMFILE_LOCK = "Gemfile.lock" +GEMFILE_SAAS_LOCK = "Gemfile.saas.lock" + +class GemfileDriftChecker + def initialize + @oss_lockfile = parse_lockfile(GEMFILE_LOCK) + @saas_lockfile = parse_lockfile(GEMFILE_SAAS_LOCK) + end + + def check + find_drift.tap do + report it + end + end + + private + def parse_lockfile(path) + Bundler::LockfileParser.new(File.read(path)) + end + + def find_drift + oss_specs, saas_specs = specs_hash(@oss_lockfile), specs_hash(@saas_lockfile) + shared_gems = oss_specs.keys & saas_specs.keys + + shared_gems.filter_map do |name| + oss_version, saas_version = oss_specs[name], saas_specs[name] + if oss_version != saas_version + { name: name, oss: oss_version, saas: saas_version } + end + end.sort_by { |d| d[:name] } + end + + def specs_hash(lockfile) + lockfile.specs.to_h { |spec| [ spec.name, spec.version.to_s ] } + end + + def report(drift) + if drift.empty? + puts "✓ Gemfile.lock and Gemfile.saas.lock are in sync" + else + puts "✗ Gemfile lock files have drifted!\n\n" + + name_width = [ drift.map { |d| d[:name].length }.max, "Gem".length ].max + oss_width = [ drift.map { |d| d[:oss].length }.max, "Gemfile.lock".length ].max + saas_width = [ drift.map { |d| d[:saas].length }.max, "Gemfile.saas.lock".length ].max + + puts " #{"Gem".ljust(name_width)} #{"Gemfile.lock".ljust(oss_width)} Gemfile.saas.lock" + puts " #{"-" * name_width} #{"-" * oss_width} #{"-" * saas_width}" + + drift.each do |d| + puts " #{d[:name].ljust(name_width)} #{d[:oss].ljust(oss_width)} #{d[:saas]}" + end + + puts "\nRun 'bin/bundle-drift correct' to restore alignment." + end + end +end + +class GemfileDriftCorrector + def correct + drift = GemfileDriftChecker.new.check + return puts "\nNothing to correct." if drift.empty? + + puts "\nRestoring alignment (Gemfile.saas.lock is authoritative)...\n\n" + + # Save original for diff + original_content = File.read(GEMFILE_LOCK) + + # Seed Gemfile.lock with Gemfile.saas.lock - Bundler will use these as version hints + FileUtils.cp(GEMFILE_SAAS_LOCK, GEMFILE_LOCK) + + # Re-lock: Bundler prunes SaaS-only gems while preserving shared versions + puts "▸ Re-locking Gemfile (seeded from Gemfile.saas.lock)" + unless system("BUNDLE_GEMFILE=Gemfile bundle lock") + File.write(GEMFILE_LOCK, original_content) + abort("Failed to lock Gemfile. Restored original.") + end + + puts "\n▸ Verifying alignment" + new_drift = GemfileDriftChecker.new.check + + if new_drift.empty? + puts "\n✓ Lock files are now in sync" + show_diff(original_content, File.read(GEMFILE_LOCK)) + else + puts "\n✗ Lock files still have drift after correction." + puts " Bundler couldn't resolve to matching versions." + puts " Restoring original Gemfile.lock." + File.write(GEMFILE_LOCK, original_content) + exit 1 + end + end + + private + def show_diff(original, corrected) + require "tempfile" + + Tempfile.create("gemfile-lock-original") do |f| + f.write(original) + f.flush + + diff = `diff -u #{f.path} #{GEMFILE_LOCK} 2>/dev/null` + unless diff.empty? + puts "\nChanges made to Gemfile.lock:" + puts diff + end + end + end +end + +case command = ARGV[0] || "check" +when "check" + exit 1 unless GemfileDriftChecker.new.check.empty? +when "correct" + GemfileDriftCorrector.new.correct +else + abort "Usage: bin/bundle-drift [check|correct]" +end diff --git a/config/ci.rb b/config/ci.rb index a99248a93..6a8cdca20 100644 --- a/config/ci.rb +++ b/config/ci.rb @@ -11,6 +11,7 @@ CI.run do step "Style: Ruby", "bin/rubocop" + step "Gemfile: Drift check", "bin/bundle-drift check" step "Security: Gem audit", "bin/bundler-audit check --update" step "Security: Importmap audit", "bin/importmap audit" step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" From f4ef9c95808c16326e415324d72854eefef23e1d Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Dec 2025 07:16:07 +0100 Subject: [PATCH 68/88] Test that you can't go to the magic link screen without an email --- test/controllers/sessions/magic_links_controller_test.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb index f2316ed51..5a4a48e51 100644 --- a/test/controllers/sessions/magic_links_controller_test.rb +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -5,6 +5,14 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest untenanted do get session_magic_link_url + assert_response :redirect, "Without an email address pending authentication, should redirect" + assert_redirected_to new_session_path + end + + untenanted do + post session_path, params: { email_address: "test@example.com" } + get session_magic_link_url + assert_response :success end end From a4d11e169fb8130fdd099533c801f90f9be053d3 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Dec 2025 07:45:23 +0100 Subject: [PATCH 69/88] Replace custom code generator with Base32 --- app/models/magic_link/code.rb | 15 +++++++-------- test/models/magic_link/code_test.rb | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/models/magic_link/code.rb b/app/models/magic_link/code.rb index 2bcc0c4e6..0a7d97e59 100644 --- a/app/models/magic_link/code.rb +++ b/app/models/magic_link/code.rb @@ -1,18 +1,17 @@ module MagicLink::Code - CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze class << self def generate(length) - Array.new(length) { CODE_ALPHABET[SecureRandom.random_number(CODE_ALPHABET.length)] }.join + SecureRandom.base32(length) end def sanitize(code) - return nil if code.blank? - - normalize_code(code) - .then { apply_substitutions(_1) } - .then { remove_invalid_characters(_1) } + if code.present? + normalize_code(code) + .then { apply_substitutions(it) } + .then { remove_invalid_characters(it) } + end end private @@ -25,7 +24,7 @@ module MagicLink::Code end def remove_invalid_characters(code) - code.gsub(/[^#{CODE_ALPHABET.join}]/, "") + code.gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, "") end end end diff --git a/test/models/magic_link/code_test.rb b/test/models/magic_link/code_test.rb index 685f26d69..2382b5494 100644 --- a/test/models/magic_link/code_test.rb +++ b/test/models/magic_link/code_test.rb @@ -5,7 +5,7 @@ class MagicLink::CodeTest < ActiveSupport::TestCase code = MagicLink::Code.generate(6) assert_equal 6, code.length - assert_match(/\A[#{MagicLink::Code::CODE_ALPHABET.join}]+\z/, code) + assert_match(/\A[#{SecureRandom::BASE32_ALPHABET.join}]+\z/, code) end test "sanitize" do From 8d32a322e930ad4d89ae2f83313342b3fec667d9 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Fri, 12 Dec 2025 08:58:52 +0000 Subject: [PATCH 70/88] Remove redundant include This is no longer needed since af0e2488 removed the streaming of avatar images. --- app/controllers/users/avatars_controller.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index d85e5f869..420dbe5b6 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -1,6 +1,4 @@ class Users::AvatarsController < ApplicationController - include ActiveStorage::Streaming - allow_unauthenticated_access only: :show before_action :set_user From c8a5d01771a284a595cfdc46550fb1e2325e5915 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 12 Dec 2025 09:59:42 +0100 Subject: [PATCH 71/88] Wrap join code redemption in a lock --- app/models/account/join_code.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb index 79160cbaa..6438d3ab8 100644 --- a/app/models/account/join_code.rb +++ b/app/models/account/join_code.rb @@ -11,8 +11,8 @@ class Account::JoinCode < ApplicationRecord before_create :generate_code, if: -> { code.blank? } def redeem_if(&block) - transaction do - increment!(:usage_count) if block.call(account) + with_lock do + increment!(:usage_count) if active? && block.call(account) end end From 87ad234f329dff421c2fda6b55686e1701a50127 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Fri, 12 Dec 2025 14:17:58 +0000 Subject: [PATCH 72/88] Apply theme preference before body To avoid any visual flashes of the old theme before the Stimulus controllers load, we can apply the saved theme from `` whenever there is a full page load. This applies to both the regular and public views, as we're doing it in the shared head partial. --- app/views/layouts/_theme_preference.html.erb | 6 ++++++ app/views/layouts/shared/_head.html.erb | 1 + 2 files changed, 7 insertions(+) create mode 100644 app/views/layouts/_theme_preference.html.erb diff --git a/app/views/layouts/_theme_preference.html.erb b/app/views/layouts/_theme_preference.html.erb new file mode 100644 index 000000000..0c33750bd --- /dev/null +++ b/app/views/layouts/_theme_preference.html.erb @@ -0,0 +1,6 @@ +<%= javascript_tag nonce: true do %> + const theme = localStorage.getItem("theme") + if (theme && theme !== "auto") { + document.documentElement.dataset.theme = theme + } +<% end %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index ecee1abc0..9ef153df2 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -15,6 +15,7 @@ <% turbo_refreshes_with method: :morph, scroll: :preserve %> + <%= render "layouts/theme_preference" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> From 430caed61e3b091ef2fc44e7ce7136b5db858837 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 11:52:30 -0500 Subject: [PATCH 73/88] saas: Bump queenbee gem for new staging location --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 0a40c4ecd..eaae95e75 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -50,7 +50,7 @@ GIT GIT remote: https://github.com/basecamp/queenbee-plugin - revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2 + revision: 14312a940471e20617b38cdec7c092a01567d18b specs: queenbee (3.2.0) activeresource From 4218f2e161c22d4745d7b47f8349262bac923c35 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Thu, 11 Dec 2025 15:37:47 +0000 Subject: [PATCH 74/88] Document Docker image deployment Also split the README a bit, to move the details under /docs. --- README.md | 190 ++------------------------------------ config/deploy.yml | 2 +- docs/development.md | 80 ++++++++++++++++ docs/docker-deployment.md | 166 +++++++++++++++++++++++++++++++++ docs/kamal-deployment.md | 111 ++++++++++++++++++++++ 5 files changed, 366 insertions(+), 183 deletions(-) create mode 100644 docs/development.md create mode 100644 docs/docker-deployment.md create mode 100644 docs/kamal-deployment.md diff --git a/README.md b/README.md index 5ea839008..867c2dba7 100644 --- a/README.md +++ b/README.md @@ -2,202 +2,28 @@ This is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com). -## Deploying Fizzy -If you'd like to run Fizzy on your own server, we recommend deploying it with [Kamal](https://kamal-deploy.org/). -Kamal makes it easier to set up a bare server, copy the application to it, and manage the configuration settings that it uses. +## Running your own Fizzy instance -(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).) +If you want to run your own Fizzy instance, but don't need to change its code, you can use our pre-built Docker image. +You'll need access to a server on which you can run Docker, and you'll need to configure some options to customize your installation. -This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it. +You can find the details of how to do a Docker-based deployment in our [Docker deployment guide](docs/docker-deployment.md). -The steps to configure your very own Fizzy are: +If you want more flexibility to customize your Fizzy installation by changing its code, and deploy those changes to your server, then we recommend you deploy Fizzy with Kamal. You can find a complete walkthrough of doing that in our [Kamal deployment guide](docs/kamal-deployment.md). -1. Fork the repo -2. Edit few things in config/deploy.yml and .kamal/secrets -3. Run `kamal setup` to do your first deploy. - -We'll go through each of these in turn. - -### Fork the repo - -To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo. -That allows you to commit your changes, and track them over time. -You can always re-sync your fork to pick up new changes from the main repo over time. - -Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed. - -### Editing the configuration - -The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance. -To get started, the parts you need to change are all in the "About your deployment" section. -We've added comments to that file to highlight what each setting needs to be, but the main ones are: - -- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`. -- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here. -- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. -- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. -- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this. -- `env/clear/MULTI_TENANT`: Set to `false` to enable single-tenant mode (disable multi-account signups). - - -Fizzy also requires a few environment variables to be set up, some of which contain secrets. -The simplest way to do this is to put them in a file called `.kamal/secrets`. -Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally. - -If you use a password manager like 1Password, you can also opt to keep your secrets there instead. -Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that. - -To store your secrets, create the file `.kamal/secrets` and enter something like the following: - -```ini -SECRET_KEY_BASE=12345 -VAPID_PUBLIC_KEY=something -VAPID_PRIVATE_KEY=somethingelse -SMTP_USERNAME=email-provider-username -SMTP_PASSWORD=email-provider-password -``` - -The values you enter here will be specific to you, and you can get or create them as follows: - -- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this. -- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use. -- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with: - - ```sh - bin/rails c - ``` - - And then run the following to create a new pair of keys: - - ```ruby - vapid_key = WebPush.generate_key - - puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" - puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" - ``` - -Once you've made all those changes, commit them to your fork so they're saved. - -### Deploy Fizzy! - -You can now do your first deploy by running: - -```sh -bin/kamal setup -``` - -This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running. - -After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run: - -```sh -bin/kamal deploy -``` - -## File storage (Active Storage) - -Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`. - -To use the included `s3` service, set: - -- `ACTIVE_STORAGE_SERVICE=s3` -- `S3_ACCESS_KEY_ID` -- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`) -- `S3_REGION` (defaults to `us-east-1`) -- `S3_SECRET_ACCESS_KEY` - -Optional for S3-compatible endpoints: - -- `S3_ENDPOINT` -- `S3_FORCE_PATH_STYLE=true` -- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`) -- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`) ## Development -### Setting up +You are welcome -- and encouraged -- to modify Fizzy to your liking. +Please see our [Development guide](docs/development.md) for how to get Fizzy set up for local development. -First, get everything installed and configured with: - -```sh -bin/setup -bin/setup --reset # Reset the database and seed it -``` - -And then run the development server: - -```sh -bin/dev -``` - -You'll be able to access the app in development at http://fizzy.localhost:3006. - -To login, enter `david@example.com` and grab the verification code from the browser console to sign in. - -### Web Push Notifications - -Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables: - -- `VAPID_PRIVATE_KEY` -- `VAPID_PUBLIC_KEY` - -Generate them with the `web-push` gem: - -```ruby -vapid_key = WebPush.generate_key - -puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" -puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" -``` - -### Running tests - -For fast feedback loops, unit tests can be run with: - -```sh -bin/rails test -``` - -The full continuous integration tests can be run with: - -```sh -bin/ci -``` - -### Database configuration - -Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL: - -```sh -DATABASE_ADAPTER=mysql bin/setup --reset -DATABASE_ADAPTER=mysql bin/ci -``` - -The remote CI pipeline will run tests against both SQLite and MySQL. - -### Outbound Emails - -You can view email previews at http://fizzy.localhost:3006/rails/mailers. - -You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with: - -```sh -bin/rails dev:email -``` - -Under the hood, this will create or remove `tmp/email-dev.txt`. - -## SaaS gem - -37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup. - -This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure. ## Contributing We welcome contributions! Please read our [style guide](STYLE.md) before submitting code. + ## License Fizzy is released under the [O'Saasy License](LICENSE.md). diff --git a/config/deploy.yml b/config/deploy.yml index 73c9f57ae..5550c317e 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -30,7 +30,7 @@ env: clear: MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email - MULTI_TENANT: false # Set to false to enable single-tenant mode + MULTI_TENANT: false # Set to true to allow multiple accounts to sign up SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 000000000..e253e430b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,80 @@ +## Development + +### Setting up + +First, get everything installed and configured with: + +```sh +bin/setup +bin/setup --reset # Reset the database and seed it +``` + +And then run the development server: + +```sh +bin/dev +``` + +You'll be able to access the app in development at http://fizzy.localhost:3006. + +To login, enter `david@example.com` and grab the verification code from the browser console to sign in. + +### Web Push Notifications + +Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables: + +- `VAPID_PRIVATE_KEY` +- `VAPID_PUBLIC_KEY` + +Generate them with the `web-push` gem: + +```ruby +vapid_key = WebPush.generate_key + +puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" +puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" +``` + +### Running tests + +For fast feedback loops, unit tests can be run with: + +```sh +bin/rails test +``` + +The full continuous integration tests can be run with: + +```sh +bin/ci +``` + +### Database configuration + +Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL: + +```sh +DATABASE_ADAPTER=mysql bin/setup --reset +DATABASE_ADAPTER=mysql bin/ci +``` + +The remote CI pipeline will run tests against both SQLite and MySQL. + +### Outbound Emails + +You can view email previews at http://fizzy.localhost:3006/rails/mailers. + +You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with: + +```sh +bin/rails dev:email +``` + +Under the hood, this will create or remove `tmp/email-dev.txt`. + +## SaaS gem + +37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup. + +This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure. + diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md new file mode 100644 index 000000000..15ec58367 --- /dev/null +++ b/docs/docker-deployment.md @@ -0,0 +1,166 @@ +## Deploying with Docker + +We provide pre-built Docker images that can be used to run Fizzy on your own server. + +If you don't need to change the source code, and just want the out-of-the-box Fizzy experience, this can be a great way to get started. + +You'll find the latest version of Fizzy's Docker image at `ghcr.io/basecamp/fizzy:main`. +To run it you'll need three things: a machine that runs Docker; a mounted volume (so that your database is stored somewhere that is kept around between restarts); and some environment variables for configuration. + +### Mounting a storage volume + +The standard Fizzy setup keeps all of its storage inside the path `/rails/storage`. +By default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location. + +The simplest way to do this is with the `--volume` flag with `docker run`. For example: + +```sh +docker run --volume fizzy:/rails/storage ghcr.io/basecamp/fizzy:main +``` + +That will create a named volume (called `fizzy`) and mount it into the correct path. +Docker will manage where that volume is actually stored on your server. + +You can also specify the data location yourself, mount a network drive, and more. +Check the Docker documentation to find out more about what's available. + +### Configuring with environment variables + +To configure your Fizzy installation, you can use environment variables. +Fizzy has several of them. +Many of these are optional, but at a minimum you'll want to configure your secret key, your SSL domain, and your SMTP email settings. + +#### Secret Key Base + +Various features inside Fizzy rely on cryptography to work (such as secure links). +To set this up, you need to provide a secret value that will be used as the basis of those secrets. +This value can be anything, but it should be unguessable, and specific to your instance. + +You can use any long random string for this, or you can have the Fizzy codebase generate one for you by running: + +```sh +bin/rails secret +``` + +Once you have one, set it in the `SECRET_KEY_BASE` environment variable: + +```sh +docker run --environment SECRET_KEY_BASE=abcdefabcdef ... +``` + +#### SSL + +If you want the Fizzy container to handle its own SSL automatically, you just need to specify the domain name that you're running it on. +You can do that with the `TLS_DOMAIN` environment variable. +Note that if you're using SSL, you'll want to allow traffic on ports 80 and 443. +So if you were running on `fizzy.example.com` you could enable SSL like this: + +```sh +docker run --publish 80:80 --publish 443:443 --environment TLS_DOMAIN=fizzy.example.com ... +``` + +If you are terminating SSL in some other proxy in front of Fizzy, then you don't need to set `TLS_DOMAIN`, and can just publish port 80: +```sh +docker run --publish 80:80 ... +``` + +If you aren't using SSL at all (for example, if you want to run it locally on your laptop) then you should specify `DISABLE_SSL=true` instead: + +```sh +docker run --publish 80:80 --environment DISABLE_SSL=true ... +``` + +#### SMTP Email + +Fizzy needs to be able to send email for its sign up/sign in flow, and for its regular summary emails. +The easiest way to set this up is to use a 3rd-party email provider (such as Postmark, Sendgrid, and so on). +You can then plug all your SMTP settings from that provider into Fizzy via the following environment variables: + +- `MAILER_FROM_ADDRESS` - the "from" address that Fizzy should use to send email +- `SMTP_ADDRESS` - the address of the SMTP server you'll send through +- `SMTP_PORT` - the port number you use to connect to that SMTP server (the default is 587) +- `SMTP_USERNAME`/`SMTP_PASSWORD` - the credentials for logging in to the SMTP server + +Less commonly, you might also need to set some of the following: + +- `SMTP_DOMAIN` - the domain name advertised to the server when connecting +- `SMTP_AUTHENTICATION` - if you need an authentication method other than the default `plain` +- `SMTP_ENABLE_STARTTLS_AUTO` - if you need to disable TLS on the SMTP connection + +You can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration). + +#### VAPID keys + +Fizzy can also send Web Push notifications. +To do this it needs a VAPID key pair. + +You can create your own keys by starting a development console with: + +```sh +bin/rails c +``` + +And then run the following to create the keypair: + +```ruby +vapid_key = WebPush.generate_key + +puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" +puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" +``` + +Set those in the `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` environment variables. + +#### S3 storage (optional) + +If you're rather that uploaded files were stored in an S3 bucket, rather than your mounted volume, you can set that up. + +First set `ACTIVE_STORAGE_SERVICE` to `s3`. +Then set the following as appropriate for your S3 bucket: + +- `S3_BUCKET` +- `S3_REGION` +- `S3_ACCESS_KEY_ID` +- `S3_SECRET_ACCESS_KEY` + +If you're using a provider other than AWS, you will also need some of the following: + +- `S3_ENDPOINT` +- `S3_FORCE_PATH_STYLE` +- `S3_REQUEST_CHECKSUM_CALCULATION` +- `S3_RESPONSE_CHECKSUM_VALIDATION` + +#### Multi-tenant mode + +By default, when you run the Fizzy Docker image you'll be limited to creating a single account (although that account can have as many users as you like). +This is for convenience: typically when you self-host you'll be running a single account, so in this mode new account signups are automatically disabled as soon as you've created your first account. + +If you do want to allow multiple accounts to be created in your instance, set `MULTI_TENANT=true` + +## Example + +Here's an example of a `docker-compose.yml` that you could use to run Fizzy via `docker compose up` + +```yaml +services: + web: + image: ghcr.io/basecamp/fizzy:main + restart: unless-stopped + ports: + - "80:80" + - "443:443" + environment: + - SECRET_KEY_BASE=abcdefabcdef + - TLS_DOMAIN=fizzy.example.com + - MAILER_FROM_ADDRESS=fizzy@example.com + - SMTP_ADDRESS=mail.example.com + - SMTP_USERNAME=user + - SMTP_PASSWORD=pass + - VAPID_PRIVATE_KEY=myvapidprivatekey + - VAPID_PUBLIC_KEY=myvapidpublickey + volumes: + - fizzy:/rails/storage + +volumes: + fizzy: +``` diff --git a/docs/kamal-deployment.md b/docs/kamal-deployment.md new file mode 100644 index 000000000..5da8f37f0 --- /dev/null +++ b/docs/kamal-deployment.md @@ -0,0 +1,111 @@ +## Deploying Fizzy with Kamal + +If you'd like to run Fizzy on your own server while having the freedom to easily make changes to its code, we recommend deploying it with [Kamal](https://kamal-deploy.org/). +Kamal makes it easy to set up a bare server, copy the application to it, and manage the configuration settings that it uses. + +(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).) + +This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it. + +The steps to configure your very own Fizzy are: + +1. Fork the repo +2. Edit few things in config/deploy.yml and .kamal/secrets +3. Run `kamal setup` to do your first deploy. + +We'll go through each of these in turn. + +### Fork the repo + +To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo. +That allows you to commit your changes, and track them over time. +You can always re-sync your fork to pick up new changes from the main repo over time. + +Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed. + +### Editing the configuration + +The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance. +To get started, the parts you need to change are all in the "About your deployment" section. +We've added comments to that file to highlight what each setting needs to be, but the main ones are: + +- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`. +- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here. +- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. +- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. +- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this. +- `env/clear/MULTI_TENANT`: Set to `true` if you want to allow multiple accounts to sign up on your server (by default, Fizzy will allow you to create a single account). + +Fizzy also requires a few environment variables to be set up, some of which contain secrets. +The simplest way to do this is to put them in a file called `.kamal/secrets`. +Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally. + +If you use a password manager like 1Password, you can also opt to keep your secrets there instead. +Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that. + +To store your secrets, create the file `.kamal/secrets` and enter something like the following: + +```ini +SECRET_KEY_BASE=12345 +VAPID_PUBLIC_KEY=something +VAPID_PRIVATE_KEY=somethingelse +SMTP_USERNAME=email-provider-username +SMTP_PASSWORD=email-provider-password +``` + +The values you enter here will be specific to you, and you can get or create them as follows: + +- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this. +- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use. +- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with: + + ```sh + bin/rails c + ``` + + And then run the following to create a new pair of keys: + + ```ruby + vapid_key = WebPush.generate_key + + puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" + puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" + ``` + +Once you've made all those changes, commit them to your fork so they're saved. + +### Deploy Fizzy! + +You can now do your first deploy by running: + +```sh +bin/kamal setup +``` + +This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running. + +After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run: + +```sh +bin/kamal deploy +``` + +### Configuring file storage (Active Storage) + +Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`. + +To use the included `s3` service, set: + +- `ACTIVE_STORAGE_SERVICE=s3` +- `S3_ACCESS_KEY_ID` +- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`) +- `S3_REGION` (defaults to `us-east-1`) +- `S3_SECRET_ACCESS_KEY` + +Optional for S3-compatible endpoints: + +- `S3_ENDPOINT` +- `S3_FORCE_PATH_STYLE=true` +- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`) +- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`) + From 7f5fa6d7153f677587d9be4a3a51b28664761ec8 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Fri, 12 Dec 2025 18:31:47 +0100 Subject: [PATCH 75/88] Use Sec-Fetch-Site exclusively for CSRF protection And close the gap with JSON requests, which shouldn't be allowed if Sec-Fetch-Site is 'cross-site' or 'none', only if it's empty as this wouldn't be coming from a browser. --- .../concerns/request_forgery_protection.rb | 11 +++- .../request_forgery_protection_test.rb | 57 ++++++++++--------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/app/controllers/concerns/request_forgery_protection.rb b/app/controllers/concerns/request_forgery_protection.rb index 95fa12892..c8481beb3 100644 --- a/app/controllers/concerns/request_forgery_protection.rb +++ b/app/controllers/concerns/request_forgery_protection.rb @@ -12,16 +12,21 @@ module RequestForgeryProtection end def verified_request? - super || safe_fetch_site? || request.format.json? + request.get? || request.head? || !protect_against_forgery? || + (valid_request_origin? && safe_fetch_site?) end SAFE_FETCH_SITES = %w[ same-origin same-site ] def safe_fetch_site? - SAFE_FETCH_SITES.include?(sec_fetch_site_value) + SAFE_FETCH_SITES.include?(sec_fetch_site_value) || (sec_fetch_site_value.nil? && api_request?) + end + + def api_request? + request.format.json? end def sec_fetch_site_value - request.headers["Sec-Fetch-Site"].to_s.downcase + request.headers["Sec-Fetch-Site"].to_s.downcase.presence end end diff --git a/test/controllers/concerns/request_forgery_protection_test.rb b/test/controllers/concerns/request_forgery_protection_test.rb index 3fd250de4..233c1e361 100644 --- a/test/controllers/concerns/request_forgery_protection_test.rb +++ b/test/controllers/concerns/request_forgery_protection_test.rb @@ -12,17 +12,17 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest ActionController::Base.allow_forgery_protection = @original_allow_forgery_protection end - test "succeeds when CSRF token is valid" do - assert_difference -> { Board.count }, +1 do + test "fails if Sec-Fetch-Site is cross-site" do + assert_no_difference -> { Board.count } do post boards_path, - params: { board: { name: "Test Board" }, authenticity_token: csrf_token }, + params: { board: { name: "Test Board" } }, headers: { "Sec-Fetch-Site" => "cross-site" } end - assert_response :redirect + assert_response :unprocessable_entity end - test "succeeds with same-origin Sec-Fetch-Site even without CSRF token" do + test "succeeds with same-origin Sec-Fetch-Site" do assert_difference -> { Board.count }, +1 do post boards_path, params: { board: { name: "Test Board" } }, @@ -32,7 +32,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest assert_response :redirect end - test "succeeds with same-site Sec-Fetch-Site even without CSRF token" do + test "succeeds with same-site Sec-Fetch-Site" do assert_difference -> { Board.count }, +1 do post boards_path, params: { board: { name: "Test Board" } }, @@ -42,17 +42,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest assert_response :redirect end - test "fails with cross-site Sec-Fetch-Site and no CSRF token" do - assert_no_difference -> { Board.count } do - post boards_path, - params: { board: { name: "Test Board" } }, - headers: { "Sec-Fetch-Site" => "cross-site" } - end - - assert_response :unprocessable_entity - end - - test "fails with none Sec-Fetch-Site and no CSRF token" do + test "fails with none Sec-Fetch-Site" do assert_no_difference -> { Board.count } do post boards_path, params: { board: { name: "Test Board" } }, @@ -62,7 +52,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity end - test "fails when Sec-Fetch-Site header is missing and no CSRF token" do + test "fails when Sec-Fetch-Site header is missing" do assert_no_difference -> { Board.count } do post boards_path, params: { board: { name: "Test Board" } } end @@ -70,16 +60,6 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity end - test "fails with invalid CSRF token and cross-site Sec-Fetch-Site" do - assert_no_difference -> { Board.count } do - post boards_path, - params: { board: { name: "Test Board" }, authenticity_token: "invalid-token" }, - headers: { "Sec-Fetch-Site" => "cross-site" } - end - - assert_response :unprocessable_entity - end - test "GET requests succeed regardless of Sec-Fetch-Site header" do get board_path(boards(:writebook)), headers: { "Sec-Fetch-Site" => "cross-site" } @@ -102,6 +82,27 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest assert_includes response.headers["Vary"], "Sec-Fetch-Site" end + test "JSON request succeeds with missing Sec-Fetch-Site" do + assert_difference -> { Board.count }, +1 do + post boards_path, + params: { board: { name: "Test Board" } }, + as: :json + end + + assert_response :created + end + + test "JSON request fails with cross-site Sec-Fetch-Site" do + assert_no_difference -> { Board.count } do + post boards_path, + params: { board: { name: "Test Board" } }, + headers: { "Sec-Fetch-Site" => "cross-site" }, + as: :json + end + + assert_response :unprocessable_entity + end + private def csrf_token @csrf_token ||= begin From 6a244b17e6deef413d4f351fcd3c9b4e7d0118e0 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 13:04:26 -0500 Subject: [PATCH 76/88] Use new FIZZY_GH_TOKEN with limited access because this is a public repo and so doesn't have access to the private org secret GH_TOKEN anymore. --- .github/dependabot.yml | 2 +- .github/workflows/ci-saas.yml | 2 +- .github/workflows/test.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dbb544dc1..1efb99ec1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ registries: type: git url: https://github.com username: x-access-token - password: ${{secrets.GH_TOKEN}} + password: ${{secrets.FIZZY_GH_TOKEN}} updates: - package-ecosystem: bundler diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml index aa98a8e93..afb91c04d 100644 --- a/.github/workflows/ci-saas.yml +++ b/.github/workflows/ci-saas.yml @@ -18,4 +18,4 @@ jobs: with: saas: true secrets: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index beee16f15..777471774 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: type: boolean required: true secrets: - GH_TOKEN: + FIZZY_GH_TOKEN: required: false permissions: @@ -50,7 +50,7 @@ jobs: MYSQL_USER: root FIZZY_DB_HOST: 127.0.0.1 FIZZY_DB_PORT: 3306 - BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }} + BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} steps: - name: Install system packages From c9e9e7be55d2380f5a4ccfa003b4f491f522314f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 12 Dec 2025 20:40:47 +0100 Subject: [PATCH 77/88] Unused --- app/javascript/helpers/http_helpers.js | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 app/javascript/helpers/http_helpers.js diff --git a/app/javascript/helpers/http_helpers.js b/app/javascript/helpers/http_helpers.js deleted file mode 100644 index 1fb986f70..000000000 --- a/app/javascript/helpers/http_helpers.js +++ /dev/null @@ -1,4 +0,0 @@ -export const HttpStatus = { - CONFLICT: 409, - UNPROCESSABLE: 422 -} From 9cff236f66a37eff5991e2438d1ab669c02183a2 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 14:46:48 -0500 Subject: [PATCH 78/88] Drop staff restriction in beta and staging because it was preventing testing of signups. --- app/controllers/concerns/authorization.rb | 5 --- .../non_production_remote_access_test.rb | 34 ------------------- 2 files changed, 39 deletions(-) delete mode 100644 test/controllers/non_production_remote_access_test.rb diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 81bd0c781..46769992e 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -3,7 +3,6 @@ module Authorization included do before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } - before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated? end class_methods do @@ -30,10 +29,6 @@ module Authorization redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? end - def ensure_only_staff_can_access_non_production_remote_environments - head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff? - end - def redirect_existing_user redirect_to root_path if Current.user end diff --git a/test/controllers/non_production_remote_access_test.rb b/test/controllers/non_production_remote_access_test.rb deleted file mode 100644 index cb84eda35..000000000 --- a/test/controllers/non_production_remote_access_test.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "test_helper" - -class NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest - test "staff can access in staging environment" do - sign_in_as :david - - Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) - get cards_path - assert_response :success - end - - test "non-staff cannot access in staging environment" do - sign_in_as :jz - - Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging")) - get cards_path - assert_response :forbidden - end - - test "non-staff can access in production environment" do - sign_in_as :jz - - Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("production")) - get cards_path - assert_response :success - end - - test "non-staff can access in local environment" do - sign_in_as :jz - - get cards_path - assert_response :success - end -end From 43fd8ab6912faaa908b40daf98e10a6e3415e643 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 15:31:22 -0500 Subject: [PATCH 79/88] Update fizzy-saas to get employee restriction in staging --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index eaae95e75..070d23a72 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: 9c356dfeedbf5309f1a36ee3371f49604c9be188 + revision: 210e68d7092a78c892c6a14df59c2167de0682b1 specs: fizzy-saas (0.1.0) audits1984 From 7d6cf62665fafc87dfdecd83cbd1d33d1b269fb7 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 12 Dec 2025 16:59:43 -0500 Subject: [PATCH 80/88] CSP config to allow Minio in development --- config/environments/development.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/environments/development.rb b/config/environments/development.rb index 3e4109dd3..4e6285263 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -34,6 +34,8 @@ Rails.application.configure do # Store uploaded files on the local file system (see config/storage.yml for options). if Rails.root.join("tmp/minio-dev.txt").exist? config.active_storage.service = :devminio + config.x.content_security_policy.connect_src = "http://minio.localhost:39000" + config.x.content_security_policy.img_src = "http://minio.localhost:39000" else config.active_storage.service = :local end From 43ab17df2fe3452dbd75b4c95458c50aff7bdfdd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 12 Dec 2025 21:47:10 -0800 Subject: [PATCH 81/88] Update tests with final method naming: record! -> record --- test/models/storage/entry_test.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test/models/storage/entry_test.rb b/test/models/storage/entry_test.rb index cf5befd52..0a7665532 100644 --- a/test/models/storage/entry_test.rb +++ b/test/models/storage/entry_test.rb @@ -7,7 +7,7 @@ class Storage::EntryTest < ActiveSupport::TestCase @card = cards(:logo) end - test "record! creates entry with positive delta" do + test "record creates entry with positive delta" do assert_difference "Storage::Entry.count", +1 do entry = Storage::Entry.record \ account: @account, @@ -25,7 +25,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! creates entry with negative delta" do + test "record creates entry with negative delta" do entry = Storage::Entry.record \ account: @account, board: @board, @@ -37,7 +37,7 @@ class Storage::EntryTest < ActiveSupport::TestCase assert_equal "detach", entry.operation end - test "record! returns nil and creates no entry when delta is zero" do + test "record returns nil and creates no entry when delta is zero" do assert_no_difference "Storage::Entry.count" do result = Storage::Entry.record \ account: @account, @@ -50,7 +50,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! accepts _id params for after_destroy_commit snapshots" do + test "record accepts _id params for after_destroy_commit snapshots" do entry = Storage::Entry.record \ account_id: @account.id, board_id: @board.id, @@ -65,7 +65,7 @@ class Storage::EntryTest < ActiveSupport::TestCase assert_equal @card.id, entry.recordable_id end - test "record! creates entry without board" do + test "record creates entry without board" do entry = Storage::Entry.record \ account: @account, board: nil, @@ -76,7 +76,7 @@ class Storage::EntryTest < ActiveSupport::TestCase assert_nil entry.board_id end - test "record! creates entry without recordable" do + test "record creates entry without recordable" do entry = Storage::Entry.record \ account: @account, board: @board, @@ -88,7 +88,7 @@ class Storage::EntryTest < ActiveSupport::TestCase assert_nil entry.recordable_id end - test "record! enqueues MaterializeJob for account" do + test "record enqueues MaterializeJob for account" do assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do Storage::Entry.record \ account: @account, @@ -99,7 +99,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! enqueues MaterializeJob for board when board_id present" do + test "record enqueues MaterializeJob for board when board_id present" do assert_enqueued_with job: Storage::MaterializeJob, args: [ @board ] do Storage::Entry.record \ account: @account, @@ -110,7 +110,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! does not enqueue job when account is deleted" do + test "record does not enqueue job when account is deleted" do # The graceful handling is that find_by returns nil, so no job is enqueued # for a non-existent account. We can't test with a fake ID due to FK constraints, # but we can verify the find_by behavior by stubbing. @@ -124,7 +124,7 @@ class Storage::EntryTest < ActiveSupport::TestCase end end - test "record! does not enqueue board job when board is deleted" do + test "record does not enqueue board job when board is deleted" do Board.stubs(:find_by).returns(nil) # Account job still enqueued, but board job is not From 82626f020d9054a9c2c783aaa52b2382c1f91733 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 13 Dec 2025 09:29:50 -0800 Subject: [PATCH 82/88] Tailscale serve support (#2126) Ensure we can serve the app from multiple hosts without breaking links. * Switch unnecessary full URLs to paths * Drop default host/port URL options for controllers Shell 1 ```bash bin/dev ``` Shell 2 ```bash tailscale serve http://fizzy.localhost:3006 ``` --- app/controllers/concerns/authentication.rb | 4 ++-- app/controllers/concerns/authorization.rb | 2 +- config/environments/development.rb | 13 +++++++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 08129d9c7..deb8b3fa3 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -38,7 +38,7 @@ module Authentication def require_account unless Current.account.present? - redirect_to main_app.session_menu_url(script_name: nil) + redirect_to main_app.session_menu_path(script_name: nil) end end @@ -125,7 +125,7 @@ module Authentication serve_development_magic_link(magic_link) session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link session[:return_to_after_authenticating] = return_to if return_to - redirect_to main_app.session_magic_link_url(script_name: nil) + redirect_to main_app.session_magic_link_path(script_name: nil) end def serve_development_magic_link(magic_link) diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 46769992e..1afb878ef 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -26,7 +26,7 @@ module Authorization end def ensure_can_access_account - redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? + redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active? end def redirect_existing_user diff --git a/config/environments/development.rb b/config/environments/development.rb index 4e6285263..e40d4fe16 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -85,9 +85,14 @@ Rails.application.configure do config.action_mailer.raise_delivery_errors = false end - config.hosts = %w[ fizzy.localhost localhost 127.0.0.1 ] + [ /^fizzy-\d+(:\d+)$/ ] + config.hosts = [ + "fizzy.localhost", + "localhost", + "127.0.0.1", + /^fizzy-\d+(:\d+)?$/, # review apps: fizzy-123:3000 + /\.ts\.net$/ # tailscale serve: hostname.tail1234.ts.net + ] - # Set host to be used by links generated in mailer and notification view templates. - config.action_controller.default_url_options = { host: config.hosts.first, port: 3006 } - config.action_mailer.default_url_options = { host: config.hosts.first, port: 3006 } + # Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs) + config.action_mailer.default_url_options = { host: "#{config.hosts.first}:3006" } end From db4c8c113861658677cf5059b0d6aa3de5c4fe5f Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 13 Dec 2025 12:37:57 -0500 Subject: [PATCH 83/88] Introduce Vips configuration --- config/initializers/vips.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 config/initializers/vips.rb diff --git a/config/initializers/vips.rb b/config/initializers/vips.rb new file mode 100644 index 000000000..c965f3734 --- /dev/null +++ b/config/initializers/vips.rb @@ -0,0 +1,14 @@ +# Disable Openslide to prevent sqlite segfault in forked parallel workers +# Requires libvips 8.13+ +Vips.block "VipsForeignLoadOpenslide", true if Vips.respond_to?(:block) + +# Limit libvips to 4 threads for each thread pool. Default is #CPUs. +Vips.concurrency_set 4 + +# Limit libvips caches to reduce memory pressure. +# +# Do not disable entirely since libvips relies on some caching internally. +# (When we disabled caches, we hit a ton of JPEG out of order read errors.) +Vips.cache_set_max 10 # Default 100 +Vips.cache_set_max_mem 10.megabytes # Default 100MB +Vips.cache_set_max_files 10 # Default 100 From 139bf3cf81dda1f8836a5d0421f9cfd3f1a04699 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 13 Dec 2025 13:05:30 -0500 Subject: [PATCH 84/88] Validate avatar sizes --- app/models/user.rb | 2 -- app/models/user/avatar.rb | 26 +++++++++++++++++++++-- test/controllers/users_controller_test.rb | 14 +++++++++++- test/models/user/avatar_test.rb | 23 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index 20ee1c40c..6503881a3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -14,8 +14,6 @@ class User < ApplicationRecord has_many :pinned_cards, through: :pins, source: :card has_many :exports, class_name: "Account::Export", dependent: :destroy - scope :with_avatars, -> { preload(:account, :avatar_attachment) } - def deactivate transaction do accesses.destroy_all diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index c635edddb..400738cb1 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -2,13 +2,20 @@ module User::Avatar extend ActiveSupport::Concern ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze + MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze included do has_one_attached :avatar do |attachable| attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately end - validate :avatar_content_type_allowed + scope :with_avatars, -> { preload(:account, :avatar_attachment) } + + validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached? + end + + def avatar_attached? + avatar.attached? end def avatar_thumbnail @@ -17,8 +24,23 @@ module User::Avatar private def avatar_content_type_allowed - if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) + if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image") end end + + def avatar_dimensions_allowed + return unless avatar.blob.analyzed? || avatar.blob.analyze + + width = avatar.blob.metadata[:width] + height = avatar.blob.metadata[:height] + + if width && width > MAX_AVATAR_DIMENSIONS[:width] + errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px") + end + + if height && height > MAX_AVATAR_DIMENSIONS[:height] + errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px") + end + end end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 20008dcee..8e99e4cef 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -65,7 +65,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden end - test "update with invalid avatar shows validation error" do + test "update with invalid avatar content type shows validation error" do sign_in_as :kevin svg_file = fixture_file_upload("avatar.svg", "image/svg+xml") @@ -76,6 +76,18 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_select ".txt-negative", text: /must be a JPEG, PNG, GIF, or WebP image/ end + test "update with oversized avatar shows validation error" do + sign_in_as :kevin + + png_file = fixture_file_upload("avatar.png", "image/png") + + ActiveStorage::Analyzer::ImageAnalyzer::Vips.any_instance.stubs(:metadata).returns({ width: 5000, height: 100 }) + + put user_path(users(:kevin)), params: { user: { avatar: png_file } } + assert_response :unprocessable_entity + assert_select ".txt-negative", text: /width must be less than 4096px/ + end + test "update with valid avatar" do sign_in_as :kevin diff --git a/test/models/user/avatar_test.rb b/test/models/user/avatar_test.rb index ed502d7ab..fcaf05d0b 100644 --- a/test/models/user/avatar_test.rb +++ b/test/models/user/avatar_test.rb @@ -33,4 +33,27 @@ class User::AvatarTest < ActiveSupport::TestCase assert users(:david).avatar.variant(:thumb).processed? end + + test "rejects images that are too wide" do + users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") + users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 5000, height: 100 }) + + assert_not users(:david).valid? + assert_includes users(:david).errors[:avatar], "width must be less than #{User::Avatar::MAX_AVATAR_DIMENSIONS[:width]}px" + end + + test "rejects images that are too tall" do + users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") + users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 100, height: 5000 }) + + assert_not users(:david).valid? + assert_includes users(:david).errors[:avatar], "height must be less than #{User::Avatar::MAX_AVATAR_DIMENSIONS[:height]}px" + end + + test "accepts images within dimension limits" do + users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png") + users(:david).avatar.blob.update!(metadata: { analyzed: true, width: 4096, height: 4096 }) + + assert users(:david).valid? + end end From fbc586646f25952106eee71c7b0c35ff85f23daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Matos?= Date: Sat, 13 Dec 2025 15:10:13 -0300 Subject: [PATCH 85/88] Refactor: improve query scope composition with merge syntax (#2131) * Refactor: improve query scope composition with merge syntax Replace manual WHERE clause concatenation with Rails' merge method for more elegant and maintainable scope composition across Card, Comment, and Filter models. This approach better follows Rails conventions and improves code readability. * Extend scope composition improvements to Card::Closeable Apply the same nested hash syntax pattern to closures table references in order and where clauses. * Remove unnecessary outer braces from where clause --------- Co-authored-by: Jeremy Daer --- app/models/card/closeable.rb | 6 +++--- app/models/card/stallable.rb | 2 +- app/models/comment.rb | 4 ++-- app/models/filter.rb | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/models/card/closeable.rb b/app/models/card/closeable.rb index 51852c9a5..e4cea509f 100644 --- a/app/models/card/closeable.rb +++ b/app/models/card/closeable.rb @@ -7,9 +7,9 @@ module Card::Closeable scope :closed, -> { joins(:closure) } scope :open, -> { where.missing(:closure) } - scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) } - scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) } - scope :closed_by, ->(users) { closed.where("closures.user_id": Array(users)) } + scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) } + scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) } + scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) } end def closed? diff --git a/app/models/card/stallable.rb b/app/models/card/stallable.rb index 545cc87ee..03348dfde 100644 --- a/app/models/card/stallable.rb +++ b/app/models/card/stallable.rb @@ -7,7 +7,7 @@ module Card::Stallable has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy scope :with_activity_spikes, -> { joins(:activity_spike) } - scope :stalled, -> { open.active.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } + scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } before_update :remember_to_detect_activity_spikes after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes? diff --git a/app/models/comment.rb b/app/models/comment.rb index a396a904f..d0315eea8 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -10,8 +10,8 @@ class Comment < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :desc } scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) } - scope :by_system, -> { joins(:creator).where(creator: { role: "system" }) } - scope :by_user, -> { joins(:creator).where.not(creator: { role: "system" }) } + scope :by_system, -> { joins(:creator).where(creator: { role: :system }) } + scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) } after_create_commit :watch_card_by_creator diff --git a/app/models/filter.rb b/app/models/filter.rb index 9dac977bd..9bfefe176 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -29,7 +29,7 @@ class Filter < ApplicationRecord result = result.where(creator_id: creators.ids) if creators.present? result = result.where(board: boards.ids) if boards.present? result = result.tagged_with(tags.ids) if tags.present? - result = result.where("cards.created_at": creation_window) if creation_window + result = result.where(cards: { created_at: creation_window }) if creation_window result = result.closed_at_window(closure_window) if closure_window result = result.closed_by(closers) if closers.present? result = terms.reduce(result) do |result, term| From 206610900317710547d05c36b1f2281b2d0f385a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=8Dtalo=20Matos?= Date: Sat, 13 Dec 2025 15:34:35 -0300 Subject: [PATCH 86/88] Add test coverage for with_golden_first scope (#2130) * Add test coverage for with_golden_first scope Test verifies that the with_golden_first scope correctly orders golden cards before non-golden cards in query results. * Refactor golden test: use instance variables, add subordering coverage Extract @golden and @non_golden fixtures into setup for reuse across all tests. Simplify with_golden_first test to verify both primary ordering (golden before non-golden) and subordering preservation. --------- Co-authored-by: Jeremy Daer --- test/models/card/golden_test.rb | 37 +++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/test/models/card/golden_test.rb b/test/models/card/golden_test.rb index 3ddd8ea39..6a0495fbe 100644 --- a/test/models/card/golden_test.rb +++ b/test/models/card/golden_test.rb @@ -3,40 +3,51 @@ require "test_helper" class Card::GoldenTest < ActiveSupport::TestCase setup do Current.session = sessions(:david) + @golden, @non_golden = cards(:logo), cards(:text) end test "check whether a card is golden" do - assert cards(:logo).golden? - assert_not cards(:text).golden? + assert @golden.golden? + assert_not @non_golden.golden? end test "promote and demote from golden" do - assert_changes -> { cards(:text).reload.golden? }, to: true do - cards(:text).gild + assert_changes -> { @non_golden.reload.golden? }, to: true do + @non_golden.gild end - assert_changes -> { cards(:logo).reload.golden? }, to: false do - cards(:logo).ungild + assert_changes -> { @golden.reload.golden? }, to: false do + @golden.ungild end end test "scopes" do - assert_includes Card.golden, cards(:logo) - assert_not_includes Card.golden, cards(:text) + assert_includes Card.golden, @golden + assert_not_includes Card.golden, @non_golden + end + + test "with_golden_first orders golden first" do + ordered = Card.where(id: [ @golden.id, @non_golden.id ]).with_golden_first + assert_equal [ @golden, @non_golden ], ordered.to_a + + # Preserves base ordering as subordering + @non_golden.gild + base_ordered = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).to_a + with_golden = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).with_golden_first.to_a + assert_equal base_ordered, with_golden end test "gilding a card touches both the card and the board" do - card = cards(:text) - board = card.board + board = @non_golden.board - card_updated_at = card.updated_at + card_updated_at = @non_golden.updated_at board_updated_at = board.updated_at travel 1.minute do - card.gild + @non_golden.gild end - assert card.reload.updated_at > card_updated_at + assert @non_golden.reload.updated_at > card_updated_at assert board.reload.updated_at > board_updated_at end end From 3d3593c8f651dbe808a9f56308788df7125740ca Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 13 Dec 2025 10:39:01 -0800 Subject: [PATCH 87/88] Bump fizzy-saas to retain fewer docker images (#2134) References https://github.com/basecamp/fizzy-saas/pull/32 --- Gemfile.saas.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 070d23a72..3d12fc6c7 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: 210e68d7092a78c892c6a14df59c2167de0682b1 + revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143 specs: fizzy-saas (0.1.0) audits1984 From 472dbeee8c53e2e4b96b960756047f59672eb652 Mon Sep 17 00:00:00 2001 From: nu-wa <32520539+nu-wa@users.noreply.github.com> Date: Sat, 13 Dec 2025 19:57:08 +0100 Subject: [PATCH 88/88] SMTP: support SMTPS on port 465 (#2132) * Add more configuration options for the SMTP connection * Add SMTP_TLS option for implicit TLS connections For SMTPS servers (typically port 465), set SMTP_TLS=true. Port auto-defaults to 465 when TLS is enabled, 587 otherwise. STARTTLS is used by default and automatically disabled when TLS is on. Fixes boolean conversion bug in original PR (string "false" is truthy) and removes insecure default for certificate verification. --------- Co-authored-by: Jeremy Daer --- config/environments/production.rb | 5 +++-- docs/docker-deployment.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 031c9d2cb..173318fe3 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -11,12 +11,13 @@ Rails.application.configure do config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { address: smtp_address, - port: ENV.fetch("SMTP_PORT", "587").to_i, + port: ENV.fetch("SMTP_PORT", ENV["SMTP_TLS"] == "true" ? "465" : "587").to_i, domain: ENV.fetch("SMTP_DOMAIN", nil), user_name: ENV.fetch("SMTP_USERNAME", nil), password: ENV.fetch("SMTP_PASSWORD", nil), authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"), - enable_starttls_auto: ENV.fetch("SMTP_ENABLE_STARTTLS_AUTO", "true") == "true" + tls: ENV["SMTP_TLS"] == "true", + openssl_verify_mode: ENV["SMTP_SSL_VERIFY_MODE"] } end diff --git a/docs/docker-deployment.md b/docs/docker-deployment.md index 15ec58367..f92b80ca2 100644 --- a/docs/docker-deployment.md +++ b/docs/docker-deployment.md @@ -78,14 +78,15 @@ You can then plug all your SMTP settings from that provider into Fizzy via the f - `MAILER_FROM_ADDRESS` - the "from" address that Fizzy should use to send email - `SMTP_ADDRESS` - the address of the SMTP server you'll send through -- `SMTP_PORT` - the port number you use to connect to that SMTP server (the default is 587) +- `SMTP_PORT` - the port number (defaults to 465 when `SMTP_TLS` is set, 587 otherwise) - `SMTP_USERNAME`/`SMTP_PASSWORD` - the credentials for logging in to the SMTP server Less commonly, you might also need to set some of the following: +- `SMTP_TLS` - set to `true` only for servers requiring implicit TLS (SMTPS on port 465); STARTTLS is used automatically by default so most servers don't need this - `SMTP_DOMAIN` - the domain name advertised to the server when connecting - `SMTP_AUTHENTICATION` - if you need an authentication method other than the default `plain` -- `SMTP_ENABLE_STARTTLS_AUTO` - if you need to disable TLS on the SMTP connection +- `SMTP_SSL_VERIFY_MODE` - set to `none` to skip certificate verification (for self-signed certs) You can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration).