Merge branch 'main' into mobile-app/prepare-webviews

* main: (63 commits)
  Ignore hotkeys with modifiers
  Fix 1Password account ID (was user UUID) (#2278)
  Switch 1Password account to 37signals.1password.com (#2276)
  Remove CSS testing comments
  Max card count equals geared pagination size
  Block IPv4-compatible IPv6 addresses in SSRF protection (#2273)
  Only enable transitions on user interaction
  Don't update counter if value hasn't changed
  Add padding to upgrade message on larger screens
  Add test coverage for autolinking multiple URLs
  Add "noopener" to autolinks' rel attribute
  Avoid string manipulation when autolinking.
  Only bump z-index when nav is open
  Move nav and related elements above footer
  Delete Dockerfile.dev
  fix: use the right gh-cli arch package (#2232)
  Bump actions/attest-build-provenance from 3.0.0 to 3.1.0 (#2257)
  Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#2256)
  Consider user avatars always public
  Implement authorization for Active Storage endpoints
  ...
This commit is contained in:
Jay Ohms
2026-01-02 11:42:07 -05:00
101 changed files with 1750 additions and 357 deletions
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.11.1
uses: docker/setup-buildx-action@v3.12.0
- name: Log in to GHCR
uses: docker/login-action@v3.6.0
@@ -98,7 +98,7 @@ jobs:
- name: Attest image provenance (per-arch)
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@v3.0.0
uses: actions/attest-build-provenance@v3.1.0
with:
subject-name: ${{ steps.vars.outputs.canonical }}
subject-digest: ${{ steps.build.outputs.digest }}
@@ -115,7 +115,7 @@ jobs:
IMAGE_NAME: ${{ github.repository }}
steps:
- name: Set up Docker Buildx (for imagetools)
uses: docker/setup-buildx-action@v3.11.1
uses: docker/setup-buildx-action@v3.12.0
- name: Log in to GHCR
uses: docker/login-action@v3.6.0
-39
View File
@@ -1,39 +0,0 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.7
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Set development environment
ENV RAILS_ENV="development" \
BUNDLE_PATH="/usr/local/bundle"
# Install packages needed for development
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential pkg-config git ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools libyaml-dev libssl-dev sqlite3 && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock .ruby-version ./
COPY lib/fizzy.rb ./lib/fizzy.rb
COPY gems ./gems/
RUN --mount=type=secret,id=GITHUB_TOKEN --mount=type=cache,id=fizzy-devbundle-${RUBY_VERSION},sharing=locked,target=/devbundle \
gem install bundler foreman && \
BUNDLE_PATH=/devbundle BUNDLE_GITHUB__COM="$(cat /run/secrets/GITHUB_TOKEN):x-oauth-basic" bundle install && \
cp -a /devbundle/. "$BUNDLE_PATH"/ && \
bundle clean --force
# Final stage
FROM base
# Entrypoint prepares the database
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start the server by default
EXPOSE 3000
EXPOSE 3006
EXPOSE 5001
CMD ["./bin/thrust", "./bin/rails", "server"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.8 KiB

+8 -6
View File
@@ -10,7 +10,7 @@
--block-space-double: calc(var(--block-space) * 2);
/* Text */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--font-sans: "Adwaita Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--font-serif: ui-serif, serif;
--font-mono: ui-monospace, monospace;
@@ -74,11 +74,13 @@
--z-events-column-header: 1;
--z-events-day-header: 3;
--z-popup: 10;
--z-nav: 30;
--z-flash: 40;
--z-tooltip: 50;
--z-bar: 60;
--z-tray: 61;
--z-nav: 20;
--z-flash: 30;
--z-tooltip: 40;
--z-bar: 50;
--z-tray: 51;
--z-welcome: 52;
--z-nav-open: 100;
/* OKLCH colors: Fixed */
--lch-black: 0% 0 0;
+4 -2
View File
@@ -15,7 +15,7 @@
--column-width-collapsed: 40px;
--column-width-expanded: 450px;
--progress-increment: var(--progress-max-height) / var(--progress-max-cards);
--progress-max-cards: 20;
--progress-max-cards: 15; /* should match first geared pagination page size */
--progress-max-height: 50dvh;
container-type: inline-size;
@@ -35,6 +35,7 @@
}
&:has(.cards) {
block-size: 100%;
min-block-size: 20lh;
}
@@ -344,6 +345,7 @@
&:after {
block-size: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment));
max-block-size: none;
opacity: 1;
inline-size: var(--column-width-collapsed);
}
@@ -756,7 +758,7 @@
--column-padding-expanded: calc(var(--column-gap) * 2);
--column-width-collapsed: 40px;
--progress-increment: var(--progress-max-width) / var(--progress-max-cards);
--progress-max-cards: 20;
--progress-max-cards: 15; /* should match first geared pagination page size */
--progress-max-width: 100%;
padding-inline: 3vw;
+11 -16
View File
@@ -23,20 +23,15 @@
}
.comment {
display: flex;
margin-inline: auto;
max-inline-size: var(--comment-max);
position: relative;
house-md-toolbar {
margin-block-start: calc(var(--comment-padding-block) * -0.5);
/* Distinguish from the .comment class used for code formatting without extra specificity */
&:where(.comments &) {
display: flex;
margin-inline: auto;
max-inline-size: var(--comment-max);
position: relative;
}
.house-md-content {
padding: var(--comment-padding-block) 0 0;
}
.comment--system & {
.comment-by-system & {
--comment-padding-block: var(--block-space-half);
text-align: center;
@@ -72,7 +67,7 @@
var(--stripe-color) 1px 10px);
padding-inline: var(--comment-padding-inline);
.comments--system-expanded .comment--system & {
.comments--system-expanded .comment-by-system & {
--stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
}
@@ -144,7 +139,7 @@
position: absolute;
}
.comment--system {
.comment-by-system {
display: none;
transition: var(--dialog-duration) allow-discrete;
transition-property: display;
@@ -155,7 +150,7 @@
}
/* Show the last system comment */
:nth-last-child(1 of .comment--system) {
:nth-last-child(1 of .comment-by-system) {
display: contents;
.comment__history {
@@ -164,7 +159,7 @@
}
/* Hide the "Show history" button if there's only one system comment */
:nth-child(1 of .comment--system) {
:nth-child(1 of .comment-by-system) {
.comment__history {
display: none;
}
+6
View File
@@ -15,6 +15,8 @@
max-inline-size: 100dvw;
padding-block: calc(var(--block-space-half) + env(safe-area-inset-top)) var(--block-space-half);
padding-inline: var(--main-padding);
position: relative;
z-index: var(--z-nav);
/* Change the grid size depending on how many buttons are present */
&:has(.header__actions > *:nth-child(1)) { --header-button-count: 1; }
@@ -25,6 +27,10 @@
row-gap: 0;
}
&:has(dialog[open]) {
z-index: var(--z-nav-open);
}
&:has(~ #main .card-columns) {
inline-size: 100dvw;
margin-inline: auto;
-5
View File
@@ -22,11 +22,6 @@
max-inline-size: 100dvw;
}
:where(#header) {
position: relative;
z-index: var(--z-nav);
}
:is(#header, #footer) {
@media print {
display: none;
+2 -12
View File
@@ -3,7 +3,7 @@
/* ------------------------------------------------------------------------ */
.nav__trigger {
--input-background: var(--color-canvas);
--input-background: transparent;
--input-border-color: transparent;
--input-padding: 0.3em 2em 0.3em 0.75em;
@@ -17,21 +17,11 @@
@media (any-hover: hover) {
&:hover {
filter: brightness(0.9);
--input-background: var(--color-ink-lighter);
span {
background: var(--color-ink-lighter);
}
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);
}
}
}
}
+1
View File
@@ -16,6 +16,7 @@
.txt-ink { color: var(--color-ink); }
.txt-reversed { color: var(--color-ink-inverted); }
.txt-negative { color: var(--color-negative); }
.txt-positive { color: var(--color-positive); }
.txt-subtle { color: var(--color-ink-dark); }
.txt-alert { color: var(--color-marker); }
.txt-undecorated { text-decoration: none; }
+27
View File
@@ -0,0 +1,27 @@
@layer components {
.welcome-letter {
position: relative;
view-transition-name: welcome-letter;
z-index: var(--z-welcome);
h2, p {
text-wrap: pretty;
}
}
.welcome-letter__close {
inset: var(--block-space) var(--block-space) auto auto;
position: absolute;
}
.welcome-letter__signature {
background-color: currentColor;
block-size: 3em;
display: inline-block;
inline-size: 8em;
mask-image: url("jf-signature.svg");
mask-position: center;
mask-repeat: no-repeat;
mask-size: 8em 3em;
}
}
+12 -2
View File
@@ -2,20 +2,30 @@ class Cards::ClosuresController < ApplicationController
include CardScoped
def create
capture_card_location
@card.close
refresh_stream_if_needed
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@card.reopen
refresh_stream_after_reopen
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.turbo_stream
format.json { head :no_content }
end
end
private
def refresh_stream_after_reopen
if @card.awaiting_triage?
set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded
end
end
end
+3 -1
View File
@@ -2,10 +2,12 @@ class Cards::NotNowsController < ApplicationController
include CardScoped
def create
capture_card_location
@card.postpone
refresh_stream_if_needed
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.turbo_stream
format.json { head :no_content }
end
end
+1 -1
View File
@@ -61,6 +61,6 @@ class CardsController < ApplicationController
end
def card_params
params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ])
params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ])
end
end
+6 -1
View File
@@ -26,7 +26,12 @@ module Authorization
end
def ensure_can_access_account
redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active?
if Current.user.blank? || !Current.user.active?
respond_to do |format|
format.html { redirect_to session_menu_path(script_name: nil) }
format.json { head :forbidden }
end
end
end
def redirect_existing_user
+11
View File
@@ -17,4 +17,15 @@ module CardScoped
def render_card_replacement
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload })
end
def capture_card_location
@source_column = @card.column
@was_in_stream = @card.awaiting_triage?
end
def refresh_stream_if_needed
if @was_in_stream
set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded
end
end
end
+2
View File
@@ -1,5 +1,7 @@
class LandingsController < ApplicationController
def show
flash.keep(:welcome_letter)
if Current.user.boards.one?
redirect_to board_path(Current.user.boards.first)
else
@@ -11,6 +11,7 @@ class Signups::CompletionsController < ApplicationController
@signup = Signup.new(signup_params)
if @signup.complete
flash[:welcome_letter] = true
redirect_to landing_url(script_name: @signup.account.slug)
else
render :new, status: :unprocessable_entity
+2
View File
@@ -12,6 +12,7 @@ module ColumnsHelper
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block)
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
hotkeys_disabled = data[:card_hotkeys_disabled]
data = {
drag_and_drop_target: "container",
@@ -37,6 +38,7 @@ module ColumnsHelper
navigable_list_auto_select_value: "false",
navigable_list_actionable_items_value: "true",
navigable_list_only_act_on_focused_items_value: "true",
card_hotkeys_disabled: hotkeys_disabled,
action: "keydown->navigable-list#navigate"
}, &block)
end
+1 -60
View File
@@ -1,64 +1,5 @@
module HtmlHelper
include ERB::Util
EXCLUDE_PUNCTUATION = %(.?,:!;"'<>)
EXCLUDE_PUNCTUATION_REGEX = /[#{Regexp.escape(EXCLUDE_PUNCTUATION)}]+\z/
def format_html(html)
fragment = Nokogiri::HTML5.fragment(html)
auto_link(fragment)
fragment.to_html.html_safe
Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe
end
private
EXCLUDED_ELEMENTS = %w[ a figcaption pre code ]
EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https])
def auto_link(fragment)
fragment.traverse do |node|
next unless auto_linkable_node?(node)
# Take care to escape the html text node, so that the subsequent Nokogiri re-parse doesn't
# create tags where there aren't any.
content = h(node.text)
linked_content = content.dup
auto_link_urls(linked_content)
auto_link_emails(linked_content)
if linked_content != content
node.replace(Nokogiri::HTML5.fragment(linked_content))
end
end
end
def auto_linkable_node?(node)
node.text? && node.ancestors.none? { |ancestor| EXCLUDED_ELEMENTS.include?(ancestor.name) }
end
def auto_link_urls(linked_content)
linked_content.gsub!(URL_REGEXP) do |match|
url, trailing_punct = extract_url_and_punctuation(match)
%(<a href="#{url}" rel="noreferrer">#{url}</a>#{trailing_punct})
end
end
def extract_url_and_punctuation(url_match)
url_match = CGI.unescapeHTML(url_match)
if match = url_match.match(EXCLUDE_PUNCTUATION_REGEX)
len = match[0].length
[ url_match[..-(len+1)], url_match[-len..] ]
else
[ url_match, "" ]
end
end
def auto_link_emails(text)
text.gsub!(EMAIL_AUTOLINK_REGEXP) do |match|
%(<a href="mailto:#{match}">#{match}</a>)
end
end
end
@@ -0,0 +1,134 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
export default class extends Controller {
static outlets = [ "navigable-list" ]
connect() {
this.morphCompletePromise = null
this.morphCompleteResolver = null
}
handleKeydown(event) {
if (this.#shouldIgnore(event) || this.#hasModifier(event)) return
const handler = this.#keyHandlers[event.key.toLowerCase()]
if (handler) {
handler.call(this, event)
}
}
// Called when turbo:morph completes - resolves our waiting promise
handleMorphComplete() {
if (this.morphCompleteResolver) {
this.morphCompleteResolver()
this.morphCompleteResolver = null
this.morphCompletePromise = null
}
}
// Private
#shouldIgnore(event) {
const target = event.target
return target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
target.closest("input, textarea, [contenteditable], lexxy-editor")
}
#hasModifier(event) {
return event.metaKey || event.ctrlKey || event.altKey || event.shiftKey
}
get #selectedCard() {
// Find the navigable-list that currently has focus
const focusedList = this.navigableListOutlets.find(list => list.hasFocus)
if (!focusedList) return null
const currentItem = focusedList.currentItem
if (currentItem?.classList.contains("card") && !this.#hotkeysDisabled(focusedList)) {
return { card: currentItem, controller: focusedList }
}
return null
}
async #postponeCard(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardNotNowUrl
if (url) {
event.preventDefault()
await this.#performCardAction(url, selection)
}
}
async #closeCard(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardClosureUrl
if (url) {
event.preventDefault()
await this.#performCardAction(url, selection)
}
}
async #assignToMe(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardAssignToMeUrl
if (url) {
event.preventDefault()
await post(url, { responseKind: "turbo-stream" })
}
}
async #performCardAction(url, selection) {
const { controller } = selection
const visibleItems = controller.visibleItems
const currentIndex = visibleItems.indexOf(selection.card)
const wasLastItem = currentIndex === visibleItems.length - 1
// Set up promise to wait for morph completion
this.morphCompletePromise = new Promise(resolve => {
this.morphCompleteResolver = resolve
})
await post(url, { responseKind: "turbo-stream" })
// Wait for Turbo Stream morph to complete
await Promise.race([
this.morphCompletePromise,
new Promise(resolve => setTimeout(resolve, 200)) // Fallback timeout
])
// Select the next card (or previous if it was the last)
const newVisibleItems = controller.visibleItems
if (newVisibleItems.length === 0) {
controller.clearSelection()
return
}
if (wasLastItem) {
controller.selectLast()
} else {
const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1)
if (newVisibleItems[nextIndex]) {
await controller.selectItem(newVisibleItems[nextIndex])
}
}
}
#hotkeysDisabled(navigableList) {
return navigableList?.element.dataset.cardHotkeysDisabled === "true"
}
#keyHandlers = {
"["(event) { this.#postponeCard(event) },
"]"(event) { this.#closeCard(event) },
m(event) { this.#assignToMe(event) }
}
}
@@ -5,7 +5,7 @@ export default class extends Controller {
static targets = [ "item", "counter" ]
static values = {
propertyName: String,
maxValue: { type: Number, default: 20 }
maxValue: { type: Number, default: 15 } // should match first geared pagination page size
}
initialize() {
@@ -13,7 +13,9 @@ export default class extends Controller {
}
connect() {
this.#updateCounter()
if (this.itemTargets.length > 0) {
this.#updateCounter()
}
}
itemTargetConnected() {
@@ -5,11 +5,13 @@ export default class extends Controller {
static targets = [ "dialog" ]
static values = {
modal: { type: Boolean, default: false },
sizing: { type: Boolean, default: true }
sizing: { type: Boolean, default: true },
autoOpen: { type: Boolean, default: false }
}
connect() {
this.dialogTarget.setAttribute("aria-hidden", "true")
if (this.autoOpenValue) this.open()
}
open() {
@@ -51,6 +51,10 @@ export default class extends Controller {
this.selectItem(target, true)
}
hoverSelect({ currentTarget }) {
this.selectItem(currentTarget)
}
selectCurrentOrReset(event) {
if (this.currentItem) {
this.#setCurrentFrom(this.currentItem)
@@ -227,6 +231,20 @@ export default class extends Controller {
})
}
// Public accessors for card_hotkeys_controller outlet
get visibleItems() {
return this.#visibleItems
}
clearSelection() {
this.#clearSelection()
this.currentItem = null
}
get hasFocus() {
return this.element.contains(document.activeElement)
}
#keyHandlers = {
ArrowDown(event) {
if (this.supportsVerticalNavigationValue) {
@@ -259,6 +277,6 @@ export default class extends Controller {
} else {
this.#clickCurrentItem(event)
}
},
}
}
}
+19 -5
View File
@@ -26,13 +26,27 @@ export default class extends Controller {
set #theme(theme) {
localStorage.setItem("theme", theme)
if (theme === "auto") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme)
const currentTheme = document.documentElement.getAttribute("data-theme") || "auto"
const hasChanged = currentTheme !== theme
const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches
const animate = hasChanged && !prefersReducedMotion
const applyTheme = () => {
if (theme === "auto") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme)
}
this.#updateButtons()
}
this.#updateButtons()
if (animate && document.startViewTransition) {
document.startViewTransition(applyTheme)
} else {
applyTheme()
}
}
#applyStoredTheme() {
+5 -1
View File
@@ -1,9 +1,13 @@
class Storage::ReconcileJob < ApplicationJob
class ReconcileAborted < StandardError; end
queue_as :backend
limits_concurrency to: 1, key: ->(owner) { owner }
discard_on ActiveJob::DeserializationError
retry_on ReconcileAborted, wait: 1.minute, attempts: 3
def perform(owner)
owner.reconcile_storage
raise ReconcileAborted, "Could not get stable snapshot for #{owner.class}##{owner.id}" unless owner.reconcile_storage
end
end
+4
View File
@@ -37,6 +37,10 @@ class Account::Export < ApplicationRecord
update!(status: :completed, completed_at: Time.current)
end
def accessible_to?(accessor)
accessor == user
end
private
def generate_zip
Tempfile.new([ "export", ".zip" ]).tap do |tempfile|
+1
View File
@@ -15,6 +15,7 @@ module Board::Publishable
def published?
publication.present?
end
alias_method :publicly_accessible?, :published?
def publish
create_publication! unless published?
+24 -8
View File
@@ -12,25 +12,32 @@ module Board::Storage
# Calculate actual storage by summing blob sizes.
#
# Uses batched pluck queries to avoid loading huge ID arrays, and avoids
# ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool
# issues when ActiveStorage uses separate connection pools (e.g., with replicas).
# Storage tracking is a business abstraction - we count what users upload.
# Original upload bytes only; variants/previews/derivatives excluded.
# Physical storage optimizations (deduplication, compression) don't affect quotas.
def calculate_real_storage_bytes
@card_ids = nil # Clear memoization for fresh calculation
card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes
end
def card_ids
@card_ids ||= cards.pluck(:id)
end
def card_image_bytes
sum_blob_bytes_in_batches \
ActiveStorage::Attachment.where(record_type: "Card", name: "image"),
cards.pluck(:id)
card_ids
end
def card_embed_bytes
sum_embed_bytes_for "Card", cards.pluck(:id)
sum_embed_bytes_for "Card", card_ids
end
def comment_embed_bytes
sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id)
card_ids.each_slice(BATCH_SIZE).sum do |batch|
sum_embed_bytes_for "Comment", Comment.where(card_id: batch).pluck(:id)
end
end
def board_embed_bytes
@@ -48,9 +55,18 @@ module Board::Storage
end
def sum_blob_bytes_in_batches(base_scope, record_ids)
# Count per-attachment to match ledger model.
# Same blob attached 3 times = 3x bytes (business abstraction, not physical storage).
#
# Do NOT remove the join thinking it's a performance optimization - it's required
# for correct per-attachment counting. We keep ActiveStorage/ActionText in the same
# database (realm/geo partitioning, not functionality partitioning), so cross-table
# joins are fine.
record_ids.each_slice(BATCH_SIZE).sum do |batch_ids|
blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id)
ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size)
base_scope
.where(record_id: batch_ids)
.joins(:blob)
.sum("active_storage_blobs.byte_size")
end
end
end
+53
View File
@@ -48,6 +48,10 @@ class Card < ApplicationRecord
delegate :accessible_to?, to: :board
def publicly_accessible?
published? && board.publicly_accessible?
end
def card
self
end
@@ -68,6 +72,55 @@ class Card < ApplicationRecord
end
private
STORAGE_BATCH_SIZE = 1000
# Override to include comments, but only load comments that have attachments.
# Cards can have thousands of comments; most won't have attachments.
# Streams in batches to avoid loading all IDs into memory at once.
def storage_transfer_records
comment_ids_with_attachments = storage_comment_ids_with_attachments
if comment_ids_with_attachments.any?
[ self, *comments.where(id: comment_ids_with_attachments).to_a ]
else
[ self ]
end
end
def storage_comment_ids_with_attachments
direct = []
rich_text_map = {}
# Stream comment IDs in batches to avoid loading all into memory
comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
batch_ids = batch.pluck(:id)
direct.concat \
ActiveStorage::Attachment
.where(record_type: "Comment", record_id: batch_ids)
.distinct
.pluck(:record_id)
ActionText::RichText
.where(record_type: "Comment", record_id: batch_ids)
.pluck(:id, :record_id)
.each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
end
embed_comment_ids = if rich_text_map.any?
rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
ActiveStorage::Attachment
.where(record_type: "ActionText::RichText", record_id: batch_ids)
.distinct
.pluck(:record_id)
end.filter_map { |rt_id| rich_text_map[rt_id] }
else
[]
end
(direct + embed_comment_ids).uniq
end
def set_default_title
self.title = "Untitled" if title.blank?
end
+1 -1
View File
@@ -15,7 +15,7 @@ class Comment < ApplicationRecord
after_create_commit :watch_card_by_creator
delegate :board, :watch_by, to: :card
delegate :publicly_accessible?, :accessible_to?, :board, :watch_by, to: :card
def to_partial_path
"cards/#{super}"
+33 -15
View File
@@ -19,7 +19,7 @@ module Storage::Totaled
# Exact: snapshot + pending entries
def bytes_used_exact
(storage_total || create_storage_total!).current_usage
create_or_find_storage_total.current_usage
end
def materialize_storage_later
@@ -28,7 +28,7 @@ module Storage::Totaled
# Materialize all pending entries into snapshot
def materialize_storage
total = storage_total || create_storage_total!
total = create_or_find_storage_total
total.with_lock do
latest_entry_id = storage_entries.maximum(:id)
@@ -44,26 +44,44 @@ module Storage::Totaled
end
# Reconcile ledger against actual attachment storage.
# Uses cursor to ensure consistency: captures max entry ID first, then calculates
# real bytes, then sums only entries up to that cursor. Concurrent uploads during
# calculation will have entries with IDs beyond the cursor, avoiding double-count.
#
# Uses two-cursor approach for consistency: capture cursor before AND after the
# scan. If they differ, entries were added during the scan and we can't get an
# accurate diff without risking double-counting or undercounting.
#
# Returns true if reconciled successfully, false if aborted due to concurrent
# writes. Caller (ReconcileJob) handles retries to avoid amplification.
def reconcile_storage
max_entry_id = storage_entries.maximum(:id)
cursor_before = storage_entries.maximum(:id)
real_bytes = calculate_real_storage_bytes
ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0
diff = real_bytes - ledger_bytes
cursor_after = storage_entries.maximum(:id)
if diff.nonzero?
Storage::Entry.record \
account: is_a?(Account) ? self : account,
board: is_a?(Board) ? self : nil,
recordable: nil,
delta: diff,
operation: "reconcile"
if cursor_before != cursor_after
Rails.logger.warn "[Storage] Reconcile aborted for #{self.class}##{id}: cursor moved during scan"
false
else
ledger_bytes = cursor_after ? storage_entries.where(id: ..cursor_after).sum(:delta) : 0
diff = real_bytes - ledger_bytes
if diff.nonzero?
Rails.logger.info "[Storage] Reconcile #{self.class}##{id}: adjusting by #{diff} bytes"
Storage::Entry.record \
account: is_a?(Account) ? self : account,
board: is_a?(Board) ? self : nil,
recordable: nil,
delta: diff,
operation: "reconcile"
end
true
end
end
private
def create_or_find_storage_total
self.storage_total ||= Storage::Total.create_or_find_by!(owner: self)
end
def calculate_real_storage_bytes
raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes"
end
+52 -12
View File
@@ -1,3 +1,6 @@
# Storage tracking is a business abstraction - we count what users upload.
# Original upload bytes only; variants/previews/derivatives excluded.
# Physical storage optimizations (deduplication, compression) don't affect quotas.
module Storage::Tracked
extend ActiveSupport::Concern
@@ -22,21 +25,27 @@ module Storage::Tracked
private
def board_transfer?
respond_to?(:board_id_changed?) && board_id_changed?
respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id?
end
def track_board_transfer
old_board_id = board_id_was
current_bytes = storage_bytes
old_board = Board.find_by(id: attribute_in_database(:board_id))
records = storage_transfer_records.compact
return if records.empty?
attachments_by_record = storage_attachments_for_records(records)
attachments_by_record.each do |recordable, attachments|
bytes = attachments.sum { |attachment| attachment.blob.byte_size }
next if bytes.zero?
if current_bytes.positive?
# Debit old board
if old_board_id
if old_board
Storage::Entry.record \
account: account,
board_id: old_board_id,
recordable: self,
delta: -current_bytes,
board: old_board,
recordable: recordable,
delta: -bytes,
operation: "transfer_out"
end
@@ -44,14 +53,45 @@ module Storage::Tracked
Storage::Entry.record \
account: account,
board: board,
recordable: self,
delta: current_bytes,
recordable: recordable,
delta: bytes,
operation: "transfer_in"
end
end
def storage_transfer_records
[ self ]
end
# Override if needed. Default = all direct attachments
def attachments_for_storage
ActiveStorage::Attachment.where(record: self)
def attachments_for_storage(recordable = self)
storage_attachments_for_records([ recordable ]).fetch(recordable, [])
end
def storage_attachments_for_records(recordables)
records = Array(recordables).compact
return {} if records.empty?
# Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents
records_by_key = records.index_by { |r| [ r.class.name, r.id ] }
rich_texts = ActionText::RichText.where(record: records)
rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] }
attachments = ActiveStorage::Attachment
.where(record: records + rich_texts)
.includes(:blob)
.to_a
attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped|
# Resolve parent without N+1: use lookup for RichText, direct for others
recordable = if attachment.record_type == "ActionText::RichText"
rich_text_to_parent[attachment.record_id]
else
records_by_key[[ attachment.record_type, attachment.record_id ]]
end
grouped[recordable] << attachment if recordable
end
end
end
+1 -1
View File
@@ -14,7 +14,7 @@ class Event::Description
end
def to_plain_text
to_sentence(creator_name, card.title)
to_sentence(creator_name, h(card.title))
end
private
+9 -3
View File
@@ -16,13 +16,19 @@ module SsrfProtection
def resolve_public_ip(hostname)
ip_addresses = resolve_dns(hostname)
public_ips = ip_addresses.reject { |ip| private_address?(ip) }
public_ips = ip_addresses.reject { |ip| blocked_address?(ip) }
public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s
end
def private_address?(ip)
def blocked_address?(ip)
ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr)
ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || in_disallowed_range?(ip)
ip.private? ||
ip.loopback? ||
ip.link_local? ||
ip.ipv4_mapped? ||
ip.ipv4_compat? ||
in_disallowed_range?(ip)
end
private
+13
View File
@@ -2,4 +2,17 @@ module Storage
def self.table_name_prefix
"storage_"
end
# Record types that participate in storage tracking (ledger entries created on attach).
# The no-reuse validation uses this to scope its check.
#
# IMPORTANT: Update this constant when adding tracked attachments to new models.
# If you add a direct attachment (not via RichText embeds) to Comment, Board, or
# another model with Storage::Tracked, you must add its record_type here or the
# no-reuse validation won't protect it.
TRACKED_RECORD_TYPES = %w[Card ActionText::RichText].freeze
# Account ID for template/demo blobs that can be reused cross-tenant.
# Set to nil to disable the whitelist (no exemptions).
TEMPLATE_ACCOUNT_ID = nil
end
+9 -10
View File
@@ -26,24 +26,23 @@ module Storage::AttachmentTracking
return unless @storage_snapshot
Storage::Entry.record \
account_id: @storage_snapshot[:account_id],
board_id: @storage_snapshot[:board_id],
recordable_type: @storage_snapshot[:recordable_type],
recordable_id: @storage_snapshot[:recordable_id],
blob_id: @storage_snapshot[:blob_id],
account: @storage_snapshot[:account],
board: @storage_snapshot[:board],
recordable: @storage_snapshot[:recordable],
blob: blob,
delta: -blob.byte_size,
operation: "detach"
end
# Snapshot records in before_destroy since parent may be deleted by the time
# after_destroy_commit runs. The records may be destroyed but .id still works.
def snapshot_storage_context
return unless storage_tracked_record
@storage_snapshot = {
account_id: storage_tracked_record.account.id,
board_id: storage_tracked_record.board_for_storage_tracking&.id,
recordable_type: storage_tracked_record.class.name,
recordable_id: storage_tracked_record.id,
blob_id: blob.id
account: storage_tracked_record.account,
board: storage_tracked_record.board_for_storage_tracking,
recordable: storage_tracked_record
}
end
+12 -16
View File
@@ -5,30 +5,26 @@ class Storage::Entry < ApplicationRecord
scope :pending, ->(last_entry_id) { where.not(id: ..last_entry_id) if last_entry_id }
# Accepts either objects or _id params (for after_destroy_commit snapshots)
def self.record(delta:, operation:, account: nil, account_id: nil, board: nil, board_id: nil,
recordable: nil, recordable_type: nil, recordable_id: nil, blob: nil, blob_id: nil)
# Records may be destroyed (during cascading deletes) but .id still works.
# Skip entirely if account is destroyed - no need to track storage for deleted accounts.
# Skip materialize jobs for destroyed boards since there's nothing to update.
def self.record(delta:, operation:, account:, board: nil, recordable: nil, blob: nil)
return if delta.zero?
account_id ||= account&.id
board_id ||= board&.id
blob_id ||= blob&.id
return if account.destroyed?
entry = create! \
account_id: account_id,
board_id: board_id,
recordable_type: recordable_type || recordable&.class&.name,
recordable_id: recordable_id || recordable&.id,
blob_id: blob_id,
account_id: account.id,
board_id: board&.id,
recordable_type: recordable&.class&.name,
recordable_id: recordable&.id,
blob_id: blob&.id,
delta: delta,
operation: operation,
user_id: Current.user&.id,
request_id: Current.request_id
# Enqueue materialization - use find_by to handle cascading deletes
# (Account/Board may be destroyed while attachments are still being cleaned up)
Account.find_by(id: account_id)&.materialize_storage_later
Board.find_by(id: board_id)&.materialize_storage_later if board_id
account.materialize_storage_later
board&.materialize_storage_later unless board&.destroyed?
entry
end
+5
View File
@@ -22,6 +22,11 @@ module User::Avatar
avatar.variable? ? avatar.variant(:thumb) : avatar
end
# Avatars are always publicly accessible
def publicly_accessible?
true
end
private
def avatar_content_type_allowed
if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
+1
View File
@@ -1,5 +1,6 @@
<%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--closed", style: "--card-color: var(--color-card-complete);",
data: {
card_hotkeys_disabled: true,
drag_and_strum_target: "container",
collapsible_columns_target: "column",
action: "focus->collapsible-columns#focusOnColumn"
+4 -1
View File
@@ -1,5 +1,5 @@
<%= tag.div class: "card-columns hide-scrollbar", data: {
controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list",
controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys",
drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item",
drag_and_drop_hover_container_class: "drag-and-drop__hover-container",
collapsible_columns_board_value: board.id,
@@ -11,8 +11,11 @@
navigable_list_prevent_handled_keys_value: true,
navigable_list_auto_select_value: false,
navigable_list_auto_scroll_value: false,
card_hotkeys_navigable_list_outlet: ".cards__transition-container",
action: "
keydown->navigable-list#navigate
keydown->card-hotkeys#handleKeydown
turbo:morph@document->card-hotkeys#handleMorphComplete
dragstart->drag-and-drop#dragStart
dragover->drag-and-drop#dragOver
dragenter->drag-and-strum#dragEnter
+1 -1
View File
@@ -1,5 +1,5 @@
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-css-variable-counter-target="counter" data-action="click->collapsible-columns#toggle click->navigable-list#selectCurrentOrReset"
style="--card-count: <%= [ count, 20 ].min %>" aria-controls="<%= column_id %>" aria-expanded="false">
style="--card-count: <%= [ count, 15 ].min %>" aria-controls="<%= column_id %>" aria-expanded="false">
<span class="cards__expander-count" data-drag-and-drop-counter="true"><%= count > 99 ? "99+" : count %></span>
<h2 class="cards__expander-title" data-collapsible-columns-target="title">
<%= title %>
+1
View File
@@ -1,5 +1,6 @@
<%= column_tag id: "not-now", name: "Not Now", drop_url: columns_card_drops_not_now_path("__id__"), class: "cards--on-deck", style: "--card-color: var(--color-card-complete);",
data: {
card_hotkeys_disabled: true,
collapsible_columns_target: "column",
drag_and_strum_target: "container",
action: "focus->collapsible-columns#focusOnColumn"
+1 -1
View File
@@ -1,4 +1,4 @@
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item" aria-checked="<%= card.assignees.include?(user) %>">
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item" <%= tag.attributes(data: local_assigns.fetch(:data, {})) %> aria-checked="<%= card.assignees.include?(user) %>">
<%= button_to card_assignments_path(card, params: { assignee_id: user.id }), method: :post,
class: "popup__btn btn", form_class: "max-width flex-item-grow" do %>
<span class="overflow-ellipsis flex-item-grow"><%= local_assigns.fetch(:user_label, user.name) %></span>
+1 -3
View File
@@ -23,9 +23,7 @@
<% end %>
<%= render collection: @assigned_to, partial: "user", locals: { card: @card } %>
<% @users.each do |user| %>
<span data-assignment-limit-target="unassigned">
<%= render "user", card: @card, user: user %>
</span>
<%= render "user", card: @card, user: user, data: { assignment_limit_target: "unassigned" } %>
<% end %>
</ul>
@@ -0,0 +1,9 @@
<%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals: { board: @card.board }) %>
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
@@ -0,0 +1,9 @@
<%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals: { board: @card.board }) %>
<% if @card.column %>
<%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %>
<% elsif @card.awaiting_triage? %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
+2 -2
View File
@@ -1,8 +1,8 @@
<% cache comment do %>
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%= turbo_frame_tag comment, :container, class: ["comment--system": comment.creator.system?] do %>
<%= turbo_frame_tag comment, :container, class: { "comment-by-system": comment.creator.system? } do %>
<%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%>
<div id="<%= dom_id(comment) %>" data-creator-id="<%= comment.creator_id %>" class="comment align-start full-width">
<div id="<%= dom_id(comment) %>" data-creator-id="<%= comment.creator_id %>" class="comment align-start full-width">
<figure class="comment__avatar flex-item-no-shrink" aria-hidden="true">
<%= avatar_tag comment.creator, hidden_for_screen_reader: true %>
</figure>
@@ -11,6 +11,11 @@ json.cache! comment do
json.creator comment.creator, partial: "users/user", as: :user
json.card do
json.id comment.card_id
json.url card_url(comment.card_id)
end
json.reactions_url card_comment_reactions_url(comment.card_id, comment.id)
json.url card_comment_url(comment.card_id, comment.id)
end
+17 -1
View File
@@ -1,6 +1,22 @@
<% draggable = local_assigns.fetch(:draggable, false) && card.published? %>
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item", css_variable_counter_target: "item" }, tabindex: 0 do %>
<% card_data = {
id: card.number,
drag_and_drop_target: "item",
navigable_list_target: "item",
css_variable_counter_target: "item"
} %>
<% if card.open? %>
<% card_data[:card_not_now_url] = card_not_now_path(card) %>
<% card_data[:card_closure_url] = card_closure_path(card) %>
<% card_data[:action] = "mouseenter->navigable-list#hoverSelect" %>
<% if Current.user %>
<% card_data[:card_assign_to_me_url] = card_assignments_path(card, params: { assignee_id: Current.user.id }) %>
<% end %>
<% end %>
<%= card_article_tag card, class: "card", draggable: draggable, data: card_data, tabindex: 0 do %>
<div class="flex flex-column flex-item-grow max-inline-size">
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
<span class="for-screen-reader"><%= card.title %></span>
@@ -12,7 +12,7 @@
</span>
</button>
<% if Current.user %>
<% if Current.user && !local_assigns[:preview] %>
<%= button_to "Assign to me", card_assignments_path(card, params: { assignee_id: Current.user.id }), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %>
<% end %>
@@ -0,0 +1,9 @@
<%= turbo_stream.replace("not-now", partial: "boards/show/not_now", method: :morph, locals: { board: @card.board }) %>
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
+2
View File
@@ -26,6 +26,8 @@
<%= render "notifications/tray" %>
</div>
<% end %>
<%= render "layouts/shared/welcome_letter" if flash[:welcome_letter] %>
</footer>
</body>
</html>
@@ -0,0 +1,28 @@
<div data-controller="dialog" data-dialog-auto-open-value="true" data-dialog-modal-value="true">
<dialog class="welcome-letter panel panel--wide shadow dialog" data-dialog-target="dialog">
<button class="welcome-letter__close btn txt-x-small" data-action="dialog#close">
<%= icon_tag "close" %>
<span class="for-screen-reader">Close dialog</span>
</button>
<div class="txt-align-center margin-block-end">
<span class="welcome-letter__avatar txt-xx-large avatar center"><%= image_tag "jf-avatar.jpg", size: 36%></span>
</div>
<h2 class="txt-medium margin-none txt-tight-lines">Welcome, and thanks for signing up for Fizzy.</h2>
<p></p>
<p>To get you started, we set you up with a Fizzy board called <em>Playground</em>. Its got a few cards designed to help you learn Fizzy itself. Open each card, go through the simple steps, and youll be an expert in Fizzy in no time. Youll see the <em>Playground</em> when you close this message.</p>
<p>If you ever need a hand, please contact me directly at jason@37signals.com. I'm here for you, were all here for you.</p>
<p>Thanks again and all the best,</p>
<span class="welcome-letter__signature"></span>
<p><strong>Jason Fried</strong>, jason@37signals.com<br>
<em>CEO &amp; co-founder of 37signals, makers of Fizzy, Basecamp, and HEY</em>
</p>
</dialog>
</div>
+1 -1
View File
@@ -51,7 +51,7 @@ if ! command -v gh &>/dev/null; then
echo
echo "▸ Installing GitHub CLI"
if command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm gh
sudo pacman -S --noconfirm github-cli
elif command -v brew &>/dev/null; then
brew install gh
else
+8
View File
@@ -16,6 +16,14 @@ module ActionText
def storage_tracked_record
record.try(:storage_tracked_record)
end
def accessible_to?(user)
record.try(:accessible_to?, user) || record.try(:publicly_accessible?)
end
def publicly_accessible?
record.try(:publicly_accessible?)
end
end
end
end
+1
View File
@@ -40,6 +40,7 @@ module ActiveStorageDirectUploadsControllerExtensions
included do
include Authentication
include Authorization
skip_forgery_protection if: :authenticate_by_bearer_token
end
end
@@ -0,0 +1,64 @@
# Enforce storage ledger integrity by preventing blob reuse in tracked contexts.
#
# Two invariants:
# 1. Account match: blob.account_id == record.account_id (multi-tenant safety)
# 2. No reuse within tracked contexts: a blob can only have one tracked attachment
#
# With per-attachment reconcile, blob reuse inside an account wouldn't break correctness -
# ledger would still count each attach, and reconcile would agree. However, we intentionally
# forbid reuse (except templates) as a product/control decision:
# - Simpler mental model (one blob = one attachment)
# - Prevents accidental quota manipulation via direct blob_id reuse
# - Cleaner audit trail in ledger entries
#
# Scope note: The no-reuse validation only blocks reuse when the *new* attachment is tracked
# AND only checks *existing* attachments in Storage::TRACKED_RECORD_TYPES. A blob first
# attached to an untracked type (avatar/export) could theoretically be reused in a tracked
# context. This is acceptable - user-accessible blob IDs from untracked contexts are
# basically nonexistent in practice.
ActiveSupport.on_load(:active_storage_attachment) do
validate :blob_account_matches_record, on: :create
validate :no_tracked_blob_reuse, on: :create
private
# Multi-tenant safety: blob must belong to same account as record
# NOTE: Skips validation if record.account is nil. This is a theoretical bypass
# if someone attaches before account assignment, but our flows assign account
# before attachment. Global/unaccounted attachments (Identity/User avatars, exports)
# bypass tenancy checks via try(:account) returning nil - this is intentional as
# these classes don't participate in storage tracking.
def blob_account_matches_record
return unless record&.try(:account).present?
return if whitelisted_for_cross_account?
unless blob&.account_id == record.account.id
errors.add(:blob_id, "blob account must match record account")
end
end
# Ledger integrity: blob can only have one tracked attachment
def no_tracked_blob_reuse
tracked_record = record&.try(:storage_tracked_record)
return unless tracked_record.present?
return if whitelisted_for_cross_account?
# Check for existing attachment of this blob in tracked contexts
# Uses Storage::TRACKED_RECORD_TYPES constant to stay generic
existing = ActiveStorage::Attachment
.where(blob_id: blob_id)
.where(record_type: Storage::TRACKED_RECORD_TYPES)
.where.not(id: id)
.exists?
if existing
errors.add(:blob_id, "cannot reuse blob in tracked storage context")
end
end
def whitelisted_for_cross_account?
# Only template account blobs can be reused cross-tenant.
# When TEMPLATE_ACCOUNT_ID is nil, no exemptions are granted.
Storage::TEMPLATE_ACCOUNT_ID.present? && blob&.account_id == Storage::TEMPLATE_ACCOUNT_ID
end
end
+9 -1
View File
@@ -38,6 +38,14 @@ production: &production
schedule: every 60 seconds
<% end %>
beta: *production
beta:
# Only Solid Queue maintenance
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
clear_solid_queue_recurring_executions:
command: "SolidQueue::RecurringExecution.clear_in_batches"
schedule: every hour at minute 52
staging: *production
development: *production
+8
View File
@@ -796,6 +796,10 @@ __Response:__
"created_at": "2025-12-05T19:36:35.401Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
},
"card": {
"id": "03f5v9zo9qlcwwpyc0ascnikz",
"url": "http://fizzy.localhost:3006/897362094/cards/03f5v9zo9qlcwwpyc0ascnikz"
},
"reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions",
"url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz"
}
@@ -826,6 +830,10 @@ __Response:__
"created_at": "2025-12-05T19:36:35.401Z",
"url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7"
},
"card": {
"id": "03f5v9zo9qlcwwpyc0ascnikz",
"url": "http://fizzy.localhost:3006/897362094/cards/03f5v9zo9qlcwwpyc0ascnikz"
},
"reactions_url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz/reactions",
"url": "http://fizzy.localhost:3006/897362094/cards/3/comments/03f5v9zo9qlcwwpyc0ascnikz"
}
+79
View File
@@ -0,0 +1,79 @@
# Loofah scrubber to auto-link URLs and email addresses in HTML text nodes.
#
# This scrubber does not perform HTML sanitization; it's assumed that the input is already sanitized
# (for example, ActionText rich text).
class AutoLinkScrubber < Loofah::Scrubber
EXCLUDED_ELEMENTS = %w[a figcaption pre code].freeze
# This regexp is similar to URI::MailTo::EMAIL_REGEXP but uses \b word boundaries instead of \A/\z
# anchors, allowing it to match email addresses embedded within longer strings.
#
# It's named EMAIL_AUTOLINK_REGEXP (not EMAIL_REGEXP) to avoid confusing Brakeman's imprecise
# constant lookup, which otherwise assumes Identity's email validation uses this \b-anchored pattern.
# See https://github.com/presidentbeef/brakeman/pull/1981
EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https])
AUTOLINK_REGEXP = /(?<url>#{URL_REGEXP})|(?<email>#{EMAIL_AUTOLINK_REGEXP})/
TRAILING_PUNCTUATION = %(.?,:!;"'<>)
TRAILING_PUNCTUATION_REGEXP = /[#{Regexp.escape(TRAILING_PUNCTUATION)}]+\z/
def initialize
@direction = :top_down
end
def scrub(node)
return Loofah::Scrubber::STOP if EXCLUDED_ELEMENTS.include?(node.name)
if node.text?
replacement = autolink_text_node(node)
node.replace(replacement) if replacement
end
Loofah::Scrubber::CONTINUE
end
private
def autolink_text_node(node)
text = node.text
links = find_links(text)
return nil if links.empty?
doc = node.document
nodes = Nokogiri::XML::NodeSet.new(doc)
pos = 0
links.each do |link|
nodes << doc.create_text_node(text[pos...link[:start]]) if link[:start] > pos
nodes << doc.create_element("a", link[:text], href: link[:href], rel: "noopener noreferrer")
pos = link[:start] + link[:length]
end
nodes << doc.create_text_node(text[pos..]) if pos < text.length
nodes
end
def find_links(text)
links = []
text.scan(AUTOLINK_REGEXP) do
match_data = Regexp.last_match
start_pos = match_data.begin(0)
if match_data[:url]
url = clean_url(match_data[:url])
links << { start: start_pos, length: url.length, text: url, href: url }
else
email = match_data[:email]
links << { start: start_pos, length: email.length, text: email, href: "mailto:#{email}" }
end
end
links
end
def clean_url(url)
url.sub(TRAILING_PUNCTUATION_REGEXP, "")
end
end
@@ -0,0 +1,49 @@
ActiveSupport.on_load :active_storage_blob do
def accessible_to?(user)
attachments.includes(:record).any? { |attachment| attachment.accessible_to?(user) } || attachments.none?
end
def publicly_accessible?
attachments.includes(:record).any? { |attachment| attachment.publicly_accessible? }
end
end
ActiveSupport.on_load :active_storage_attachment do
def accessible_to?(user)
record.try(:accessible_to?, user)
end
def publicly_accessible?
record.try(:publicly_accessible?)
end
end
Rails.application.config.to_prepare do
module ActiveStorage::Authorize
extend ActiveSupport::Concern
include Authentication
included do
# Ensure require_authentication runs after set_blob.
skip_before_action :require_authentication
before_action :require_authentication, :ensure_accessible, unless: :publicly_accessible_blob?
end
private
def publicly_accessible_blob?
@blob.publicly_accessible?
end
def ensure_accessible
unless @blob.accessible_to?(Current.user)
head :forbidden
end
end
end
ActiveStorage::Blobs::RedirectController.include ActiveStorage::Authorize
ActiveStorage::Blobs::ProxyController.include ActiveStorage::Authorize
ActiveStorage::Representations::RedirectController.include ActiveStorage::Authorize
ActiveStorage::Representations::ProxyController.include ActiveStorage::Authorize
end
+1 -1
View File
@@ -1,4 +1,4 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET)
SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
+1
View File
@@ -0,0 +1 @@
secrets.beta
+1
View File
@@ -0,0 +1 @@
secrets.beta
+1
View File
@@ -0,0 +1 @@
secrets.beta
+1
View File
@@ -0,0 +1 @@
secrets.beta
+1 -1
View File
@@ -1,4 +1,4 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET)
SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
+1 -1
View File
@@ -1,4 +1,4 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET)
SECRETS=$(kamal secrets fetch --adapter 1password --account 23QPQDKZC5BKBIIG7UGT5GR5RM --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
+7 -2
View File
@@ -64,9 +64,14 @@ This environment uses a FlashBlade bucket for blob storage.
Beta is primarily intended for testing product features. It uses the same production database and Active Storage configuration.
Beta tenant is:
There are 4 beta environments:
- https://fizzy-beta.37signals.com
- https://beta1.fizzy-beta.com
- https://beta2.fizzy-beta.com
- https://beta3.fizzy-beta.com
- https://beta4.fizzy-beta.com
Deploy with: `bin/kamal deploy -d beta1` (or `-d beta2`, `-d beta3`, `-d beta4`)
### Staging
+1 -1
View File
@@ -42,7 +42,7 @@
box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color);
color: var(--settings-subscription-text-color);
margin-inline: auto;
padding: 0 0.3em 0 1.2em;
padding: 0.3em 0.3em 0.3em 1.2em;
transform: rotate(-1deg);
@media (max-width: 640px) {
+5 -1
View File
@@ -9,7 +9,11 @@ module SubscriptionsHelper
elsif subscription.canceled?
"Your Fizzy subscription ended on"
else
"Your next payment is <b>#{ number_to_currency(subscription.next_amount_due, strip_insignificant_zeros: true) }</b> on".html_safe
"Your next payment is <b>#{ format_currency(subscription.next_amount_due) }</b> on".html_safe
end
end
def format_currency(amount)
number_to_currency(amount, precision: (amount % 1).zero? ? 0 : 2)
end
end
@@ -10,9 +10,9 @@
<% end %>
</h3>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) %>/month</strong> for <strong>unlimited cards</strong>, <strong>unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= format_currency(Plan.paid.price) %>/month</strong> for <strong>unlimited cards</strong>, <strong>unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<%= button_to "Upgrade to #{Plan.paid.name} for #{ number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<%= button_to "Upgrade to #{Plan.paid.name} for #{ format_currency(Plan.paid.price) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<p>Cancel anytime, no contracts, take your data with you whenever.</p>
<p class="settings-subscription__footer txt-small margin-none-block-end">Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes <%= number_with_delimiter(Plan.free.card_limit) %> cards, unlimited users, and <%= storage_to_human_size(Plan.free.storage_limit) %> of storage.</p>
@@ -10,8 +10,10 @@
<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %>
<p class="margin-block-start-half">
The <strong><%= Current.account.plan.name %></strong> plan includes <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong>. Upgrade to get <strong><%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %></strong> extra storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more.
The <strong><%= Current.account.plan.name %></strong> plan includes <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong>. Upgrade to get <strong><%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %></strong> extra storage for <%= format_currency(Plan.paid_with_extra_storage.price - Plan.paid.price) %>/month more.
</p>
<%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name} for #{ number_to_currency(Plan.paid_with_extra_storage.price, strip_insignificant_zeros: true) }/month", account_subscription_upgrade_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
<% end %>
<% if Current.account.subscription %>
@@ -9,9 +9,9 @@
<div class="settings-subscription__footer txt-small margin-block">
Right now youre on the <strong><%= Current.account.plan.name %></strong> plan which includes unlimited cards, unlimited users, and <%= storage_to_human_size(Current.account.plan.storage_limit) %> of storage.
<% if subscription.plan == Plan.paid %>
<% if subscription.plan == Plan.paid && !(Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit?) %>
<%= button_to "Upgrade", account_subscription_upgrade_path, class: "btn btn--plain settings-subscription__link txt-link", form: { class: "flex-inline flex-wrap", data: { turbo: false } } %>
to <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more.
to <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> storage for <%= format_currency(Plan.paid_with_extra_storage.price - Plan.paid.price) %>/month more.
<% elsif subscription.plan == Plan.paid_with_extra_storage && !Current.account.exceeding_storage_limit? %>
<%= button_to "Downgrade", account_subscription_downgrade_path, class: "btn btn--plain settings-subscription__link txt-link", form: { class: "flex-inline flex-wrap", data: { turbo: false } } %>
to <%= storage_to_human_size(Plan.paid.storage_limit) %> storage.
+49 -32
View File
@@ -1,46 +1,63 @@
<div class="panel shadow center" style="--panel-size: 50ch;">
<div class="flex flex-column gap txt-medium">
<h1 class="txt-x-large font-weight-black margin-block-none">Edit Account <%= @account.external_account_id %></h1>
<% @page_title = "Account admin" %>
<dl class="flex flex-column gap-half margin-block-none">
<div class="flex gap-half">
<dt class="font-weight-bold">Name:</dt>
<dd class="margin-inline-start-none"><%= @account.name %></dd>
</div>
<div class="flex gap-half">
<dt class="font-weight-bold">Actual card count:</dt>
<dd class="margin-inline-start-none"><%= @account.cards_count %></dd>
</div>
<div class="flex gap-half">
<dt class="font-weight-bold">Actual bytes used:</dt>
<dd class="margin-inline-start-none"><%= storage_to_human_size(@account.bytes_used) %></dd>
</div>
</dl>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Accounts", saas.admin_accounts_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>
<div class="panel panel--wide shadow center">
<div class="flex flex-column gap txt-medium">
<h1 class="txt-x-large font-weight-black margin-none">Edit Account <%= @account.external_account_id %></h1>
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %>
<div class="flex align-center gap-half">
<%= form.label :card_count, "Override card count", class: "font-weight-bold" %>
<%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %>
</div>
<table class="txt-align-start">
<tr>
<td class="txt-subtle">Account Name:</td>
<td class="margin-inline-start-none"><%= @account.name %></td>
</tr>
<tr>
<td class="txt-subtle">Subscription plan:</td>
<td class="margin-inline-start-none"><%= @account.plan.name %></td>
</tr>
<tr>
<td class="txt-subtle">Actual card count:</td>
<td class="margin-inline-start-none"><%= @account.cards_count %> cards </td>
</tr>
<tr>
<td class="txt-subtle">Actual bytes used:</td>
<td class="margin-inline-start-none"><%= storage_to_human_size(@account.bytes_used) %></td>
</tr>
<tr>
<td>
<%= form.label :card_count, class: "txt-subtle" do %>
<span>Override card count</span><br><%= "(Exceeds plan limit)" if @account.exceeding_card_limit? %>
<% end %>
</td>
<td><%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %></td>
</tr>
<tr>
<td>
<%= form.label :bytes_used, class: "txt-subtle" do %>
<span>Override bytes used</span><br><%= "(Exceeds plan limit)" if @account.exceeding_storage_limit? %>
<% end %>
</td>
<td><%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %></td>
</tr>
</table>
<div class="flex align-center gap-half">
<%= form.label :bytes_used, "Override bytes used", class: "font-weight-bold" %>
<%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
</div>
<button type="submit" class="btn btn--reversed">Save</button>
<button type="submit" class="btn btn--link full-width">Save changes</button>
<% end %>
<% if @account.overridden_limits %>
<%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %>
<%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative full-width" %>
<% end %>
<% if @account.comped? %>
<%= button_to "Uncomp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %>
<%= button_to "Uncomp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :delete, class: "btn btn--plain txt-negative txt-underline" %>
<% else %>
<%= button_to "Comp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :post, class: "btn btn--positive" %>
<%= button_to "Comp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :post, class: "btn btn--plain txt-positive txt-underline" %>
<% end %>
<%= link_to "Back to accounts", saas.admin_accounts_path, class: "btn btn--plain txt-link" %>
</div>
</div>
+12 -5
View File
@@ -1,6 +1,13 @@
<h1>Find Account</h1>
<div class="panel panel--centered">
<div class="flex flex-column gap txt-medium">
<h1 class="txt-x-large font-weight-black margin-none">Find an account</h1>
<%= form_with url: saas.admin_account_search_path, class: "flex flex-column gap" do |form| %>
<%= form.text_field :q, placeholder: "Account ID", autofocus: true, class: "input" %>
<%= form.submit "Search", class: "btn btn--link" %>
<% end %>
</div>
</div>
<%= form_with url: saas.admin_account_search_path do |form| %>
<%= form.text_field :q, placeholder: "Account ID", autofocus: true %>
<%= form.submit "Search" %>
<% end %>
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env bash
url=$(op read "op://Deploy/Deploy Chatbot/url" --account Basecamp)
url=$(op read "op://Deploy/Deploy Chatbot/url" --account 23QPQDKZC5BKBIIG7UGT5GR5RM)
curl -s -d content="[Fizzy] ${1}" "${url}"
+63 -8
View File
@@ -1,15 +1,70 @@
<%
raise "The BETA_NUMBER environment variable must be given" unless ENV["BETA_NUMBER"]
@data = {
"1" => {
"hosts" => {
"web" => ["fizzy-beta-app-101.df-iad-int.37signals.com"],
"jobs" => ["fizzy-beta-jobs-101.df-iad-int.37signals.com"],
"lb" => "fizzy-beta-lb-101.df-iad-int.37signals.com"
},
"dbs" => {
"solidqueue" => "fizzy-beta-solidqueue-db-101.df-iad-int.37signals.com"
}
},
"2" => {
"hosts" => {
"web" => ["fizzy-beta-app-102.df-iad-int.37signals.com"],
"jobs" => ["fizzy-beta-jobs-102.df-iad-int.37signals.com"],
"lb" => "fizzy-beta-lb-102.df-iad-int.37signals.com"
},
"dbs" => {
"solidqueue" => "fizzy-beta-solidqueue-db-102.df-iad-int.37signals.com"
}
},
"3" => {
"hosts" => {
"web" => ["fizzy-beta-app-103.df-iad-int.37signals.com"],
"jobs" => ["fizzy-beta-jobs-103.df-iad-int.37signals.com"],
"lb" => "fizzy-beta-lb-103.df-iad-int.37signals.com"
},
"dbs" => {
"solidqueue" => "fizzy-beta-solidqueue-db-103.df-iad-int.37signals.com"
}
},
"4" => {
"hosts" => {
"web" => ["fizzy-beta-app-104.df-iad-int.37signals.com"],
"jobs" => ["fizzy-beta-jobs-104.df-iad-int.37signals.com"],
"lb" => "fizzy-beta-lb-104.df-iad-int.37signals.com"
},
"dbs" => {
"solidqueue" => "fizzy-beta-solidqueue-db-104.df-iad-int.37signals.com"
}
}
}
@beta_number = ENV["BETA_NUMBER"]
raise "Beta #{@beta_number} doesn't appear to be defined" unless @data[@beta_number]
@web_hosts = @data[@beta_number]["hosts"]["web"]
@job_hosts = @data[@beta_number]["hosts"]["jobs"]
@lb_host = @data[@beta_number]["hosts"]["lb"]
@solidqueue_db = @data[@beta_number]["dbs"]["solidqueue"]
%>
retain_containers: 1
servers:
web:
hosts:
- fizzy-beta-app-01.sc-chi-int.37signals.com: sc_chi
- fizzy-beta-app-101.df-iad-int.37signals.com: df_iad
- <%= @web_hosts[0] %>: df_iad
labels:
otel_scrape_enabled: true
# we don't run the jobs role in beta
allow_empty_roles: true
jobs:
hosts:
- <%= @job_hosts[0] %>: df_iad
labels:
otel_scrape_enabled: true
proxy:
ssl: false
@@ -19,12 +74,13 @@ ssh:
env:
clear:
APP_FQDN: beta<%= @beta_number %>.fizzy-beta.com
RAILS_ENV: beta
MYSQL_DATABASE_HOST: fizzy-mysql-primary
MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica
MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary
MYSQL_SOLID_QUEUE_HOST: fizzy-mysql-primary
MYSQL_SOLID_CACHE_HOST: fizzy-beta-solidcache-db-101
MYSQL_SOLID_QUEUE_HOST: <%= @solidqueue_db %>
MYSQL_SOLID_CACHE_HOST: fizzy-beta-solidcache-db-101.df-iad-int.37signals.com
secret:
- RAILS_MASTER_KEY
- MYSQL_ALTER_PASSWORD
@@ -49,14 +105,13 @@ env:
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
tags:
sc_chi: {}
df_iad:
PRIMARY_DATACENTER: true
accessories:
load-balancer:
image: basecamp/kamal-proxy:lb
host: fizzy-beta-lb-01.sc-chi-int.37signals.com
host: <%= @lb_host %>
labels:
otel_role: load-balancer
otel_service: fizzy-load-balancer
+2
View File
@@ -0,0 +1,2 @@
<% ENV["BETA_NUMBER"] = "1" %>
<%= ERB.new(File.read(File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "config", "deploy.beta.yml")), trim_mode: 2).result %>
+2
View File
@@ -0,0 +1,2 @@
<% ENV["BETA_NUMBER"] = "2" %>
<%= ERB.new(File.read(File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "config", "deploy.beta.yml")), trim_mode: 2).result %>
+2
View File
@@ -0,0 +1,2 @@
<% ENV["BETA_NUMBER"] = "3" %>
<%= ERB.new(File.read(File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "config", "deploy.beta.yml")), trim_mode: 2).result %>
+2
View File
@@ -0,0 +1,2 @@
<% ENV["BETA_NUMBER"] = "4" %>
<%= ERB.new(File.read(File.join(Gem::Specification.find_by_name("fizzy-saas").gem_dir, "config", "deploy.beta.yml")), trim_mode: 2).result %>
+3 -3
View File
@@ -1,8 +1,8 @@
require_relative "production"
Rails.application.configure do
config.action_mailer.smtp_settings[:domain] = "fizzy-beta.37signals.com"
config.action_mailer.smtp_settings[:domain] = ENV.fetch("APP_FQDN", "fizzy-beta.com")
config.action_mailer.smtp_settings[:address] = "smtp-outbound-staging"
config.action_mailer.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" }
config.action_controller.default_url_options = { host: "fizzy-beta.37signals.com", protocol: "https" }
config.action_mailer.default_url_options = { host: ENV.fetch("APP_FQDN", "fizzy-beta.com"), protocol: "https" }
config.action_controller.default_url_options = { host: ENV.fetch("APP_FQDN", "fizzy-beta.com"), protocol: "https" }
end
+1 -1
View File
@@ -27,7 +27,7 @@ end
# Fetch secrets from 1Password
secrets_escaped = `kamal secrets fetch \
--adapter 1password \
--account basecamp \
--account 23QPQDKZC5BKBIIG7UGT5GR5RM \
--from "Deploy/Fizzy" \
"Development/STRIPE_SECRET_KEY" \
"Development/STRIPE_MONTHLY_V1_PRICE_ID" \
+31 -7
View File
@@ -2,14 +2,38 @@
set -e
# fizzy-beta-lb-01.sc-chi-int.37signals.com
#
ssh app@fizzy-beta-lb-01.sc-chi-int.37signals.com \
# Beta 1: fizzy-beta-lb-101 -> fizzy-beta-app-101
ssh app@fizzy-beta-lb-101.df-iad-int.37signals.com \
docker exec fizzy-load-balancer \
kamal-proxy deploy fizzy \
--force \
--tls \
--host=fizzy-beta.37signals.com \
--writer-affinity-timeout=0 \
--target=fizzy-beta-app-101.df-iad-int.37signals.com \
--read-target=fizzy-beta-app-01.sc-chi-int.37signals.com
--host=beta1.fizzy-beta.com \
--target=fizzy-beta-app-101.df-iad-int.37signals.com
# Beta 2: fizzy-beta-lb-102 -> fizzy-beta-app-102
ssh app@fizzy-beta-lb-102.df-iad-int.37signals.com \
docker exec fizzy-load-balancer \
kamal-proxy deploy fizzy \
--force \
--tls \
--host=beta2.fizzy-beta.com \
--target=fizzy-beta-app-102.df-iad-int.37signals.com
# Beta 3: fizzy-beta-lb-103 -> fizzy-beta-app-103
ssh app@fizzy-beta-lb-103.df-iad-int.37signals.com \
docker exec fizzy-load-balancer \
kamal-proxy deploy fizzy \
--force \
--tls \
--host=beta3.fizzy-beta.com \
--target=fizzy-beta-app-103.df-iad-int.37signals.com
# Beta 4: fizzy-beta-lb-104 -> fizzy-beta-app-104
ssh app@fizzy-beta-lb-104.df-iad-int.37signals.com \
docker exec fizzy-load-balancer \
kamal-proxy deploy fizzy \
--force \
--tls \
--host=beta4.fizzy-beta.com \
--target=fizzy-beta-app-104.df-iad-int.37signals.com
+19 -2
View File
@@ -8,7 +8,21 @@
# Run via Kamal:
# kamal app exec -d <stage> -p --reuse "bin/rails runner script/migrations/backfill-storage-ledger.rb"
#
# Safe to re-run: skips attachments that already have entries (by blob_id).
# Safe to re-run: skips attachments that already have entries (by blob_id + recordable).
#
# IMPORTANT: Before running in production, verify zero historic blob reuse in tracked contexts:
#
# ActiveStorage::Attachment
# .joins(:blob)
# .where(record_type: Storage::TRACKED_RECORD_TYPES)
# .where.not(active_storage_blobs: { account_id: Storage::TEMPLATE_ACCOUNT_ID })
# .select(:blob_id)
# .group(:blob_id)
# .having("COUNT(*) > 1")
# .count
# # Should return empty hash if no reuse exists in tracked contexts
#
# If reuse exists (excluding template blobs), fix the data first.
class BackfillStorageLedger
def run
puts "Backfilling storage entries…"
@@ -26,7 +40,10 @@ class BackfillStorageLedger
ActiveStorage::Attachment.includes(:blob).find_each do |attachment|
record = attachment.record.try(:storage_tracked_record)
if record.nil? || Storage::Entry.exists?(blob_id: attachment.blob_id)
# Backfill creates one entry PER ATTACHMENT (not per blob) to match the ledger model.
# Storage tracking is a business abstraction at the attachment level.
# IMPORTANT: This assumes no historic blob reuse. Run pre-check query above first.
if record.nil? || Storage::Entry.exists?(blob_id: attachment.blob_id, recordable: record)
skipped += 1
next
end
@@ -3,9 +3,9 @@
require_relative "../config/environment"
domains = {
"production" => "box-car.com",
"beta" => "fizzy-beta.37signals.com",
"staging" => "fizzy.37signals-staging.com"
"production" => "app.fizzy.do",
"beta" => ENV.fetch("APP_FQDN", "beta1.fizzy-beta.com"),
"staging" => "app.fizzy-staging.com"
}
def fix_attachments(rich_text)
@@ -54,19 +54,33 @@ class ActiveStorage::DirectUploadsControllerTest < ActionDispatch::IntegrationTe
test "create unauthenticated" do
post rails_direct_uploads_path,
params: @blob_params.merge(authenticity_token: csrf_token),
params: @blob_params,
as: :json
assert_response :redirect
end
test "create in another account is forbidden" do
sign_in_as :david
post rails_direct_uploads_path(script_name: "/#{ActiveRecord::FixtureSet.identify("initech")}"),
params: @blob_params,
as: :json
assert_response :forbidden
end
test "create with valid access token in another account is forbidden" do
post rails_direct_uploads_path(script_name: "/#{ActiveRecord::FixtureSet.identify("initech")}"),
params: @blob_params,
headers: bearer_token_header(identity_access_tokens(:davids_api_token).token),
as: :json
assert_response :forbidden
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
@@ -63,10 +63,12 @@ class Cards::CommentsControllerTest < ActionDispatch::IntegrationTest
test "show as JSON" do
comment = comments(:logo_agreement_kevin)
get card_comment_path(cards(:logo), comment), as: :json
get card_comment_path(comment.card, comment), as: :json
assert_response :success
assert_equal comment.id, @response.parsed_body["id"]
assert_equal comment.card.id, @response.parsed_body.dig("card", "id")
assert_equal card_url(comment.card.id), @response.parsed_body.dig("card", "url")
end
test "update as JSON" do
@@ -29,6 +29,18 @@ class Signup::CompletionsControllerTest < ActionDispatch::IntegrationTest
assert_response :redirect, "Valid params should redirect"
end
test "shows welcome letter after signup" do
untenanted do
post signup_completion_path, params: {
signup: {
full_name: @signup.full_name
}
}
end
assert flash[:welcome_letter]
end
test "create with blank name" do
untenanted do
post signup_completion_path, params: {
+34 -19
View File
@@ -3,71 +3,86 @@ require "test_helper"
class HtmlHelperTest < ActionView::TestCase
test "convert URLs into anchor tags" do
assert_equal_html \
%(<p>Check this: <a href="https://example.com" rel="noreferrer">https://example.com</a></p>),
%(<p>Check this: <a href="https://example.com" rel="noopener noreferrer">https://example.com</a></p>),
format_html("<p>Check this: https://example.com</p>")
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a></p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a></p>),
format_html("<p>Check this: https://example.com/</p>")
end
test "convert multiple URLs in the same string" do
assert_equal_html \
%(Visit <a href="https://foo.com/" rel="noopener noreferrer">https://foo.com/</a>. Also see <a href="https://bar.com/" rel="noopener noreferrer">https://bar.com/</a>!),
format_html("Visit https://foo.com/. Also see https://bar.com/!")
end
test "don't include punctuation in URL autolinking" do
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>!</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>!</p>),
format_html("<p>Check this: https://example.com/!</p>")
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>.</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>.</p>),
format_html("<p>Check this: https://example.com/.</p>")
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>?</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>?</p>),
format_html("<p>Check this: https://example.com/?</p>")
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>,</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>,</p>),
format_html("<p>Check this: https://example.com/,</p>")
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>:</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>:</p>),
format_html("<p>Check this: https://example.com/:</p>")
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>;</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>;</p>),
format_html("<p>Check this: https://example.com/;</p>")
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>"</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>"</p>),
format_html("<p>Check this: https://example.com/\"</p>")
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>'</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>'</p>),
format_html("<p>Check this: https://example.com/'</p>")
# trailing entities that decode to punctuation
# use assert_equal and not assert_equal_html to make sure we're getting entities correct
assert_equal \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>&lt;</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>&lt;</p>),
format_html("<p>Check this: https://example.com/&lt;</p>")
assert_equal \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>&gt;</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>&gt;</p>),
format_html("<p>Check this: https://example.com/&gt;</p>")
assert_equal \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>"</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>"</p>),
format_html("<p>Check this: https://example.com/&quot;</p>")
# multiple punctuation characters including entities
assert_equal_html \
%(<p>Check this: <a href="https://example.com/" rel="noreferrer">https://example.com/</a>!?;</p>),
%(<p>Check this: <a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>!?;</p>),
format_html("<p>Check this: https://example.com/!?;</p>")
assert_equal_html \
%(&lt;img src="<a href="https://example.com/" rel="noreferrer">https://example.com/</a>"&gt;),
%(&lt;img src="<a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>"&gt;),
format_html(%(&lt;img src=&quot;https://example.com/&quot;&gt;))
assert_equal_html \
%(&lt;img src="<a href="https://example.com/" rel="noreferrer">https://example.com/</a>"!&gt;),
%(&lt;img src="<a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>"!&gt;),
format_html(%(&lt;img src=&quot;https://example.com/&quot;!&gt;))
end
test "make sure the linked content is properly sanitized" do
# https://hackerone.com/reports/3481093
result = format_html(%(https://google.com/\"&gt;test&lt;/a&gt;&lt;input&gt;&lt;/input&gt;))
assert_no_match(/<input>/i, result, "should not create an input element")
result = format_html(%(https://google.com/\"&gt;&lt;script&gt;alert('xss')&lt;/script&gt;))
assert_no_match(/<script>/i, result, "should not create a script element")
end
test "handle URLs with query parameters" do
# use assert_equal and not assert_equal_html to make sure we're getting entities correct
assert_equal \
%(<p>Check this: <a href="https://example.com/a?b=c&amp;d=e" rel="noreferrer">https://example.com/a?b=c&amp;d=e</a></p>),
%(<p>Check this: <a href="https://example.com/a?b=c&amp;d=e" rel="noopener noreferrer">https://example.com/a?b=c&amp;d=e</a></p>),
format_html("<p>Check this: https://example.com/a?b=c&amp;d=e</p>")
assert_equal \
%(<p>Check this: <a href="https://example.com/a?b=c&amp;d=e" rel="noreferrer">https://example.com/a?b=c&amp;d=e</a></p>),
%(<p>Check this: <a href="https://example.com/a?b=c&amp;d=e" rel="noopener noreferrer">https://example.com/a?b=c&amp;d=e</a></p>),
format_html("<p>Check this: https://example.com/a?b=c&d=e</p>")
end
@@ -79,7 +94,7 @@ class HtmlHelperTest < ActionView::TestCase
test "convert email addresses into mailto links" do
assert_equal_html \
%(<p>Contact us at <a href="mailto:support@example.com">support@example.com</a></p>),
%(<p>Contact us at <a href="mailto:support@example.com" rel="noopener noreferrer">support@example.com</a></p>),
format_html("<p>Contact us at support@example.com</p>")
end
@@ -0,0 +1,220 @@
require "test_helper"
class ActiveStorageAuthorizationTest < ActionDispatch::IntegrationTest
setup do
Current.session = sessions(:david)
@account = accounts("37s")
@board = boards(:writebook)
@card = cards(:logo)
@blob = attach_blob_to_card(@card)
end
test "authenticated user with board access can view blob" do
sign_in_as :david
get rails_blob_path(@blob, disposition: :inline)
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
test "authenticated user without board access cannot view blob" do
sign_in_as :mike
get rails_blob_path(@blob, disposition: :inline)
assert_response :forbidden
end
test "unauthenticated user cannot view blob" do
get rails_blob_path(@blob, disposition: :inline)
assert_response :redirect
assert_match %r{/session/new}, response.location
end
test "authenticated user with board access can view representation" do
sign_in_as :david
get rails_representation_path(@blob.representation(resize_to_limit: [ 100, 100 ]))
assert_response :redirect
assert_match %r{rails/active_storage/}, response.location
end
test "authenticated user without board access cannot view representation" do
sign_in_as :mike
get rails_representation_path(@blob.representation(resize_to_limit: [ 100, 100 ]))
assert_response :forbidden
end
test "unauthenticated user can view blob on published board with published card" do
@board.publish
get rails_blob_path(@blob, disposition: :inline)
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
test "unauthenticated user cannot view blob on published board with draft card" do
@board.publish
# Create the draft card and attachment with proper Current context
draft_blob = nil
Current.with(account: @account, session: sessions(:david)) do
draft_card = @board.cards.create!(title: "Draft", status: :drafted, creator: users(:david))
draft_card.image.attach io: file_fixture("moon.jpg").open, filename: "draft.jpg", content_type: "image/jpeg"
draft_blob = draft_card.image.blob
end
get rails_blob_path(draft_blob, disposition: :inline)
assert_response :redirect
assert_match %r{/session/new}, response.location
end
# Rich text embeds in cards
test "authenticated user with board access can view rich text embed in card" do
sign_in_as :david
blob = attach_blob_as_rich_text_embed(@card)
get rails_blob_path(blob, disposition: :inline)
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
test "authenticated user without board access cannot view rich text embed in card" do
sign_in_as :mike
blob = attach_blob_as_rich_text_embed(@card)
get rails_blob_path(blob, disposition: :inline)
assert_response :forbidden
end
test "unauthenticated user can view rich text embed in card on published board" do
@board.publish
blob = attach_blob_as_rich_text_embed(@card)
get rails_blob_path(blob, disposition: :inline)
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
# Rich text embeds in comments
test "authenticated user with board access can view rich text embed in comment" do
sign_in_as :david
comment = comments(:logo_1)
blob = attach_blob_as_rich_text_embed(comment)
get rails_blob_path(blob, disposition: :inline)
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
test "authenticated user without board access cannot view rich text embed in comment" do
sign_in_as :mike
comment = comments(:logo_1)
blob = attach_blob_as_rich_text_embed(comment)
get rails_blob_path(blob, disposition: :inline)
assert_response :forbidden
end
test "unauthenticated user can view rich text embed in comment on published board" do
@board.publish
comment = comments(:logo_1)
blob = attach_blob_as_rich_text_embed(comment)
get rails_blob_path(blob, disposition: :inline)
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
test "unauthenticated user can view avatar" do
blob = attach_avatar_to(users(:david))
get rails_blob_path(blob, disposition: :inline)
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
test "unauthenticated user can view avatar thumbnail" do
blob = attach_avatar_to(users(:david))
get rails_representation_path(blob.representation(resize_to_fill: [ 256, 256 ]))
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
# Account exports
test "export owner can download their export" do
sign_in_as :david
blob = create_export_blob_for(users(:david))
get rails_blob_path(blob, disposition: :attachment)
assert_response :redirect
assert_match %r{rails/active_storage}, response.location
end
test "non-owner cannot download another user's export" do
sign_in_as :jz
blob = create_export_blob_for(users(:david))
get rails_blob_path(blob, disposition: :attachment)
assert_response :forbidden
end
test "unauthenticated user cannot download export" do
blob = create_export_blob_for(users(:david))
get rails_blob_path(blob, disposition: :attachment)
assert_response :redirect
assert_match %r{/session/new}, response.location
end
private
def attach_blob_to_card(card)
Current.with(session: sessions(:david)) do
card.image.attach io: file_fixture("moon.jpg").open, filename: "test.jpg", content_type: "image/jpeg"
card.image.blob
end
end
def attach_blob_as_rich_text_embed(container)
Current.with(account: @account, session: sessions(:david)) do
blob = ActiveStorage::Blob.create_and_upload! \
io: file_fixture("moon.jpg").open,
filename: "embed.jpg",
content_type: "image/jpeg"
attachment_html = ActionText::Attachment.from_attachable(blob).to_html
if container.respond_to?(:description)
container.update!(description: "<p>Description with image: #{attachment_html}</p>")
else
container.update!(body: "<p>Body with image: #{attachment_html}</p>")
end
blob.reload
end
end
def create_export_blob_for(user)
export = Account::Export.create!(account: @account, user: user)
export.file.attach io: StringIO.new("test export content"), filename: "export.zip", content_type: "application/zip"
export.file.blob
end
def attach_avatar_to(user)
Current.with(account: @account, session: sessions(:david)) do
user.avatar.attach io: file_fixture("moon.jpg").open, filename: "avatar.jpg", content_type: "image/jpeg"
user.avatar.blob
end
end
end
+25
View File
@@ -45,4 +45,29 @@ class Storage::ReconcileJobTest < ActiveJob::TestCase
test "job queued to backend queue" do
assert_equal "backend", Storage::ReconcileJob.new.queue_name
end
test "job has concurrency limit by owner" do
job = Storage::ReconcileJob.new(@board)
# limits_concurrency is a Solid Queue feature
# Just verify the job can be instantiated and has the correct queue
assert_equal "backend", job.queue_name
end
test "job raises ReconcileAborted when reconcile fails" do
# Use perform_now with a fresh board that we can stub
board = @account.boards.create!(name: "Abort Test Board", creator: users(:david))
# Prepend a module to intercept reconcile_storage
intercept = Module.new do
def reconcile_storage
false
end
end
board.singleton_class.prepend(intercept)
assert_raises Storage::ReconcileJob::ReconcileAborted do
# Call perform directly to avoid serialization
Storage::ReconcileJob.new.perform(board)
end
end
end
+10
View File
@@ -32,4 +32,14 @@ class Event::DescriptionTest < ActiveSupport::TestCase
assert_includes description.to_plain_text, "David added"
end
test "escapes html in card titles in plain text description" do
card = cards(:logo)
card.update_column(:title, "<script>alert('xss')</script>")
description = events(:logo_published).description_for(users(:david))
assert_includes description.to_plain_text, "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
assert_not_includes description.to_plain_text, "<script>"
end
end
+32
View File
@@ -51,6 +51,38 @@ class SsrfProtectionTest < ActiveSupport::TestCase
assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("multi.example.com")
end
# IPv6 address format tests (SSRF bypass prevention)
test "blocks IPv4-mapped IPv6 addresses with private IPs" do
stub_dns_resolution("::ffff:192.168.1.1")
assert_nil SsrfProtection.resolve_public_ip("mapped-private.example.com")
end
test "blocks IPv4-mapped IPv6 addresses with link-local IPs" do
stub_dns_resolution("::ffff:169.254.169.254")
assert_nil SsrfProtection.resolve_public_ip("mapped-metadata.example.com")
end
test "blocks IPv4-mapped IPv6 addresses even with public IPs" do
stub_dns_resolution("::ffff:93.184.216.34")
assert_nil SsrfProtection.resolve_public_ip("mapped-public.example.com")
end
test "blocks IPv4-compatible IPv6 addresses with private IPs" do
stub_dns_resolution("::192.168.1.1")
assert_nil SsrfProtection.resolve_public_ip("compat-private.example.com")
end
test "blocks IPv4-compatible IPv6 addresses with link-local IPs" do
stub_dns_resolution("::169.254.169.254")
assert_nil SsrfProtection.resolve_public_ip("compat-metadata.example.com")
end
test "blocks IPv4-compatible IPv6 addresses even with public IPs" do
stub_dns_resolution("::93.184.216.34")
assert_nil SsrfProtection.resolve_public_ip("compat-public.example.com")
end
private
def stub_dns_resolution(*ips)
dns_mock = mock("dns")
+28 -21
View File
@@ -50,12 +50,14 @@ class Storage::EntryTest < ActiveSupport::TestCase
end
end
test "record accepts _id params for after_destroy_commit snapshots" do
test "record works with destroyed records (destroyed? check)" do
# Simulate a destroyed record - .id still works after destroy
@card.destroy
entry = Storage::Entry.record \
account_id: @account.id,
board_id: @board.id,
recordable_type: "Card",
recordable_id: @card.id,
account: @account,
board: @board,
recordable: @card,
delta: 2048,
operation: "detach"
@@ -110,31 +112,36 @@ class Storage::EntryTest < ActiveSupport::TestCase
end
end
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.
Account.stubs(:find_by).returns(nil)
test "record skips entirely when account is destroyed" do
# No need to track storage for deleted accounts
@account.destroy
assert_no_enqueued_jobs only: Storage::MaterializeJob do
Storage::Entry.record \
assert_no_difference "Storage::Entry.count" do
result = Storage::Entry.record \
account: @account,
delta: 1024,
operation: "attach"
assert_nil result
end
end
test "record does not enqueue board job when board is deleted" do
Board.stubs(:find_by).returns(nil)
test "record does not enqueue board job when board is destroyed" do
@board.destroy
# Account job still enqueued, but board job is not
entry = Storage::Entry.record \
account: @account,
board: @board,
delta: 1024,
operation: "attach"
# Account job still enqueued, but destroyed board skips its job
jobs = []
assert_enqueued_with job: Storage::MaterializeJob, args: [ @account ] do
Storage::Entry.record \
account: @account,
board: @board,
delta: 1024,
operation: "attach"
end
assert_not_nil entry
# Verify board job was NOT enqueued
board_jobs = enqueued_jobs.select { |j| j["arguments"].include?(@board.to_global_id.to_s) }
assert_empty board_jobs
end
test "entries belong to account" do
+88
View File
@@ -0,0 +1,88 @@
require "test_helper"
class Storage::NoReuseTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
@account = accounts("37s")
Current.account = @account # Ensure blobs get correct account_id
@board = @account.boards.create!(name: "Test", creator: users(:david))
end
# No-reuse validation
# NOTE: For persisted records, ActiveStorage::Attached::One#attach raises
# ActiveRecord::RecordNotSaved when validation fails.
test "rejects attaching blob that already has tracked attachment" do
blob = ActiveStorage::Blob.create_and_upload! \
io: StringIO.new("x" * 1000),
filename: "test.png",
content_type: "image/png"
# First attachment succeeds
card1 = @board.cards.create!(title: "Card 1", creator: users(:david))
card1.image.attach(blob)
assert card1.image.attached?
# Second attachment of same blob fails
card2 = @board.cards.create!(title: "Card 2", creator: users(:david))
assert_raises ActiveRecord::RecordNotSaved do
card2.image.attach(blob)
end
# Verify only one attachment exists for this blob
assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count
end
test "rejects cross-account blob attachment" do
other_account = Account.create!(name: "Other")
other_board = other_account.boards.create!(name: "Other Board", creator: users(:david))
# Blob created in @account context
blob = ActiveStorage::Blob.create_and_upload! \
io: StringIO.new("x" * 1000),
filename: "test.png",
content_type: "image/png"
card = other_board.cards.create!(title: "Card", creator: users(:david))
assert_raises ActiveRecord::RecordNotSaved do
card.image.attach(blob)
end
# Verify attachment was not created (blob account doesn't match record account)
assert_not card.reload.image.attached?
end
test "allows attaching blob to untracked record type" do
file = file_fixture("moon.jpg")
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "avatar.jpg",
content_type: "image/jpeg"
# User avatar is not a tracked record type
user = users(:david)
user.avatar.attach(blob)
# Should succeed - avatars are not storage-tracked
assert user.avatar.attached?
end
test "allows multiple attachments of same blob to untracked record types" do
file = file_fixture("moon.jpg")
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "avatar.jpg",
content_type: "image/jpeg"
# First attachment to untracked (avatar)
user1 = users(:david)
user1.avatar.attach(blob)
assert user1.avatar.attached?
# Second attachment to untracked (another avatar) should work
# since no-reuse only checks tracked contexts
user2 = users(:jz)
user2.avatar.attach(blob)
assert user2.avatar.attached?
end
end
+81
View File
@@ -273,4 +273,85 @@ class Storage::TotaledTest < ActiveSupport::TestCase
assert_not_nil entry
assert_equal(-5000, entry.delta)
end
test "reconcile_storage aborts when entry added during scan" do
board = @account.boards.create!(name: "Test Board", creator: users(:david))
card = board.cards.create!(title: "Test Card", creator: users(:david))
card.image.attach io: StringIO.new("x" * 1000), filename: "test.png", content_type: "image/png"
# Delete entry to create drift
Storage::Entry.where(board: board).delete_all
# Intercept calculate_real_storage_bytes to insert a new entry mid-scan,
# faithfully simulating a concurrent upload that changes the cursor
board.define_singleton_method(:calculate_real_storage_bytes) do
Storage::Entry.create!(
account_id: account.id,
board_id: id,
delta: 500,
operation: "attach"
)
super()
end
# Should abort and return false without creating reconcile entry
assert_no_difference "Storage::Entry.where(operation: 'reconcile').count" do
result = board.reconcile_storage
assert_equal false, result
end
end
test "reconcile_storage returns true on success" do
board = @account.boards.create!(name: "Test Board", creator: users(:david))
result = board.reconcile_storage
assert_equal true, result
end
# ensure_storage_total race safety
test "ensure_storage_total handles concurrent creation" do
@account.storage_total&.destroy
threads = 3.times.map do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
@account.bytes_used_exact
end
end
end
threads.each(&:join)
assert_equal 1, Storage::Total.where(owner: @account).count
end
# per-attachment reconcile
test "reconcile counts each attachment separately" do
board = @account.boards.create!(name: "Test", creator: users(:david))
# Create 3 distinct blobs (one per card) - no reuse
file = file_fixture("moon.jpg")
expected_bytes = file.size
3.times do |i|
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "image_#{i}.jpg",
content_type: "image/jpeg"
embed = ActionText::Attachment.from_attachable(blob).to_html
board.cards.create!(title: "Card #{i}", description: "<p>#{embed}</p>", creator: users(:david))
end
Storage::Entry.where(board: board).delete_all
board.reconcile_storage
entry = Storage::Entry.find_by(board: board, operation: "reconcile")
# 3 attachments x file_size bytes
assert_equal expected_bytes * 3, entry.delta
end
end

Some files were not shown because too many files have changed in this diff Show More