diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 4286f90ae..bbd1eb22a 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -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 diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 9c20fcda5..000000000 --- a/Dockerfile.dev +++ /dev/null @@ -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"] diff --git a/app/assets/images/jf-avatar.jpg b/app/assets/images/jf-avatar.jpg new file mode 100644 index 000000000..07f9f6592 Binary files /dev/null and b/app/assets/images/jf-avatar.jpg differ diff --git a/app/assets/images/jf-signature.svg b/app/assets/images/jf-signature.svg new file mode 100644 index 000000000..0e0653d06 --- /dev/null +++ b/app/assets/images/jf-signature.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index d475d0628..561d78640 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -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; diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index c3dbb7d74..c8453bc14 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -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; } diff --git a/app/assets/stylesheets/header.css b/app/assets/stylesheets/header.css index 21553e358..c29b21518 100644 --- a/app/assets/stylesheets/header.css +++ b/app/assets/stylesheets/header.css @@ -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; diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index 57d1803bd..c48a78034 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -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); - } - } } } diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 0d00d9977..62304d020 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -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; } diff --git a/app/assets/stylesheets/welcome-letter.css b/app/assets/stylesheets/welcome-letter.css new file mode 100644 index 000000000..80424ef66 --- /dev/null +++ b/app/assets/stylesheets/welcome-letter.css @@ -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; + } +} diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb index c4aac26d1..a78b30441 100644 --- a/app/controllers/cards/closures_controller.rb +++ b/app/controllers/cards/closures_controller.rb @@ -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 diff --git a/app/controllers/cards/not_nows_controller.rb b/app/controllers/cards/not_nows_controller.rb index 8eeb0e663..ea91436b0 100644 --- a/app/controllers/cards/not_nows_controller.rb +++ b/app/controllers/cards/not_nows_controller.rb @@ -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 diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index bfdc1ee2e..daf22a5bc 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -61,6 +61,6 @@ class CardsController < ApplicationController end def card_params - params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ]) + params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ]) end end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 1afb878ef..f30d518cc 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -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 diff --git a/app/controllers/concerns/card_scoped.rb b/app/controllers/concerns/card_scoped.rb index 52311809e..72e42d080 100644 --- a/app/controllers/concerns/card_scoped.rb +++ b/app/controllers/concerns/card_scoped.rb @@ -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 diff --git a/app/controllers/landings_controller.rb b/app/controllers/landings_controller.rb index b3b4c9162..e1c206c7b 100644 --- a/app/controllers/landings_controller.rb +++ b/app/controllers/landings_controller.rb @@ -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 diff --git a/app/controllers/signups/completions_controller.rb b/app/controllers/signups/completions_controller.rb index f0ea56ff9..4fcaaabd3 100644 --- a/app/controllers/signups/completions_controller.rb +++ b/app/controllers/signups/completions_controller.rb @@ -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 diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index f48b5d557..b14e6adc6 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -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 diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb index 218d58e78..a3d0bbab3 100644 --- a/app/helpers/html_helper.rb +++ b/app/helpers/html_helper.rb @@ -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) - %(#{url}#{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| - %(#{match}) - end - end end diff --git a/app/javascript/controllers/card_hotkeys_controller.js b/app/javascript/controllers/card_hotkeys_controller.js new file mode 100644 index 000000000..b2f92ee9b --- /dev/null +++ b/app/javascript/controllers/card_hotkeys_controller.js @@ -0,0 +1,130 @@ +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)) 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") + } + + 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) } + } +} diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index cdc8ea7cd..f6449cea7 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -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() { diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js index bd5e87665..b9eba66ee 100644 --- a/app/javascript/controllers/navigable_list_controller.js +++ b/app/javascript/controllers/navigable_list_controller.js @@ -45,6 +45,10 @@ export default class extends Controller { this.selectItem(target, true) } + hoverSelect({ currentTarget }) { + this.selectItem(currentTarget) + } + selectCurrentOrReset(event) { if (this.currentItem) { this.#setCurrentFrom(this.currentItem) @@ -221,6 +225,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) { @@ -253,6 +271,6 @@ export default class extends Controller { } else { this.#clickCurrentItem(event) } - }, + } } } diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js index 154bf4fc0..cf64df579 100644 --- a/app/javascript/controllers/theme_controller.js +++ b/app/javascript/controllers/theme_controller.js @@ -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() { diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb index be0c0748c..cd3bf3803 100644 --- a/app/jobs/storage/reconcile_job.rb +++ b/app/jobs/storage/reconcile_job.rb @@ -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 diff --git a/app/models/account/export.rb b/app/models/account/export.rb index c5e3d64c3..1fd2689d2 100644 --- a/app/models/account/export.rb +++ b/app/models/account/export.rb @@ -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| diff --git a/app/models/board/publishable.rb b/app/models/board/publishable.rb index 6c1ce47d4..8b113cc9e 100644 --- a/app/models/board/publishable.rb +++ b/app/models/board/publishable.rb @@ -15,6 +15,7 @@ module Board::Publishable def published? publication.present? end + alias_method :publicly_accessible?, :published? def publish create_publication! unless published? diff --git a/app/models/board/storage.rb b/app/models/board/storage.rb index 64dde9985..24028e714 100644 --- a/app/models/board/storage.rb +++ b/app/models/board/storage.rb @@ -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 diff --git a/app/models/card.rb b/app/models/card.rb index 33c96e502..18957cb9b 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -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 diff --git a/app/models/comment.rb b/app/models/comment.rb index d0315eea8..30706e8b5 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -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}" diff --git a/app/models/concerns/storage/totaled.rb b/app/models/concerns/storage/totaled.rb index 3f220f55f..b54a6ef22 100644 --- a/app/models/concerns/storage/totaled.rb +++ b/app/models/concerns/storage/totaled.rb @@ -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 diff --git a/app/models/concerns/storage/tracked.rb b/app/models/concerns/storage/tracked.rb index 7b96e95c0..745ef6692 100644 --- a/app/models/concerns/storage/tracked.rb +++ b/app/models/concerns/storage/tracked.rb @@ -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 diff --git a/app/models/event/description.rb b/app/models/event/description.rb index 672784ddb..f44233011 100644 --- a/app/models/event/description.rb +++ b/app/models/event/description.rb @@ -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 diff --git a/app/models/storage.rb b/app/models/storage.rb index 9c17b7cd9..445362b34 100644 --- a/app/models/storage.rb +++ b/app/models/storage.rb @@ -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 diff --git a/app/models/storage/attachment_tracking.rb b/app/models/storage/attachment_tracking.rb index d68f5845d..1c3c7151d 100644 --- a/app/models/storage/attachment_tracking.rb +++ b/app/models/storage/attachment_tracking.rb @@ -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 diff --git a/app/models/storage/entry.rb b/app/models/storage/entry.rb index a2f6e726b..768a4c6f8 100644 --- a/app/models/storage/entry.rb +++ b/app/models/storage/entry.rb @@ -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 diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index 400738cb1..c62d87e09 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -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) diff --git a/app/views/boards/show/_closed.html.erb b/app/views/boards/show/_closed.html.erb index 5d2820399..d4712b832 100644 --- a/app/views/boards/show/_closed.html.erb +++ b/app/views/boards/show/_closed.html.erb @@ -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" diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index cb7d4c086..25ed093d7 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -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, @@ -12,8 +12,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 diff --git a/app/views/boards/show/_not_now.html.erb b/app/views/boards/show/_not_now.html.erb index 66cd99519..ab84d04b8 100644 --- a/app/views/boards/show/_not_now.html.erb +++ b/app/views/boards/show/_not_now.html.erb @@ -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" diff --git a/app/views/cards/assignments/_user.html.erb b/app/views/cards/assignments/_user.html.erb index 0d4eff888..b2e885649 100644 --- a/app/views/cards/assignments/_user.html.erb +++ b/app/views/cards/assignments/_user.html.erb @@ -1,4 +1,4 @@ -