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:
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
max-inline-size: 100dvw;
|
||||
}
|
||||
|
||||
:where(#header) {
|
||||
position: relative;
|
||||
z-index: var(--z-nav);
|
||||
}
|
||||
|
||||
:is(#header, #footer) {
|
||||
@media print {
|
||||
display: none;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -15,6 +15,7 @@ module Board::Publishable
|
||||
def published?
|
||||
publication.present?
|
||||
end
|
||||
alias_method :publicly_accessible?, :published?
|
||||
|
||||
def publish
|
||||
create_publication! unless published?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,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,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,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>
|
||||
|
||||
@@ -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 }) %>
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) %>
|
||||
@@ -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>. It’s got a few cards designed to help you learn Fizzy itself. Open each card, go through the simple steps, and you’ll be an expert in Fizzy in no time. You’ll 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, we’re 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 & co-founder of 37signals, makers of Fizzy, Basecamp, and HEY</em>
|
||||
</p>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
secrets.beta
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
secrets.beta
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
secrets.beta
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
secrets.beta
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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? %> it’s 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? %> it’s 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 you’re 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 you’re 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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -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
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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><</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 \
|
||||
%(<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 \
|
||||
%(<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>")
|
||||
|
||||
# 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 \
|
||||
%(<img src="<a href="https://example.com/" rel="noreferrer">https://example.com/</a>">),
|
||||
%(<img src="<a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>">),
|
||||
format_html(%(<img src="https://example.com/">))
|
||||
assert_equal_html \
|
||||
%(<img src="<a href="https://example.com/" rel="noreferrer">https://example.com/</a>"!>),
|
||||
%(<img src="<a href="https://example.com/" rel="noopener noreferrer">https://example.com/</a>"!>),
|
||||
format_html(%(<img src="https://example.com/"!>))
|
||||
end
|
||||
|
||||
test "make sure the linked content is properly sanitized" do
|
||||
# https://hackerone.com/reports/3481093
|
||||
result = format_html(%(https://google.com/\">test</a><input></input>))
|
||||
assert_no_match(/<input>/i, result, "should not create an input element")
|
||||
|
||||
result = format_html(%(https://google.com/\"><script>alert('xss')</script>))
|
||||
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&d=e" rel="noreferrer">https://example.com/a?b=c&d=e</a></p>),
|
||||
%(<p>Check this: <a href="https://example.com/a?b=c&d=e" rel="noopener noreferrer">https://example.com/a?b=c&d=e</a></p>),
|
||||
format_html("<p>Check this: https://example.com/a?b=c&d=e</p>")
|
||||
|
||||
assert_equal \
|
||||
%(<p>Check this: <a href="https://example.com/a?b=c&d=e" rel="noreferrer">https://example.com/a?b=c&d=e</a></p>),
|
||||
%(<p>Check this: <a href="https://example.com/a?b=c&d=e" rel="noopener noreferrer">https://example.com/a?b=c&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
|
||||
@@ -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
|
||||
|
||||
@@ -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, "<script>alert('xss')</script>"
|
||||
assert_not_includes description.to_plain_text, "<script>"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user