Fix conflict and move the update from README to docs/kamal-deployment.md

This commit is contained in:
hiendinhngoc
2025-12-14 14:22:34 +07:00
104 changed files with 1540 additions and 494 deletions
-2
View File
@@ -5,5 +5,3 @@ db/schema.rb linguist-generated
# Mark any vendored files as having been vendored.
vendor/* linguist-vendored
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials
+1 -1
View File
@@ -5,7 +5,7 @@ registries:
type: git
url: https://github.com
username: x-access-token
password: ${{secrets.GH_TOKEN}}
password: ${{secrets.FIZZY_GH_TOKEN}}
updates:
- package-ecosystem: bundler
+14
View File
@@ -7,6 +7,20 @@ permissions:
contents: read
jobs:
gemfile-drift:
name: Gemfile drift
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Check for lockfile drift
run: bin/bundle-drift check
security:
name: Security
runs-on: ubuntu-latest
+1 -1
View File
@@ -18,4 +18,4 @@ jobs:
with:
saas: true
secrets:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }}
+2 -2
View File
@@ -7,7 +7,7 @@ on:
type: boolean
required: true
secrets:
GH_TOKEN:
FIZZY_GH_TOKEN:
required: false
permissions:
@@ -50,7 +50,7 @@ jobs:
MYSQL_USER: root
FIZZY_DB_HOST: 127.0.0.1
FIZZY_DB_PORT: 3306
BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }}
BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }}
steps:
- name: Install system packages
-2
View File
@@ -41,5 +41,3 @@
/config/credentials/*.key
.DS_Store
.kamal/*
+1 -1
View File
@@ -2,7 +2,7 @@ source "https://rubygems.org"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
gem "rails", github: "rails/rails", branch: "ast-immediate-variants-process-locally"
gem "rails", github: "rails/rails", branch: "main"
# Assets & front end
gem "importmap-rails"
+18 -39
View File
@@ -13,8 +13,8 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
branch: ast-immediate-variants-process-locally
revision: 4f4e0acaf558f6adf7df3ac23a51beb470463901
branch: main
specs:
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
@@ -117,8 +117,8 @@ GEM
specs:
action_text-trix (2.1.15)
railties
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
@@ -151,8 +151,8 @@ GEM
brakeman (7.1.1)
racc
builder (3.3.0)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -185,15 +185,11 @@ GEM
tzinfo
faker (3.5.2)
i18n (>= 1.8.11, < 2)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86-linux-gnu)
ffi (1.17.2-x86-linux-musl)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
fugit (1.12.1)
@@ -260,7 +256,6 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.2)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
@@ -272,7 +267,7 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
mittens (0.3.1)
mittens (0.3.0)
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
@@ -293,9 +288,6 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
nokogiri (1.18.10)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-musl)
@@ -306,8 +298,6 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
@@ -332,7 +322,7 @@ GEM
psych (5.2.6)
date
stringio
public_suffix (7.0.0)
public_suffix (6.0.2)
puma (7.1.0)
nio4r (~> 2.0)
raabro (1.4.0)
@@ -366,10 +356,10 @@ GEM
io-console (~> 0.5)
rexml (3.4.4)
rouge (4.6.1)
rqrcode (3.1.1)
rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.1)
rqrcode_core (2.0.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -388,7 +378,7 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.1)
rubocop-rails (2.34.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -427,18 +417,13 @@ GEM
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.8.1)
mini_portile2 (~> 2.8.0)
sqlite3 (2.8.1-aarch64-linux-gnu)
sqlite3 (2.8.1-aarch64-linux-musl)
sqlite3 (2.8.1-arm-linux-gnu)
sqlite3 (2.8.1-arm-linux-musl)
sqlite3 (2.8.1-arm64-darwin)
sqlite3 (2.8.1-x86-linux-gnu)
sqlite3 (2.8.1-x86-linux-musl)
sqlite3 (2.8.1-x86_64-darwin)
sqlite3 (2.8.1-x86_64-linux-gnu)
sqlite3 (2.8.1-x86_64-linux-musl)
sqlite3 (2.8.0-aarch64-linux-gnu)
sqlite3 (2.8.0-aarch64-linux-musl)
sqlite3 (2.8.0-arm-linux-gnu)
sqlite3 (2.8.0-arm-linux-musl)
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
base64
logger
@@ -453,7 +438,6 @@ GEM
thruster (0.1.16)
thruster (0.1.16-aarch64-linux)
thruster (0.1.16-arm64-darwin)
thruster (0.1.16-x86_64-darwin)
thruster (0.1.16-x86_64-linux)
timeout (0.4.4)
trilogy (2.9.0)
@@ -497,11 +481,6 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin
arm64-linux
ruby
x86-linux-gnu
x86-linux-musl
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
+4 -4
View File
@@ -21,7 +21,7 @@ GIT
GIT
remote: https://github.com/basecamp/fizzy-saas
revision: a14df11b57818697df4b2cc7b6a43e762ebaa196
revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143
specs:
fizzy-saas (0.1.0)
audits1984
@@ -50,7 +50,7 @@ GIT
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2
revision: 14312a940471e20617b38cdec7c092a01567d18b
specs:
queenbee (3.2.0)
activeresource
@@ -82,8 +82,8 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 0636a79d1bf268db6cdbbc6327106d08c3ff3751
branch: ast-immediate-variants-process-locally
revision: 4f4e0acaf558f6adf7df3ac23a51beb470463901
branch: main
specs:
actioncable (8.2.0.alpha)
actionpack (= 8.2.0.alpha)
+8 -182
View File
@@ -2,201 +2,27 @@
This is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com).
## Deploying Fizzy
If you'd like to run Fizzy on your own server, we recommend deploying it with [Kamal](https://kamal-deploy.org/).
Kamal makes it easier to set up a bare server, copy the application to it, and manage the configuration settings that it uses.
## Running your own Fizzy instance
(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).)
If you want to run your own Fizzy instance, but don't need to change its code, you can use our pre-built Docker image.
You'll need access to a server on which you can run Docker, and you'll need to configure some options to customize your installation.
This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it.
You can find the details of how to do a Docker-based deployment in our [Docker deployment guide](docs/docker-deployment.md).
The steps to configure your very own Fizzy are:
1. Fork the repo
2. Initialize Kamal by running `kamal init`. This command generates the `.kamal` directory along with the required configuration files, including `.kamal/secrets`.
3. Edit few things in `config/deploy.yml` and `.kamal/secrets`
4. Run `kamal setup` to do your first deploy.
We'll go through each of these in turn.
### Fork the repo
To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo.
That allows you to commit your changes, and track them over time.
You can always re-sync your fork to pick up new changes from the main repo over time.
Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed.
### Editing the configuration
The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance.
To get started, the parts you need to change are all in the "About your deployment" section.
We've added comments to that file to highlight what each setting needs to be, but the main ones are:
- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`.
- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here.
- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off.
- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.
- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this.
Fizzy also requires a few environment variables to be set up, some of which contain secrets.
The simplest way to do this is to put them in a file called `.kamal/secrets`.
Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally.
If you use a password manager like 1Password, you can also opt to keep your secrets there instead.
Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that.
To store your secrets, create the file `.kamal/secrets` and enter something like the following:
```ini
SECRET_KEY_BASE=12345
VAPID_PUBLIC_KEY=something
VAPID_PRIVATE_KEY=somethingelse
SMTP_USERNAME=email-provider-username
SMTP_PASSWORD=email-provider-password
```
The values you enter here will be specific to you, and you can get or create them as follows:
- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this.
- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use.
- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with:
```sh
bin/rails c
```
And then run the following to create a new pair of keys:
```ruby
vapid_key = WebPush.generate_key
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
Once you've made all those changes, commit them to your fork so they're saved.
### Deploy Fizzy!
You can now do your first deploy by running:
```sh
bin/kamal setup
```
This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running.
After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run:
```sh
bin/kamal deploy
```
## File storage (Active Storage)
Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`.
To use the included `s3` service, set:
- `ACTIVE_STORAGE_SERVICE=s3`
- `S3_ACCESS_KEY_ID`
- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`)
- `S3_REGION` (defaults to `us-east-1`)
- `S3_SECRET_ACCESS_KEY`
Optional for S3-compatible endpoints:
- `S3_ENDPOINT`
- `S3_FORCE_PATH_STYLE=true`
- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`)
- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`)
If you want more flexibility to customize your Fizzy installation by changing its code, and deploy those changes to your server, then we recommend you deploy Fizzy with Kamal. You can find a complete walkthrough of doing that in our [Kamal deployment guide](docs/kamal-deployment.md).
## Development
### Setting up
You are welcome -- and encouraged -- to modify Fizzy to your liking.
Please see our [Development guide](docs/development.md) for how to get Fizzy set up for local development.
First, get everything installed and configured with:
```sh
bin/setup
bin/setup --reset # Reset the database and seed it
```
And then run the development server:
```sh
bin/dev
```
You'll be able to access the app in development at http://fizzy.localhost:3006.
To login, enter `david@example.com` and grab the verification code from the browser console to sign in.
### Web Push Notifications
Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:
- `VAPID_PRIVATE_KEY`
- `VAPID_PUBLIC_KEY`
Generate them with the `web-push` gem:
```ruby
vapid_key = WebPush.generate_key
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
### Running tests
For fast feedback loops, unit tests can be run with:
```sh
bin/rails test
```
The full continuous integration tests can be run with:
```sh
bin/ci
```
### Database configuration
Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL:
```sh
DATABASE_ADAPTER=mysql bin/setup --reset
DATABASE_ADAPTER=mysql bin/ci
```
The remote CI pipeline will run tests against both SQLite and MySQL.
### Outbound Emails
You can view email previews at http://fizzy.localhost:3006/rails/mailers.
You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with:
```sh
bin/rails dev:email
```
Under the hood, this will create or remove `tmp/email-dev.txt`.
## SaaS gem
37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup.
This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure.
## Contributing
We welcome contributions! Please read our [style guide](STYLE.md) before submitting code.
## License
Fizzy is released under the [O'Saasy License](LICENSE.md).
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 3H4c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h5v2H7v2h10v-2h-2v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H4V5h16v10z"/></svg>

After

Width:  |  Height:  |  Size: 190 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M21.64 13.24A9 9 0 1 1 10.76 2.36a9.07 9.07 0 0 0 10.88 10.88z"/></svg>

After

Width:  |  Height:  |  Size: 141 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>

After

Width:  |  Height:  |  Size: 544 B

+111 -2
View File
@@ -10,7 +10,7 @@
--block-space-double: calc(var(--block-space) * 2);
/* Text */
--font-sans: -apple-system, BlinkMacSystemFont, sans-serif;
--font-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;
@@ -255,8 +255,117 @@
--color-gradient-2: oklch(var(--lch-pink-lighter));
--color-gradient-3: oklch(var(--lch-purple-lighter));
--color-gradient-4: var(--color-canvas);
}
@media (prefers-color-scheme: dark) {
/* Dark mode - explicit theme choice overrides system preference */
html[data-theme="dark"] {
--lch-canvas: 20% 0.0195 232.58;
--lch-ink-inverted: var(--lch-black);
--lch-ink-darkest: 96.02% 0.0034 260;
--lch-ink-darker: 86% 0.0061 260;
--lch-ink-dark: 73.97% 0.009 260;
--lch-ink-medium: 62% 0.0122 260;
--lch-ink-light: 40% 0.0148 260;
--lch-ink-lighter: 30% 0.0178 260;
--lch-ink-lightest: 25% 0.0204 260;
--lch-uncolor-darkest: 96.09% 0.0076 100;
--lch-uncolor-darker: 86% 0.021 90;
--lch-uncolor-dark: 73.93% 0.041 80;
--lch-uncolor-medium: 62% 0.0552 70;
--lch-uncolor-light: 40% 0.0387 60;
--lch-uncolor-lighter: 30% 0.012 50;
--lch-uncolor-lightest: 25% 0.0017 40;
--lch-red-darkest: 95.85% 0.0218 46;
--lch-red-darker: 86% 0.086 44;
--lch-red-dark: 73.95% 0.139 42;
--lch-red-medium: 62% 0.154 40;
--lch-red-light: 40% 0.088 38;
--lch-red-lighter: 30% 0.032 36;
--lch-red-lightest: 25% 0.011 34;
--lch-yellow-darkest: 96% 0.056 100;
--lch-yellow-darker: 86% 0.103 90;
--lch-yellow-dark: 74.06% 0.136 80;
--lch-yellow-medium: 62.1% 0.146 70;
--lch-yellow-light: 40% 0.0736 60;
--lch-yellow-lighter: 30% 0.026 50;
--lch-yellow-lightest: 25% 0.01 40;
--lch-lime-darkest: 96.04% 0.066 115;
--lch-lime-darker: 86% 0.098 114;
--lch-lime-dark: 73.97% 0.121 113;
--lch-lime-medium: 62% 0.128 112;
--lch-lime-light: 40% 0.0637 111;
--lch-lime-lighter: 30% 0.024 110;
--lch-lime-lightest: 25% 0.012 109;
--lch-green-darkest: 96.12% 0.035 143;
--lch-green-darker: 86% 0.082 144;
--lch-green-dark: 73.99% 0.117 145;
--lch-green-medium: 62% 0.1261 146;
--lch-green-light: 40% 0.065 147;
--lch-green-lighter: 30% 0.03 148;
--lch-green-lightest: 25% 0.018 149;
--lch-aqua-darkest: 96.15% 0.0244 202;
--lch-aqua-darker: 86% 0.06 204;
--lch-aqua-dark: 73.92% 0.095 206;
--lch-aqua-medium: 62% 0.106 208;
--lch-aqua-light: 40% 0.0594 210;
--lch-aqua-lighter: 30% 0.028 212;
--lch-aqua-lightest: 25% 0.017 214;
--lch-blue-darkest: 95.93% 0.0217 252;
--lch-blue-darker: 86% 0.068 254;
--lch-blue-dark: 74% 0.1293 256;
--lch-blue-medium: 62% 0.159 258;
--lch-blue-light: 40% 0.094 260;
--lch-blue-lighter: 30% 0.0452 262;
--lch-blue-lightest: 25% 0.0318 264;
--lch-violet-darkest: 95.97% 0.019 280;
--lch-violet-darker: 86% 0.068 282;
--lch-violet-dark: 74.08% 0.142 284;
--lch-violet-medium: 62% 0.184 286;
--lch-violet-light: 40% 0.108 288;
--lch-violet-lighter: 30% 0.048 290;
--lch-violet-lightest: 25% 0.025 292;
--lch-purple-darkest: 95.99% 0.0217 302;
--lch-purple-darker: 86% 0.068 304;
--lch-purple-dark: 73.98% 0.141 306;
--lch-purple-medium: 62% 0.177 308;
--lch-purple-light: 40% 0.099 310;
--lch-purple-lighter: 30% 0.04 312;
--lch-purple-lightest: 25% 0.017 314;
--lch-pink-darkest: 95.84% 0.0308 336;
--lch-pink-darker: 86% 0.074 338;
--lch-pink-dark: 74.04% 0.1294 340;
--lch-pink-medium: 62% 0.166 342;
--lch-pink-light: 40% 0.085 344;
--lch-pink-lighter: 30% 0.03 346;
--lch-pink-lightest: 25% 0.011 348;
--color-terminal-bg: var(--color-canvas);
--color-terminal-text-light: oklch(var(--lch-green-dark));
--color-golden: oklch(var(--lch-blue-medium));
--color-highlight: oklch(var(--lch-blue-lighter));
--shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42),
0 0.2em 1.6em -0.8em oklch(var(--lch-black) / 0.6),
0 0.4em 2.4em -1em oklch(var(--lch-black) / 0.7),
0 0.4em 0.8em -1.2em oklch(var(--lch-black) / 0.8),
0 0.8em 1.2em -1.6em oklch(var(--lch-black) / 0.9),
0 1.2em 1.6em -2em oklch(var(--lch-black) / 1);
}
/* Fallback to system preference when no explicit theme is set */
@media (prefers-color-scheme: dark) {
html:not([data-theme]) {
--lch-canvas: 20% 0.0195 232.58;
--lch-ink-inverted: var(--lch-black);
+7 -1
View File
@@ -19,10 +19,16 @@
view-transition-name: bar;
z-index: var(--z-bar);
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
border-block: 1px solid var(--color-ink-lighter);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
border-block: 1px solid var(--color-ink-lighter);
}
}
&:has(.bar__placeholder[hidden]) {
padding-inline: 1ch;
}
+7 -1
View File
@@ -55,9 +55,15 @@
::selection {
background: var(--color-selected);
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
background-color: var(--color-selected-dark);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
background-color: var(--color-selected-dark);
}
}
}
:where(ul, ol):where([role="list"]) {
+7 -1
View File
@@ -27,10 +27,16 @@
}
}
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
--btn-hover-brightness: 1.25;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
--btn-hover-brightness: 1.25;
}
}
&[disabled],
&:has([disabled]),
[disabled] &[type=submit],
+35 -28
View File
@@ -179,9 +179,15 @@
outline: var(--focus-ring-size) solid var(--color-selected-dark);
outline-offset: var(--focus-ring-offset);
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
outline-color: oklch(var(--lch-blue-medium));
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
outline-color: oklch(var(--lch-blue-medium));
}
}
}
}
@@ -658,38 +664,39 @@
/* -------------------------------------------------------------------------- */
/* Surface a mini bubble if there are cards with bubbles inside */
.cards--considering:has(.bubble:not([hidden])),
.cards--doing.is-collapsed:has(.bubble:not([hidden])) {
.cards__transition-container {
--bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
--bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;
.cards--considering:has(.bubble:not([hidden])) .cards__expander-title,
.cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container {
--bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
--bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;
&:before {
background: radial-gradient(
color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%,
color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100%
);
block-size: 1em;
border-radius: var(--bubble-shape);
content: "";
inline-size: 1em;
inset: 0 0 auto auto;
position: absolute;
translate: 20% -20%;
z-index: 1;
}
/* Maybe column: position bubble relative to the title, not the container */
.cards--considering & {
overflow: visible;
position: relative;
&:before {
background: radial-gradient(
color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%,
color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100%
);
block-size: 1em;
border-radius: var(--bubble-shape);
content: "";
inline-size: 1em;
inset: 0 0 auto auto;
position: absolute;
translate: 20% -20%;
z-index: 1;
}
.cards--considering &:before {
inset: 0 auto auto 0;
translate: 100% 75%;
inset-block-start: 50%;
translate: 125% -75%;
z-index: -1;
}
}
}
.cards--considering:has(.bubble:not([hidden])) .cards__expander-title:before {
translate: 120% 50%;
}
/* Card column indicators
/* -------------------------------------------------------------------------- */
@@ -758,7 +765,7 @@
block-size: 1px;
inline-size: 100%;
inset: 50% 0 auto;
translaate: 0 -50%;
translate: 0 -50%;
}
&:after {
+7 -1
View File
@@ -27,10 +27,16 @@
text-align: start;
z-index: 1;
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
box-shadow: 0 0 0 1px var(--color-ink-lighter);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
box-shadow: 0 0 0 1px var(--color-ink-lighter);
}
}
.popup {
inline-size: 260px;
}
+7 -1
View File
@@ -12,9 +12,15 @@
opacity: 0.5;
mix-blend-mode: multiply;
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
mix-blend-mode: screen;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
mix-blend-mode: screen;
}
}
}
span::before,
+3
View File
@@ -72,6 +72,8 @@
.icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); }
.icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); }
.icon--minus { --svg: url("minus.svg "); }
.icon--monitor { --svg: url("monitor.svg "); }
.icon--moon { --svg: url("moon.svg "); }
.icon--move { --svg: url("move.svg "); }
.icon--notification-bell-access-only { --svg: url("bell.svg "); }
.icon--notification-bell-watching { --svg: url("bell-off.svg "); }
@@ -96,6 +98,7 @@
.icon--settings { --svg: url("settings.svg "); }
.icon--share { --svg: url("share.svg "); }
.icon--sliders { --svg: url("sliders.svg "); }
.icon--sun { --svg: url("sun.svg "); }
.icon--switch { --svg: url("switch.svg "); }
.icon--tag { --svg: url("tag.svg "); }
.icon--tag-outline { --svg: url("tag-outline.svg "); }
+10 -2
View File
@@ -104,17 +104,25 @@
.input--select {
--input-border-radius: 2em;
--input-padding: 0.5em 1.8em 0.5em 1.2em;
--caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
--caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
background-image: var(--caret-icon);
background-size: 0.5em;
background-position: center right 0.9em;
background-repeat: no-repeat;
text-align: start;
html[data-theme="dark"] & {
--caret-icon: var(--caret-icon-dark);
}
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
html:not([data-theme]) & {
--caret-icon: var(--caret-icon-dark);
}
}
option {
+1
View File
@@ -24,6 +24,7 @@
block-size: var(--knob-size);
inline-size: var(--knob-size);
inset: 50% auto auto 50%;
opacity: 0;
position: absolute;
translate: -50% -50%;
z-index: 1;
+11 -2
View File
@@ -55,10 +55,13 @@
}
.lexxy-code-language-picker {
--caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
--caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
-webkit-appearance: none;
appearance: none;
background-color: var(--color-canvas);
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
background-image: var(--caret-icon);
background-position: center right 0.9em;
background-repeat: no-repeat;
background-size: 0.5em;
@@ -75,8 +78,14 @@
padding-inline: 1.5ch 1.8em;
text-align: start;
html[data-theme="dark"] & {
--caret-icon: var(--caret-icon-dark);
}
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
html:not([data-theme]) & {
--caret-icon: var(--caret-icon-dark);
}
}
option {
+8 -1
View File
@@ -24,8 +24,15 @@
}
}
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
filter: invert(1);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
filter: invert(1);
}
}
}
}
+8 -2
View File
@@ -23,9 +23,15 @@
background: var(--color-ink-lighter);
}
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
--input-background: var(--color-ink-lighter);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
--input-background: var(--color-ink-lighter);
}
}
}
}
@@ -221,7 +227,7 @@
.nav__blank-slate {
font-size: var(--text-small);
margin: 1rem auto;
margin: 2rem auto;
.nav:has(.popup__item:not([hidden])) & {
display: none;
+8 -1
View File
@@ -95,9 +95,16 @@
&:not(.expanded):hover {
filter: brightness(var(--reaction-hover-brightness));
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
--reaction-hover-brightness: 1.25;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
--reaction-hover-brightness: 1.25;
}
}
}
}
}
@@ -29,6 +29,7 @@
}
:where(ol, ul) {
list-style: disc;
padding-inline-start: 3ch;
}
+2 -2
View File
@@ -69,8 +69,8 @@ summary {
.search__empty {
margin-block: 3em;
opacity: 0.66;
text-align: center;
margin-inline: auto;
inline-size: fit-content;
}
.search__excerpt {
+17 -1
View File
@@ -14,7 +14,7 @@
--markup-deleted: lch(39.64 68.17 31.45);
/* Redefine named color values for dark mode */
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] {
--keyword: lch(67.63 58.99 30.64);
--entity: lch(75.13 46.73 306.74);
--constant: lch(74.9 39.71 255.53);
@@ -28,6 +28,22 @@
--markup-deleted: lch(73.8% 65 29.18);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) {
--keyword: lch(67.63 58.99 30.64);
--entity: lch(75.13 46.73 306.74);
--constant: lch(74.9 39.71 255.53);
--string: lch(74.9 39.71 255.53);
--variable: lch(76.17 61.1 61.97);
--comment: lch(60.83 6.66 254.46);
--entity-tag: lch(83.65 59.31 141.61);
--markup-heading: lch(47.93 71.67 280.72);
--markup-list: lch(83.84 57.9 85.03);
--markup-inserted: lch(83.65 59.31 141.61);
--markup-deleted: lch(73.8% 65 29.18);
}
}
color: var(--color-ink);
.w {
+33
View File
@@ -0,0 +1,33 @@
@layer components {
.theme-switcher {
@media (max-width: 479px) {
--row-gap: 1ch;
flex-direction: column;
}
}
.theme-switcher__btn {
--btn-background: var(--color-ink-lightest);
--btn-border-radius: 0.4em;
--btn-border-size: 0;
--btn-gap: 0.1lh;
--btn-padding: 1em;
--icon-size: 2em;
column-gap: var(--inline-space);
flex: 1;
flex-direction: column;
position: relative;
white-space: nowrap;
&:has(input:checked) {
--btn-background: var(--color-selected);
--btn-color: var(--color-ink);
}
@media (max-width: 479px) {
flex-direction: row;
}
}
}
+8 -1
View File
@@ -300,9 +300,16 @@
border-radius: 0;
}
@media (prefers-color-scheme: dark) {
html[data-theme="dark"] & {
box-shadow: 0 0 0 1px var(--color-ink-lighter);
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
box-shadow: 0 0 0 1px var(--color-ink-lighter);
}
}
}
.card__background {
+1 -1
View File
@@ -61,6 +61,6 @@ class CardsController < ApplicationController
end
def card_params
params.expect(card: [ :status, :title, :description, :image, :created_at, tag_ids: [] ])
params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ])
end
end
+19 -4
View File
@@ -6,6 +6,7 @@ module Authentication
before_action :require_authentication
after_action :ensure_development_magic_link_not_leaked
helper_method :authenticated?
helper_method :email_address_pending_authentication
etag { Current.identity.id if authenticated? }
@@ -37,7 +38,7 @@ module Authentication
def require_account
unless Current.account.present?
redirect_to session_menu_url(script_name: nil)
redirect_to main_app.session_menu_path(script_name: nil)
end
end
@@ -78,11 +79,11 @@ module Authentication
end
def redirect_authenticated_user
redirect_to root_url if authenticated?
redirect_to main_app.root_url if authenticated?
end
def redirect_tenanted_request
redirect_to root_url if Current.account.present?
redirect_to main_app.root_url if Current.account.present?
end
def start_new_session_for(identity)
@@ -107,10 +108,24 @@ module Authentication
end
end
def email_address_pending_authentication_matches?(email_address)
if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "")
session.delete(:email_address_pending_authentication)
true
else
false
end
end
def email_address_pending_authentication
session[:email_address_pending_authentication]
end
def redirect_to_session_magic_link(magic_link, return_to: nil)
serve_development_magic_link(magic_link)
session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link
session[:return_to_after_authenticating] = return_to if return_to
redirect_to session_magic_link_url(script_name: nil)
redirect_to main_app.session_magic_link_path(script_name: nil)
end
def serve_development_magic_link(magic_link)
+1 -6
View File
@@ -3,7 +3,6 @@ module Authorization
included do
before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? }
before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated?
end
class_methods do
@@ -27,11 +26,7 @@ module Authorization
end
def ensure_can_access_account
redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active?
end
def ensure_only_staff_can_access_non_production_remote_environments
head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff?
redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active?
end
def redirect_existing_user
@@ -12,16 +12,21 @@ module RequestForgeryProtection
end
def verified_request?
super || safe_fetch_site? || request.format.json?
request.get? || request.head? || !protect_against_forgery? ||
(valid_request_origin? && safe_fetch_site?)
end
SAFE_FETCH_SITES = %w[ same-origin same-site ]
def safe_fetch_site?
SAFE_FETCH_SITES.include?(sec_fetch_site_value)
SAFE_FETCH_SITES.include?(sec_fetch_site_value) || (sec_fetch_site_value.nil? && api_request?)
end
def api_request?
request.format.json?
end
def sec_fetch_site_value
request.headers["Sec-Fetch-Site"].to_s.downcase
request.headers["Sec-Fetch-Site"].to_s.downcase.presence
end
end
@@ -2,6 +2,7 @@ class Sessions::MagicLinksController < ApplicationController
disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" }
before_action :ensure_that_email_address_pending_authentication_exists
layout "public"
@@ -10,14 +11,28 @@ class Sessions::MagicLinksController < ApplicationController
def create
if magic_link = MagicLink.consume(code)
start_new_session_for magic_link.identity
redirect_to after_sign_in_url(magic_link)
authenticate_with magic_link
else
redirect_to session_magic_link_path, flash: { shake: true }
end
end
private
def ensure_that_email_address_pending_authentication_exists
unless email_address_pending_authentication.present?
redirect_to new_session_path, alert: "Enter your email address to sign in."
end
end
def authenticate_with(magic_link)
if email_address_pending_authentication_matches?(magic_link.identity.email_address)
start_new_session_for magic_link.identity
redirect_to after_sign_in_url(magic_link)
else
redirect_to new_session_path, alert: "Authentication failed. Please try again."
end
end
def code
params.expect(:code)
end
+2 -1
View File
@@ -14,7 +14,8 @@ class SessionsController < ApplicationController
else
signup = Signup.new(email_address: email_address)
if signup.valid?(:identity_creation)
redirect_to_session_magic_link signup.create_identity
magic_link = signup.create_identity if Account.accepting_signups?
redirect_to_session_magic_link magic_link
else
head :unprocessable_entity
end
+5
View File
@@ -3,6 +3,7 @@ class SignupsController < ApplicationController
allow_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." }
before_action :redirect_authenticated_user
before_action :enforce_tenant_limit
layout "public"
@@ -24,6 +25,10 @@ class SignupsController < ApplicationController
redirect_to new_signup_completion_path if authenticated?
end
def enforce_tenant_limit
redirect_to new_session_url unless Account.accepting_signups?
end
def signup_params
params.expect signup: :email_address
end
@@ -1,6 +1,4 @@
class Users::AvatarsController < ApplicationController
include ActiveStorage::Streaming
allow_unauthenticated_access only: :show
before_action :set_user
-2
View File
@@ -1,7 +1,5 @@
module LoginHelper
def login_url
# Use main_app because this helper may be invoked from an engine controller
# that inherits from AdminController.
main_app.new_session_path(script_name: nil)
end
@@ -0,0 +1,49 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["lightButton", "darkButton", "autoButton"]
connect() {
this.#applyStoredTheme()
}
setLight() {
this.#theme = "light"
}
setDark() {
this.#theme = "dark"
}
setAuto() {
this.#theme = "auto"
}
get #storedTheme() {
return localStorage.getItem("theme") || "auto"
}
set #theme(theme) {
localStorage.setItem("theme", theme)
if (theme === "auto") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme)
}
this.#updateButtons()
}
#applyStoredTheme() {
this.#theme = this.#storedTheme
}
#updateButtons() {
const storedTheme = this.#storedTheme
if (this.hasLightButtonTarget) { this.lightButtonTarget.checked = (storedTheme === "light") }
if (this.hasDarkButtonTarget) { this.darkButtonTarget.checked = (storedTheme === "dark") }
if (this.hasAutoButtonTarget) { this.autoButtonTarget.checked = (storedTheme !== "light" && storedTheme !== "dark") }
}
}
-4
View File
@@ -1,4 +0,0 @@
export const HttpStatus = {
CONFLICT: 409,
UNPROCESSABLE: 422
}
+1 -1
View File
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Account::Storage, Entropic, Seedeable
include Account::Storage, Entropic, MultiTenantable, Seedeable
has_one :join_code
has_many :users, dependent: :destroy
+2 -2
View File
@@ -11,8 +11,8 @@ class Account::JoinCode < ApplicationRecord
before_create :generate_code, if: -> { code.blank? }
def redeem_if(&block)
transaction do
increment!(:usage_count) if block.call(account)
with_lock do
increment!(:usage_count) if active? && block.call(account)
end
end
+13
View File
@@ -0,0 +1,13 @@
module Account::MultiTenantable
extend ActiveSupport::Concern
included do
cattr_accessor :multi_tenant, default: false
end
class_methods do
def accepting_signups?
multi_tenant || Account.none?
end
end
end
+2 -2
View File
@@ -27,7 +27,7 @@ class Card::ActivitySpike::Detector
def multiple_people_commented?(minimum_comments: 3, minimum_participants: 2)
card.comments
.where("created_at >= ?", recent_period.seconds.ago)
.where(created_at: recent_period.seconds.ago..)
.group(:card_id)
.having("COUNT(*) >= ?", minimum_comments)
.having("COUNT(DISTINCT creator_id) >= ?", minimum_participants)
@@ -51,6 +51,6 @@ class Card::ActivitySpike::Detector
end
def last_event
card.events.order(created_at: :desc).first
card.events.order(:created_at).last
end
end
+3 -3
View File
@@ -7,9 +7,9 @@ module Card::Closeable
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) }
scope :closed_by, ->(users) { closed.where("closures.user_id": Array(users)) }
scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) }
scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) }
scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) }
end
def closed?
+2 -2
View File
@@ -4,7 +4,7 @@ module Card::Eventable
include ::Eventable
included do
before_create { self.last_active_at = Time.current }
before_create { self.last_active_at ||= created_at || Time.current }
after_save :track_title_change, if: :saved_change_to_title?
end
@@ -12,7 +12,7 @@ module Card::Eventable
def event_was_created(event)
transaction do
create_system_comment_for(event)
touch_last_active_at
touch_last_active_at unless was_just_published?
end
end
+1 -1
View File
@@ -7,7 +7,7 @@ module Card::Stallable
has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy
scope :with_activity_spikes, -> { joins(:activity_spike) }
scope :stalled, -> { open.active.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }
scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) }
before_update :remember_to_detect_activity_spikes
after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes?
+2 -2
View File
@@ -10,8 +10,8 @@ class Comment < ApplicationRecord
scope :chronologically, -> { order created_at: :asc, id: :desc }
scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }
scope :by_system, -> { joins(:creator).where(creator: { role: "system" }) }
scope :by_user, -> { joins(:creator).where.not(creator: { role: "system" }) }
scope :by_system, -> { joins(:creator).where(creator: { role: :system }) }
scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) }
after_create_commit :watch_card_by_creator
+1 -1
View File
@@ -29,7 +29,7 @@ class Filter < ApplicationRecord
result = result.where(creator_id: creators.ids) if creators.present?
result = result.where(board: boards.ids) if boards.present?
result = result.tagged_with(tags.ids) if tags.present?
result = result.where("cards.created_at": creation_window) if creation_window
result = result.where(cards: { created_at: creation_window }) if creation_window
result = result.closed_at_window(closure_window) if closure_window
result = result.closed_by(closers) if closers.present?
result = terms.reduce(result) do |result, term|
+7 -8
View File
@@ -1,18 +1,17 @@
module MagicLink::Code
CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze
CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
class << self
def generate(length)
Array.new(length) { CODE_ALPHABET[SecureRandom.random_number(CODE_ALPHABET.length)] }.join
SecureRandom.base32(length)
end
def sanitize(code)
return nil if code.blank?
normalize_code(code)
.then { apply_substitutions(_1) }
.then { remove_invalid_characters(_1) }
if code.present?
normalize_code(code)
.then { apply_substitutions(it) }
.then { remove_invalid_characters(it) }
end
end
private
@@ -25,7 +24,7 @@ module MagicLink::Code
end
def remove_invalid_characters(code)
code.gsub(/[^#{CODE_ALPHABET.join}]/, "")
code.gsub(/[^#{SecureRandom::BASE32_ALPHABET.join}]/, "")
end
end
end
-2
View File
@@ -14,8 +14,6 @@ class User < ApplicationRecord
has_many :pinned_cards, through: :pins, source: :card
has_many :exports, class_name: "Account::Export", dependent: :destroy
scope :with_avatars, -> { preload(:account, :avatar_attachment) }
def deactivate
transaction do
accesses.destroy_all
+24 -2
View File
@@ -2,13 +2,20 @@ module User::Avatar
extend ActiveSupport::Concern
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze
included do
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately
end
validate :avatar_content_type_allowed
scope :with_avatars, -> { preload(:account, :avatar_attachment) }
validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached?
end
def avatar_attached?
avatar.attached?
end
def avatar_thumbnail
@@ -17,8 +24,23 @@ module User::Avatar
private
def avatar_content_type_allowed
if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image")
end
end
def avatar_dimensions_allowed
return unless avatar.blob.analyzed? || avatar.blob.analyze
width = avatar.blob.metadata[:width]
height = avatar.blob.metadata[:height]
if width && width > MAX_AVATAR_DIMENSIONS[:width]
errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px")
end
if height && height > MAX_AVATAR_DIMENSIONS[:height]
errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px")
end
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
<header>
<h2 class="divider txt-large">Auto close</h2>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for specific period of time. <em>This is the default, global setting — you can override it on each board.</em></p>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. <em>This is the default, global setting — you can override it on each board.</em></p>
</header>
<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>
+1 -1
View File
@@ -6,6 +6,6 @@
<% else %>
<%= button_to card_goldness_path(card), class: "btn", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click" } do %>
<%= icon_tag "golden-ticket" %>
<span class="for-screen-reader">Promote to golden (shift+g)</span>
<span class="for-screen-reader">Promote to Golden Ticket (shift+g)</span>
<% end %>
<% end %>
@@ -0,0 +1,6 @@
<%= javascript_tag nonce: true do %>
const theme = localStorage.getItem("theme")
if (theme && theme !== "auto") {
document.documentElement.dataset.theme = theme
}
<% end %>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<%= render "layouts/shared/head" %>
<body data-controller="local-time timezone-cookie turbo-navigation" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation">
<body data-controller="local-time timezone-cookie turbo-navigation theme" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation">
<header class="header <%= @header_class %>" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= render "my/menu" if Current.user %>
+1
View File
@@ -15,6 +15,7 @@
<% turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= render "layouts/theme_preference" %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
@@ -1,7 +1,9 @@
<section class="notifications-list notifications-list--read panel panel--wide center borderless unpad flex flex-column gap-half margin-block-start">
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
<% if page.records.any? %>
<section class="notifications-list notifications-list--read panel panel--wide center borderless unpad flex flex-column gap-half margin-block-start">
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
<div id="notifications_list_read" contents>
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
</div>
</section>
<div id="notifications_list_read" contents>
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
</div>
</section>
<% end %>
@@ -5,8 +5,8 @@
<%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %>
</div>
<% else %>
<div class="notifications-list__empty-message pad border-radius border translucent" style="--border-style: dashed; --border-color: var(--color-ink); --border-radius: 0.2em;">
<strong>Nothing new for you.</strong>
<div class="notifications-list__empty-message blank-slate blank-slate--empty align-self-center">
Nothing new for you
</div>
<% end %>
+1 -1
View File
@@ -14,7 +14,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click", style: "view-transistion-name: card-board-title;" %>
<%= back_link_to @card.board.name, published_board_url(@card.board), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click", style: "view-transition-name: card-board-title;" %>
</div>
<% end %>
+1 -1
View File
@@ -1,7 +1,7 @@
<section class="search">
<div class="search__results">
<% unless page.used? %>
<div class="search__empty">
<div class="search__empty blank-slate blank-slate--empty">
No matches
</div>
<% end %>
+4 -2
View File
@@ -3,7 +3,9 @@
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] || flash[:shake] %>">
<header>
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<p class="margin-none-block-start txt-medium">Then enter the verification code included in the email below:</p>
<p class="margin-none-block-start txt-medium">
Then enter the verification code included in the email below:
</p>
</header>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %>
@@ -13,7 +15,7 @@
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %>
<% end %>
<p class="txt-small">The code you receive will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
<p class="txt-small">The code sent to <strong><%= email_address_pending_authentication %></strong> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
</div>
<% if Rails.env.development? && flash[:magic_link_code].present? %>
+5 -1
View File
@@ -10,7 +10,11 @@
</label>
</div>
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and well get you signed in.</p>
<% if Account.accepting_signups? %>
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and well get you signed in.</p>
<% else %>
<p>Enter your email and well get you signed in.</p>
<% end %>
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
<span>Lets go</span>
@@ -1,6 +1,6 @@
<p class="subtitle">Confirm your email address change</p>
<%= link_to "Yes use use this email address", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: "btn" %>
<%= link_to "Yes use this email address", user_email_address_confirmation_url(script_name: @user.account.slug, user_id: @user.id, email_address_token: @token), class: "btn" %>
<p class="margin-block-start-double">If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p>
+4
View File
@@ -0,0 +1,4 @@
<div class="flex flex-column align-center">
<h2 class="divider txt-large margin-none-block full-width">Developer</h2>
<p class="margin-none">Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.</p>
</div>
+23
View File
@@ -0,0 +1,23 @@
<section>
<strong class="divider txt-large">Appearance</strong>
<div class="theme-switcher flex gap max-width justify-center txt-small margin-block-start-half">
<label class="btn theme-switcher__btn">
<%= icon_tag "sun" %>
<span class="overflow-ellipsis">Always light</span>
<input type="radio" name="theme" data-action="click->theme#setLight" data-theme-target="lightButton">
</label>
<label class="btn theme-switcher__btn" >
<%= icon_tag "moon" %>
<span class="overflow-ellipsis">Always dark</span>
<input type="radio" name="theme" data-action="click->theme#setDark" data-theme-target="darkButton">
</label>
<label class="btn theme-switcher__btn">
<%= icon_tag "monitor" %>
<span class="overflow-ellipsis">Same as OS</span>
<input type="radio" name="theme" data-action="click->theme#setAuto" data-theme-target="autoButton">
</label>
</div>
</section>
+8 -7
View File
@@ -1,13 +1,14 @@
<div class="flex flex-column align-center gap">
<div class="flex flex-column gap align-center margin-block-double">
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
<header class="full-width">
<h2 class="divider txt-large">Link a device</h2>
<p class="margin-none-block" id="session_transfer_label">Use this link to sign-in on another device</p>
</header>
<label class="flex flex-column gap full-width">
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
<header>
<strong class="divider txt-large">Devices</strong>
<p class="margin-none" id="session_transfer_label">Link to automatically log in on another device.</p>
</header>
<span class="flex align-center gap margin-inline">
<input type="text" class="input fill-white" id="session_transfer_url" value="<%= url %>" aria-labelledby="session_transfer_label" readonly>
</span>
</label>
<div class="flex align-center gap">
@@ -5,7 +5,7 @@
<%= @page_title %>
</h1>
<p class="margin-none-block-start margin-block-end-half">
That email confirmation link is no longer valid—they expire after 30 minues. Youll have to try again.
That email confirmation link is no longer valid—they expire after 30 minutes. Youll have to try again.
</p>
<%= link_to "Change my email address", new_user_email_address_path(user_id: @user, script_name: @user.account.slug), class: "btn btn--link center" %>
+10 -16
View File
@@ -37,28 +37,22 @@
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn btn--link", data: { turbo_frame: "_top" } %>
</div>
<% end %>
<% if Current.user == @user %>
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
<%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %>
<span>Sign out of Fizzy on this device</span>
<% end %>
<% end %>
</div>
</section>
<% if Current.user == @user %>
<section class="panel shadow" style="--panel-size: 45ch;">
<%= render "users/theme" %>
<%= render "users/transfer", user: @user %>
<div class="flex flex-column align-center gap margin-block-start-double">
<header class="full-width">
<h2 class="divider txt-large margin-none-block">API</h2>
</header>
<div class="flex align-center gap txt-normal">
<%= link_to "Personal access tokens", my_access_tokens_path, class: "btn" %>
</div>
</div>
<div class="center margin-block-start-double">
<%= button_to session_url(script_name: nil), method: :delete, class: "btn btn--plain txt-link txt-small", data: { turbo: false } do %>
<span>Sign out of Fizzy</span>
<% end %>
</div>
<%= render "users/access_tokens" %>
</section>
<% end %>
</div>
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
echo
gum style --foreground 135 --bold "▸ OSS: Gemfile"
gum style --foreground 240 "bundle $*"
BUNDLE_GEMFILE=Gemfile bundle "$@"
echo
gum style --foreground 135 --bold "▸ SaaS: Gemfile.saas"
gum style --foreground 240 "bundle $*"
BUNDLE_GEMFILE=Gemfile.saas bundle "$@"
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env ruby
# Checks that Gemfile.lock and Gemfile.saas.lock are in sync for shared dependencies.
# Since Gemfile.saas evals Gemfile, shared gems should have identical versions.
#
# Usage:
# bin/bundle-drift [check] # check for drift (default subcommand)
# bin/bundle-drift correct # restore alignment (Gemfile.saas.lock is authoritative)
require "bundler"
require "fileutils"
GEMFILE_LOCK = "Gemfile.lock"
GEMFILE_SAAS_LOCK = "Gemfile.saas.lock"
class GemfileDriftChecker
def initialize
@oss_lockfile = parse_lockfile(GEMFILE_LOCK)
@saas_lockfile = parse_lockfile(GEMFILE_SAAS_LOCK)
end
def check
find_drift.tap do
report it
end
end
private
def parse_lockfile(path)
Bundler::LockfileParser.new(File.read(path))
end
def find_drift
oss_specs, saas_specs = specs_hash(@oss_lockfile), specs_hash(@saas_lockfile)
shared_gems = oss_specs.keys & saas_specs.keys
shared_gems.filter_map do |name|
oss_version, saas_version = oss_specs[name], saas_specs[name]
if oss_version != saas_version
{ name: name, oss: oss_version, saas: saas_version }
end
end.sort_by { |d| d[:name] }
end
def specs_hash(lockfile)
lockfile.specs.to_h { |spec| [ spec.name, spec.version.to_s ] }
end
def report(drift)
if drift.empty?
puts "✓ Gemfile.lock and Gemfile.saas.lock are in sync"
else
puts "✗ Gemfile lock files have drifted!\n\n"
name_width = [ drift.map { |d| d[:name].length }.max, "Gem".length ].max
oss_width = [ drift.map { |d| d[:oss].length }.max, "Gemfile.lock".length ].max
saas_width = [ drift.map { |d| d[:saas].length }.max, "Gemfile.saas.lock".length ].max
puts " #{"Gem".ljust(name_width)} #{"Gemfile.lock".ljust(oss_width)} Gemfile.saas.lock"
puts " #{"-" * name_width} #{"-" * oss_width} #{"-" * saas_width}"
drift.each do |d|
puts " #{d[:name].ljust(name_width)} #{d[:oss].ljust(oss_width)} #{d[:saas]}"
end
puts "\nRun 'bin/bundle-drift correct' to restore alignment."
end
end
end
class GemfileDriftCorrector
def correct
drift = GemfileDriftChecker.new.check
return puts "\nNothing to correct." if drift.empty?
puts "\nRestoring alignment (Gemfile.saas.lock is authoritative)...\n\n"
# Save original for diff
original_content = File.read(GEMFILE_LOCK)
# Seed Gemfile.lock with Gemfile.saas.lock - Bundler will use these as version hints
FileUtils.cp(GEMFILE_SAAS_LOCK, GEMFILE_LOCK)
# Re-lock: Bundler prunes SaaS-only gems while preserving shared versions
puts "▸ Re-locking Gemfile (seeded from Gemfile.saas.lock)"
unless system("BUNDLE_GEMFILE=Gemfile bundle lock")
File.write(GEMFILE_LOCK, original_content)
abort("Failed to lock Gemfile. Restored original.")
end
puts "\n▸ Verifying alignment"
new_drift = GemfileDriftChecker.new.check
if new_drift.empty?
puts "\n✓ Lock files are now in sync"
show_diff(original_content, File.read(GEMFILE_LOCK))
else
puts "\n✗ Lock files still have drift after correction."
puts " Bundler couldn't resolve to matching versions."
puts " Restoring original Gemfile.lock."
File.write(GEMFILE_LOCK, original_content)
exit 1
end
end
private
def show_diff(original, corrected)
require "tempfile"
Tempfile.create("gemfile-lock-original") do |f|
f.write(original)
f.flush
diff = `diff -u #{f.path} #{GEMFILE_LOCK} 2>/dev/null`
unless diff.empty?
puts "\nChanges made to Gemfile.lock:"
puts diff
end
end
end
end
case command = ARGV[0] || "check"
when "check"
exit 1 unless GemfileDriftChecker.new.check.empty?
when "correct"
GemfileDriftCorrector.new.correct
else
abort "Usage: bin/bundle-drift [check|correct]"
end
+1
View File
@@ -11,6 +11,7 @@ CI.run do
step "Style: Ruby", "bin/rubocop"
step "Gemfile: Drift check", "bin/bundle-drift check"
step "Security: Gem audit", "bin/bundler-audit check --update"
step "Security: Importmap audit", "bin/importmap audit"
step "Security: Brakeman audit", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
+1
View File
@@ -30,6 +30,7 @@ env:
clear:
MAILER_FROM_ADDRESS: support@example.com # The email "from" address that Fizzy sends email from
SMTP_ADDRESS: mail.example.com # The SMTP server you'll use to send email
MULTI_TENANT: false # Set to true to allow multiple accounts to sign up
SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container
+11 -4
View File
@@ -34,6 +34,8 @@ Rails.application.configure do
# Store uploaded files on the local file system (see config/storage.yml for options).
if Rails.root.join("tmp/minio-dev.txt").exist?
config.active_storage.service = :devminio
config.x.content_security_policy.connect_src = "http://minio.localhost:39000"
config.x.content_security_policy.img_src = "http://minio.localhost:39000"
else
config.active_storage.service = :local
end
@@ -83,9 +85,14 @@ Rails.application.configure do
config.action_mailer.raise_delivery_errors = false
end
config.hosts = %w[ fizzy.localhost localhost 127.0.0.1 ] + [ /^fizzy-\d+(:\d+)$/ ]
config.hosts = [
"fizzy.localhost",
"localhost",
"127.0.0.1",
/^fizzy-\d+(:\d+)?$/, # review apps: fizzy-123:3000
/\.ts\.net$/ # tailscale serve: hostname.tail1234.ts.net
]
# Set host to be used by links generated in mailer and notification view templates.
config.action_controller.default_url_options = { host: config.hosts.first, port: 3006 }
config.action_mailer.default_url_options = { host: config.hosts.first, port: 3006 }
# Canonical host for mailer URLs (emails always link here, not personal Tailscale URLs)
config.action_mailer.default_url_options = { host: "#{config.hosts.first}:3006" }
end
+8 -4
View File
@@ -11,12 +11,13 @@ Rails.application.configure do
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: smtp_address,
port: ENV.fetch("SMTP_PORT", "587").to_i,
port: ENV.fetch("SMTP_PORT", ENV["SMTP_TLS"] == "true" ? "465" : "587").to_i,
domain: ENV.fetch("SMTP_DOMAIN", nil),
user_name: ENV.fetch("SMTP_USERNAME", nil),
password: ENV.fetch("SMTP_PASSWORD", nil),
authentication: ENV.fetch("SMTP_AUTHENTICATION", "plain"),
enable_starttls_auto: ENV.fetch("SMTP_ENABLE_STARTTLS_AUTO", "true") == "true"
tls: ENV["SMTP_TLS"] == "true",
openssl_verify_mode: ENV["SMTP_SSL_VERIFY_MODE"]
}
end
@@ -60,12 +61,15 @@ Rails.application.configure do
# config.action_cable.url = "wss://example.com/cable"
# config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
# Set DISABLE_SSL=true to disable all SSL options, rather than specify each individually
ssl_enabled = "true" unless ENV["DISABLE_SSL"] == "true"
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
# Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies.
config.assume_ssl = ENV.fetch("ASSUME_SSL", "true") == "true"
config.assume_ssl = ENV.fetch("ASSUME_SSL", ssl_enabled) == "true"
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = ENV.fetch("FORCE_SSL", "true") == "true"
config.force_ssl = ENV.fetch("FORCE_SSL", ssl_enabled) == "true"
# Log to STDOUT by default
config.logger = ActiveSupport::Logger.new(STDOUT)
+3
View File
@@ -67,4 +67,7 @@ Rails.application.configure do
# Load test helpers
config.autoload_paths += %w[ test/test_helpers ]
# Enable multi-tenant mode for tests
config.x.multi_tenant.enabled = true
end
@@ -1,7 +0,0 @@
# FIXME: Upstream to Rails
Rails.application.config.after_initialize do
ActionController.add_renderer :svg do |svg, options|
self.content_type = Mime[:svg] if media_type.nil?
svg
end
end
+10
View File
@@ -35,6 +35,16 @@ module ActiveStorageControllerExtensions
end
end
module ActiveStorageDirectUploadsControllerExtensions
extend ActiveSupport::Concern
included do
include Authentication
skip_forgery_protection if: :authenticate_by_bearer_token
end
end
Rails.application.config.to_prepare do
ActiveStorage::BaseController.include ActiveStorageControllerExtensions
ActiveStorage::DirectUploadsController.include ActiveStorageDirectUploadsControllerExtensions
end
+4 -4
View File
@@ -2,9 +2,9 @@ require "deployment"
require_relative "extensions"
if ActiveRecord::Base.replica_configured?
Rails.application.configure do
config.active_record.database_selector = { delay: 0.seconds }
config.active_record.database_resolver = Deployment::DatabaseResolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
Rails.application.configure do
config.active_record.database_selector = { delay: 0.seconds }
config.active_record.database_resolver = Deployment::DatabaseResolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
end
end
+5
View File
@@ -0,0 +1,5 @@
Rails.application.configure do
config.after_initialize do
Account.multi_tenant = ENV["MULTI_TENANT"] == "true" || config.x.multi_tenant.enabled == true
end
end
+14
View File
@@ -0,0 +1,14 @@
# Disable Openslide to prevent sqlite segfault in forked parallel workers
# Requires libvips 8.13+
Vips.block "VipsForeignLoadOpenslide", true if Vips.respond_to?(:block)
# Limit libvips to 4 threads for each thread pool. Default is #CPUs.
Vips.concurrency_set 4
# Limit libvips caches to reduce memory pressure.
#
# Do not disable entirely since libvips relies on some caching internally.
# (When we disabled caches, we hit a ton of JPEG out of order read errors.)
Vips.cache_set_max 10 # Default 100
Vips.cache_set_max_mem 10.megabytes # Default 100MB
Vips.cache_set_max_files 10 # Default 100
+4 -1
View File
@@ -178,10 +178,11 @@ curl -X POST \
"content_type": "image/png"
}
}' \
https://app.fizzy.do/rails/active_storage/direct_uploads
https://app.fizzy.do/123456/rails/active_storage/direct_uploads
```
The `checksum` is a Base64-encoded MD5 hash of the file content.
The direct upload endpoint is scoped to your account (replace `/123456` with your account slug).
__Response:__
@@ -495,6 +496,7 @@ Creates a new card in a board.
| `image` | file | No | Header image for the card |
| `tag_ids` | array | No | Array of tag IDs to apply to the card |
| `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) |
| `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) |
__Request:__
@@ -522,6 +524,7 @@ Updates a card.
| `status` | string | No | Card status: `drafted`, `published` |
| `image` | file | No | Header image for the card |
| `tag_ids` | array | No | Array of tag IDs to apply to the card |
| `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) |
__Request:__
+80
View File
@@ -0,0 +1,80 @@
## Development
### Setting up
First, get everything installed and configured with:
```sh
bin/setup
bin/setup --reset # Reset the database and seed it
```
And then run the development server:
```sh
bin/dev
```
You'll be able to access the app in development at http://fizzy.localhost:3006.
To login, enter `david@example.com` and grab the verification code from the browser console to sign in.
### Web Push Notifications
Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:
- `VAPID_PRIVATE_KEY`
- `VAPID_PUBLIC_KEY`
Generate them with the `web-push` gem:
```ruby
vapid_key = WebPush.generate_key
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
### Running tests
For fast feedback loops, unit tests can be run with:
```sh
bin/rails test
```
The full continuous integration tests can be run with:
```sh
bin/ci
```
### Database configuration
Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL:
```sh
DATABASE_ADAPTER=mysql bin/setup --reset
DATABASE_ADAPTER=mysql bin/ci
```
The remote CI pipeline will run tests against both SQLite and MySQL.
### Outbound Emails
You can view email previews at http://fizzy.localhost:3006/rails/mailers.
You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with:
```sh
bin/rails dev:email
```
Under the hood, this will create or remove `tmp/email-dev.txt`.
## SaaS gem
37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup.
This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure.
+167
View File
@@ -0,0 +1,167 @@
## Deploying with Docker
We provide pre-built Docker images that can be used to run Fizzy on your own server.
If you don't need to change the source code, and just want the out-of-the-box Fizzy experience, this can be a great way to get started.
You'll find the latest version of Fizzy's Docker image at `ghcr.io/basecamp/fizzy:main`.
To run it you'll need three things: a machine that runs Docker; a mounted volume (so that your database is stored somewhere that is kept around between restarts); and some environment variables for configuration.
### Mounting a storage volume
The standard Fizzy setup keeps all of its storage inside the path `/rails/storage`.
By default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location.
The simplest way to do this is with the `--volume` flag with `docker run`. For example:
```sh
docker run --volume fizzy:/rails/storage ghcr.io/basecamp/fizzy:main
```
That will create a named volume (called `fizzy`) and mount it into the correct path.
Docker will manage where that volume is actually stored on your server.
You can also specify the data location yourself, mount a network drive, and more.
Check the Docker documentation to find out more about what's available.
### Configuring with environment variables
To configure your Fizzy installation, you can use environment variables.
Fizzy has several of them.
Many of these are optional, but at a minimum you'll want to configure your secret key, your SSL domain, and your SMTP email settings.
#### Secret Key Base
Various features inside Fizzy rely on cryptography to work (such as secure links).
To set this up, you need to provide a secret value that will be used as the basis of those secrets.
This value can be anything, but it should be unguessable, and specific to your instance.
You can use any long random string for this, or you can have the Fizzy codebase generate one for you by running:
```sh
bin/rails secret
```
Once you have one, set it in the `SECRET_KEY_BASE` environment variable:
```sh
docker run --environment SECRET_KEY_BASE=abcdefabcdef ...
```
#### SSL
If you want the Fizzy container to handle its own SSL automatically, you just need to specify the domain name that you're running it on.
You can do that with the `TLS_DOMAIN` environment variable.
Note that if you're using SSL, you'll want to allow traffic on ports 80 and 443.
So if you were running on `fizzy.example.com` you could enable SSL like this:
```sh
docker run --publish 80:80 --publish 443:443 --environment TLS_DOMAIN=fizzy.example.com ...
```
If you are terminating SSL in some other proxy in front of Fizzy, then you don't need to set `TLS_DOMAIN`, and can just publish port 80:
```sh
docker run --publish 80:80 ...
```
If you aren't using SSL at all (for example, if you want to run it locally on your laptop) then you should specify `DISABLE_SSL=true` instead:
```sh
docker run --publish 80:80 --environment DISABLE_SSL=true ...
```
#### SMTP Email
Fizzy needs to be able to send email for its sign up/sign in flow, and for its regular summary emails.
The easiest way to set this up is to use a 3rd-party email provider (such as Postmark, Sendgrid, and so on).
You can then plug all your SMTP settings from that provider into Fizzy via the following environment variables:
- `MAILER_FROM_ADDRESS` - the "from" address that Fizzy should use to send email
- `SMTP_ADDRESS` - the address of the SMTP server you'll send through
- `SMTP_PORT` - the port number (defaults to 465 when `SMTP_TLS` is set, 587 otherwise)
- `SMTP_USERNAME`/`SMTP_PASSWORD` - the credentials for logging in to the SMTP server
Less commonly, you might also need to set some of the following:
- `SMTP_TLS` - set to `true` only for servers requiring implicit TLS (SMTPS on port 465); STARTTLS is used automatically by default so most servers don't need this
- `SMTP_DOMAIN` - the domain name advertised to the server when connecting
- `SMTP_AUTHENTICATION` - if you need an authentication method other than the default `plain`
- `SMTP_SSL_VERIFY_MODE` - set to `none` to skip certificate verification (for self-signed certs)
You can find out more about all these settings in the [Rails Action Mailer documentation](https://guides.rubyonrails.org/action_mailer_basics.html#action-mailer-configuration).
#### VAPID keys
Fizzy can also send Web Push notifications.
To do this it needs a VAPID key pair.
You can create your own keys by starting a development console with:
```sh
bin/rails c
```
And then run the following to create the keypair:
```ruby
vapid_key = WebPush.generate_key
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
Set those in the `VAPID_PRIVATE_KEY` and `VAPID_PUBLIC_KEY` environment variables.
#### S3 storage (optional)
If you're rather that uploaded files were stored in an S3 bucket, rather than your mounted volume, you can set that up.
First set `ACTIVE_STORAGE_SERVICE` to `s3`.
Then set the following as appropriate for your S3 bucket:
- `S3_BUCKET`
- `S3_REGION`
- `S3_ACCESS_KEY_ID`
- `S3_SECRET_ACCESS_KEY`
If you're using a provider other than AWS, you will also need some of the following:
- `S3_ENDPOINT`
- `S3_FORCE_PATH_STYLE`
- `S3_REQUEST_CHECKSUM_CALCULATION`
- `S3_RESPONSE_CHECKSUM_VALIDATION`
#### Multi-tenant mode
By default, when you run the Fizzy Docker image you'll be limited to creating a single account (although that account can have as many users as you like).
This is for convenience: typically when you self-host you'll be running a single account, so in this mode new account signups are automatically disabled as soon as you've created your first account.
If you do want to allow multiple accounts to be created in your instance, set `MULTI_TENANT=true`
## Example
Here's an example of a `docker-compose.yml` that you could use to run Fizzy via `docker compose up`
```yaml
services:
web:
image: ghcr.io/basecamp/fizzy:main
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- SECRET_KEY_BASE=abcdefabcdef
- TLS_DOMAIN=fizzy.example.com
- MAILER_FROM_ADDRESS=fizzy@example.com
- SMTP_ADDRESS=mail.example.com
- SMTP_USERNAME=user
- SMTP_PASSWORD=pass
- VAPID_PRIVATE_KEY=myvapidprivatekey
- VAPID_PUBLIC_KEY=myvapidpublickey
volumes:
- fizzy:/rails/storage
volumes:
fizzy:
```
+112
View File
@@ -0,0 +1,112 @@
## Deploying Fizzy with Kamal
If you'd like to run Fizzy on your own server while having the freedom to easily make changes to its code, we recommend deploying it with [Kamal](https://kamal-deploy.org/).
Kamal makes it easy to set up a bare server, copy the application to it, and manage the configuration settings that it uses.
(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).)
This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it.
The steps to configure your very own Fizzy are:
1. Fork the repo
2. Initialize Kamal by running `kamal init`. This command generates the `.kamal` directory along with the required configuration files, including `.kamal/secrets`.
3. Edit few things in config/deploy.yml and .kamal/secrets
4. Run `kamal setup` to do your first deploy.
We'll go through each of these in turn.
### Fork the repo
To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo.
That allows you to commit your changes, and track them over time.
You can always re-sync your fork to pick up new changes from the main repo over time.
Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed.
### Editing the configuration
The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance.
To get started, the parts you need to change are all in the "About your deployment" section.
We've added comments to that file to highlight what each setting needs to be, but the main ones are:
- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`.
- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here.
- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off.
- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.
- `env/clear/SMTP_ADDRESS`: The address of an SMTP server that you can send email through. You can use a 3rd-party service for this, like Sendgrid or Postmark, in which case their documentation will tell you what to use for this.
- `env/clear/MULTI_TENANT`: Set to `true` if you want to allow multiple accounts to sign up on your server (by default, Fizzy will allow you to create a single account).
Fizzy also requires a few environment variables to be set up, some of which contain secrets.
The simplest way to do this is to put them in a file called `.kamal/secrets`.
Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally.
If you use a password manager like 1Password, you can also opt to keep your secrets there instead.
Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that.
To store your secrets, create the file `.kamal/secrets` and enter something like the following:
```ini
SECRET_KEY_BASE=12345
VAPID_PUBLIC_KEY=something
VAPID_PRIVATE_KEY=somethingelse
SMTP_USERNAME=email-provider-username
SMTP_PASSWORD=email-provider-password
```
The values you enter here will be specific to you, and you can get or create them as follows:
- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this.
- `SMTP_USERNAME` & `SMTP_PASSWORD` should be valid credentials for your SMTP server. If you're using a 3rd-party service here, consult their documentation for what to use.
- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with:
```sh
bin/rails c
```
And then run the following to create a new pair of keys:
```ruby
vapid_key = WebPush.generate_key
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
```
Once you've made all those changes, commit them to your fork so they're saved.
### Deploy Fizzy!
You can now do your first deploy by running:
```sh
bin/kamal setup
```
This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running.
After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run:
```sh
bin/kamal deploy
```
### Configuring file storage (Active Storage)
Production uses the local disk service by default. To use any other service defined in `config/storage.yml`, set `ACTIVE_STORAGE_SERVICE`.
To use the included `s3` service, set:
- `ACTIVE_STORAGE_SERVICE=s3`
- `S3_ACCESS_KEY_ID`
- `S3_BUCKET` (defaults to `fizzy-#{Rails.env}-activestorage`)
- `S3_REGION` (defaults to `us-east-1`)
- `S3_SECRET_ACCESS_KEY`
Optional for S3-compatible endpoints:
- `S3_ENDPOINT`
- `S3_FORCE_PATH_STYLE=true`
- `S3_REQUEST_CHECKSUM_CALCULATION` (defaults to `when_supported`)
- `S3_RESPONSE_CHECKSUM_VALIDATION` (defaults to `when_supported`)
@@ -0,0 +1,72 @@
require "test_helper"
class ActiveStorage::DirectUploadsControllerTest < ActionDispatch::IntegrationTest
setup do
@blob_params = {
blob: {
filename: "screenshot.png",
byte_size: 12345,
checksum: "GQ5SqLsM7ylnji0Wgd9wNC==",
content_type: "image/png"
}
}
end
test "create" do
sign_in_as :david
post rails_direct_uploads_path,
params: @blob_params,
headers: bearer_token_header(identity_access_tokens(:davids_api_token).token),
as: :json
assert_response :success
assert_includes response.parsed_body.keys, "direct_upload"
end
test "create with valid access token" do
post rails_direct_uploads_path,
params: @blob_params,
headers: bearer_token_header(identity_access_tokens(:davids_api_token).token),
as: :json
assert_response :success
assert_includes response.parsed_body.keys, "direct_upload"
end
test "create with read-only access token" do
post rails_direct_uploads_path,
params: @blob_params,
headers: bearer_token_header(identity_access_tokens(:jasons_api_token).token),
as: :json
assert_response :unauthorized
end
test "create with invalid access token" do
post rails_direct_uploads_path,
params: @blob_params,
headers: bearer_token_header("invalid_token"),
as: :json
assert_response :unauthorized
end
test "create unauthenticated" do
post rails_direct_uploads_path,
params: @blob_params.merge(authenticity_token: csrf_token),
as: :json
assert_response :redirect
end
private
def bearer_token_header(token)
{ "Authorization" => "Bearer #{token}" }
end
def csrf_token
get new_session_url
response.body[/name="csrf-token" content="([^"]+)"/, 1]
end
end
+81 -11
View File
@@ -21,8 +21,9 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
end
card = Card.last
assert card.drafted?
assert_redirected_to card
assert card.drafted?
end
test "create resumes existing draft if it exists" do
@@ -30,9 +31,8 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
assert_no_difference -> { Card.count } do
post board_cards_path(boards(:writebook))
assert_redirected_to draft
end
assert_redirected_to draft
end
test "show" do
@@ -68,6 +68,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
boards(:writebook).update! all_access: false
boards(:writebook).accesses.revoke_from users(:kevin)
get card_path(cards(:logo))
assert_response :not_found
end
@@ -75,6 +76,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
test "admins can see delete button on any card" do
get card_path(cards(:logo))
assert_response :success
assert_match "Delete this card", response.body
end
@@ -83,6 +85,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
get card_path(cards(:logo))
assert_response :success
assert_match "Delete this card", response.body
end
@@ -91,6 +94,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
get card_path(cards(:logo))
assert_response :success
assert_no_match "Delete this card", response.body
end
@@ -124,10 +128,9 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
test "show card with comment containing malformed remote image attachment" do
card = cards(:logo)
card.comments.create!(
card.comments.create! \
creator: users(:kevin),
body: '<action-text-attachment url="image.png" content-type="image/*" presentation="gallery"></action-text-attachment>'
)
get card_path(card)
assert_response :success
@@ -140,6 +143,7 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
get card_path(card), as: :json
assert_response :success
assert_equal card.title, @response.parsed_body["title"]
assert_equal 2, @response.parsed_body["steps"].size
end
@@ -149,12 +153,12 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
post board_cards_path(boards(:writebook)),
params: { card: { title: "My new card", description: "Big if true", tag_ids: [ tags(:web).id, tags(:mobile).id ] } },
as: :json
assert_response :created
end
assert_response :created
assert_equal card_path(Card.last, format: :json), @response.headers["Location"]
card = Card.last
assert_equal card_path(card, format: :json), @response.headers["Location"]
assert_equal "My new card", card.title
assert_equal "Big if true", card.description.to_plain_text
assert_equal [ tags(:mobile), tags(:web) ].sort, card.tags.sort
@@ -167,25 +171,91 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
post board_cards_path(boards(:writebook)),
params: { card: { title: "Backdated card", created_at: custom_time } },
as: :json
assert_response :created
end
assert_response :created
assert_equal custom_time, Card.last.created_at
end
test "create as JSON with custom last_active_at" do
created_time = Time.utc(2024, 1, 15, 10, 30, 0)
last_active_time = Time.utc(2024, 6, 1, 12, 0, 0)
assert_difference -> { Card.count }, +1 do
post board_cards_path(boards(:writebook)),
params: { card: { title: "Card with activity", created_at: created_time, last_active_at: last_active_time } },
as: :json
assert_response :created
end
card = Card.last
assert_equal created_time, card.created_at
assert_equal last_active_time, card.last_active_at
end
test "create as JSON defaults last_active_at to created_at when not provided" do
created_time = Time.utc(2024, 1, 15, 10, 30, 0)
assert_difference -> { Card.count }, +1 do
post board_cards_path(boards(:writebook)),
params: { card: { title: "Backdated card without last_active_at", created_at: created_time } },
as: :json
assert_response :created
end
card = Card.last
assert_equal created_time, card.created_at
assert_equal created_time, card.last_active_at
end
test "update as JSON with custom last_active_at" do
card = cards(:logo)
custom_time = Time.utc(2024, 3, 15, 14, 0, 0)
put card_path(card, format: :json), params: { card: { last_active_at: custom_time } }
assert_response :success
assert_equal custom_time, card.reload.last_active_at
end
test "update as JSON can restore last_active_at after comments overwrite it" do
created_time = Time.utc(2024, 1, 15, 10, 30, 0)
last_active_time = Time.utc(2024, 6, 1, 12, 0, 0)
# Create a card with custom timestamps (simulating import)
post board_cards_path(boards(:writebook)),
params: { card: { title: "Imported card", created_at: created_time, last_active_at: last_active_time } },
as: :json
assert_response :created
card = Card.last
# Adding a comment overwrites last_active_at (this is expected)
card.comments.create!(creator: users(:kevin), body: "Imported comment")
assert_not_equal last_active_time, card.reload.last_active_at
# After import, restore the correct last_active_at
put card_path(card, format: :json), params: { card: { last_active_at: last_active_time } }
assert_response :success
assert_equal last_active_time, card.reload.last_active_at
end
test "update as JSON" do
card = cards(:logo)
put card_path(card, format: :json), params: { card: { title: "Update test" } }
put card_path(card, format: :json), params: { card: { title: "Update test" } }
assert_response :success
assert_equal "Update test", card.reload.title
end
test "delete as JSON" do
card = cards(:logo)
delete card_path(card, format: :json)
delete card_path(card, format: :json)
assert_response :no_content
assert_not Card.exists?(card.id)
end
end
@@ -12,17 +12,17 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
ActionController::Base.allow_forgery_protection = @original_allow_forgery_protection
end
test "succeeds when CSRF token is valid" do
assert_difference -> { Board.count }, +1 do
test "fails if Sec-Fetch-Site is cross-site" do
assert_no_difference -> { Board.count } do
post boards_path,
params: { board: { name: "Test Board" }, authenticity_token: csrf_token },
params: { board: { name: "Test Board" } },
headers: { "Sec-Fetch-Site" => "cross-site" }
end
assert_response :redirect
assert_response :unprocessable_entity
end
test "succeeds with same-origin Sec-Fetch-Site even without CSRF token" do
test "succeeds with same-origin Sec-Fetch-Site" do
assert_difference -> { Board.count }, +1 do
post boards_path,
params: { board: { name: "Test Board" } },
@@ -32,7 +32,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
assert_response :redirect
end
test "succeeds with same-site Sec-Fetch-Site even without CSRF token" do
test "succeeds with same-site Sec-Fetch-Site" do
assert_difference -> { Board.count }, +1 do
post boards_path,
params: { board: { name: "Test Board" } },
@@ -42,17 +42,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
assert_response :redirect
end
test "fails with cross-site Sec-Fetch-Site and no CSRF token" do
assert_no_difference -> { Board.count } do
post boards_path,
params: { board: { name: "Test Board" } },
headers: { "Sec-Fetch-Site" => "cross-site" }
end
assert_response :unprocessable_entity
end
test "fails with none Sec-Fetch-Site and no CSRF token" do
test "fails with none Sec-Fetch-Site" do
assert_no_difference -> { Board.count } do
post boards_path,
params: { board: { name: "Test Board" } },
@@ -62,7 +52,7 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
end
test "fails when Sec-Fetch-Site header is missing and no CSRF token" do
test "fails when Sec-Fetch-Site header is missing" do
assert_no_difference -> { Board.count } do
post boards_path, params: { board: { name: "Test Board" } }
end
@@ -70,16 +60,6 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
end
test "fails with invalid CSRF token and cross-site Sec-Fetch-Site" do
assert_no_difference -> { Board.count } do
post boards_path,
params: { board: { name: "Test Board" }, authenticity_token: "invalid-token" },
headers: { "Sec-Fetch-Site" => "cross-site" }
end
assert_response :unprocessable_entity
end
test "GET requests succeed regardless of Sec-Fetch-Site header" do
get board_path(boards(:writebook)), headers: { "Sec-Fetch-Site" => "cross-site" }
@@ -102,6 +82,27 @@ class RequestForgeryProtectionTest < ActionDispatch::IntegrationTest
assert_includes response.headers["Vary"], "Sec-Fetch-Site"
end
test "JSON request succeeds with missing Sec-Fetch-Site" do
assert_difference -> { Board.count }, +1 do
post boards_path,
params: { board: { name: "Test Board" } },
as: :json
end
assert_response :created
end
test "JSON request fails with cross-site Sec-Fetch-Site" do
assert_no_difference -> { Board.count } do
post boards_path,
params: { board: { name: "Test Board" } },
headers: { "Sec-Fetch-Site" => "cross-site" },
as: :json
end
assert_response :unprocessable_entity
end
private
def csrf_token
@csrf_token ||= begin
@@ -1,34 +0,0 @@
require "test_helper"
class NonProductionRemoteAccessTest < ActionDispatch::IntegrationTest
test "staff can access in staging environment" do
sign_in_as :david
Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging"))
get cards_path
assert_response :success
end
test "non-staff cannot access in staging environment" do
sign_in_as :jz
Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("staging"))
get cards_path
assert_response :forbidden
end
test "non-staff can access in production environment" do
sign_in_as :jz
Rails.stubs(:env).returns(ActiveSupport::EnvironmentInquirer.new("production"))
get cards_path
assert_response :success
end
test "non-staff can access in local environment" do
sign_in_as :jz
get cards_path
assert_response :success
end
end
@@ -0,0 +1,16 @@
require "test_helper"
class QrCodesControllerTest < ActionDispatch::IntegrationTest
test "show" do
join_code = account_join_codes(:"37s")
account = accounts("37s")
url = join_url(code: join_code.code, script_name: account.slug, host: "app.fizzy.do")
signed_token = QrCodeLink.new(url).signed
get qr_code_path(signed_token)
assert_response :success
assert_match %r{image/svg\+xml}, response.content_type
assert_includes response.body, "<svg"
end
end
@@ -5,6 +5,14 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
untenanted do
get session_magic_link_url
assert_response :redirect, "Without an email address pending authentication, should redirect"
assert_redirected_to new_session_path
end
untenanted do
post session_path, params: { email_address: "test@example.com" }
get session_magic_link_url
assert_response :success
end
end
@@ -14,6 +22,7 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
magic_link = MagicLink.create!(identity: identity)
untenanted do
post session_path, params: { email_address: identity.email_address }
post session_magic_link_url, params: { code: magic_link.code }
assert_response :redirect
@@ -28,6 +37,7 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
magic_link = MagicLink.create!(identity: identity, purpose: :sign_up)
untenanted do
post session_path, params: { email_address: identity.email_address }
post session_magic_link_url, params: { code: magic_link.code }
assert_response :redirect
@@ -37,6 +47,20 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest
end
end
test "create with cross-user code" do
identity = identities(:kevin)
other_identity = identities(:jason)
magic_link = MagicLink.create!(identity: other_identity)
untenanted do
post session_path, params: { email_address: identity.email_address }
post session_magic_link_url, params: { code: magic_link.code }
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
end
end
test "create with invalid code" do
identity = identities(:kevin)
magic_link = MagicLink.create!(identity: identity)
@@ -9,6 +9,15 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "new redirects authenticated users" do
sign_in_as :kevin
untenanted do
get new_session_path
assert_redirected_to root_url
end
end
test "create" do
identity = identities(:kevin)
@@ -36,6 +45,21 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end
end
test "create for a new user when single tenant mode already has a tenant" do
with_multi_tenant_mode(false) do
untenanted do
assert_no_difference -> { MagicLink.count } do
assert_no_difference -> { Identity.count } do
post session_path,
params: { email_address: "nonexistent-#{SecureRandom.hex(6)}@example.com" }
end
end
assert_redirected_to session_magic_link_path
end
end
end
test "create with invalid email address" do
# Avoid Sentry exceptions when attackers try to stuff invalid emails. The browser performs form
# field validation that should normally prevent this from occurring, so I'm not worried about
@@ -63,4 +63,16 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to new_signup_completion_path
end
end
test "redirects to session#new when single_tenant and user exists" do
users(:david)
with_multi_tenant_mode(false) do
untenanted do
get new_signup_path
assert_redirected_to new_session_url
end
end
end
end
+13 -1
View File
@@ -65,7 +65,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_response :forbidden
end
test "update with invalid avatar shows validation error" do
test "update with invalid avatar content type shows validation error" do
sign_in_as :kevin
svg_file = fixture_file_upload("avatar.svg", "image/svg+xml")
@@ -76,6 +76,18 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_select ".txt-negative", text: /must be a JPEG, PNG, GIF, or WebP image/
end
test "update with oversized avatar shows validation error" do
sign_in_as :kevin
png_file = fixture_file_upload("avatar.png", "image/png")
ActiveStorage::Analyzer::ImageAnalyzer::Vips.any_instance.stubs(:metadata).returns({ width: 5000, height: 100 })
put user_path(users(:kevin)), params: { user: { avatar: png_file } }
assert_response :unprocessable_entity
assert_select ".txt-negative", text: /width must be less than 4096px/
end
test "update with valid avatar" do
sign_in_as :kevin
@@ -0,0 +1,22 @@
require "test_helper"
class Account::MultiTenantableTest < ActiveSupport::TestCase
test "accepting_signups? is true when multi_tenant is enabled" do
with_multi_tenant_mode(true) do
assert Account.accepting_signups?
end
end
test "accepting_signups? is false when multi_tenant is disabled and accounts exist" do
with_multi_tenant_mode(false) do
assert_not Account.accepting_signups?
end
end
test "accepting_signups? is true when multi_tenant is disabled but no accounts exist" do
with_multi_tenant_mode(false) do
Account.delete_all
assert Account.accepting_signups?
end
end
end
+39 -3
View File
@@ -5,11 +5,47 @@ class Card::EventableTest < ActiveSupport::TestCase
Current.session = sessions(:david)
end
test "new cards get the current time as the last activity time" do
test "new cards default last_active_at to created_at" do
freeze_time
card = boards(:writebook).cards.create!(title: "Some card card", creator: users(:david))
assert_equal Time.current, card.last_active_at
card = boards(:writebook).cards.create!(title: "Some card", creator: users(:david))
assert_equal card.created_at, card.last_active_at
end
test "new cards with custom created_at default last_active_at to that time" do
custom_time = 1.week.ago.change(usec: 0)
card = boards(:writebook).cards.create!(title: "Backdated card", creator: users(:david), created_at: custom_time)
assert_equal custom_time, card.created_at
assert_equal custom_time, card.last_active_at
end
test "new cards preserve explicit last_active_at" do
created_time = 2.weeks.ago.change(usec: 0)
last_active_time = 1.week.ago.change(usec: 0)
card = boards(:writebook).cards.create! \
title: "Card with explicit timestamps",
creator: users(:david),
created_at: created_time,
last_active_at: last_active_time
assert_equal created_time, card.created_at
assert_equal last_active_time, card.last_active_at
end
test "publishing a card does not overwrite last_active_at" do
created_time = 2.weeks.ago.change(usec: 0)
last_active_time = 1.week.ago.change(usec: 0)
card = boards(:writebook).cards.create! \
title: "Published card",
creator: users(:david),
status: :published,
created_at: created_time,
last_active_at: last_active_time
assert_equal last_active_time, card.last_active_at
end
test "tracking events update the last activity time" do
+24 -13
View File
@@ -3,40 +3,51 @@ require "test_helper"
class Card::GoldenTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
@golden, @non_golden = cards(:logo), cards(:text)
end
test "check whether a card is golden" do
assert cards(:logo).golden?
assert_not cards(:text).golden?
assert @golden.golden?
assert_not @non_golden.golden?
end
test "promote and demote from golden" do
assert_changes -> { cards(:text).reload.golden? }, to: true do
cards(:text).gild
assert_changes -> { @non_golden.reload.golden? }, to: true do
@non_golden.gild
end
assert_changes -> { cards(:logo).reload.golden? }, to: false do
cards(:logo).ungild
assert_changes -> { @golden.reload.golden? }, to: false do
@golden.ungild
end
end
test "scopes" do
assert_includes Card.golden, cards(:logo)
assert_not_includes Card.golden, cards(:text)
assert_includes Card.golden, @golden
assert_not_includes Card.golden, @non_golden
end
test "with_golden_first orders golden first" do
ordered = Card.where(id: [ @golden.id, @non_golden.id ]).with_golden_first
assert_equal [ @golden, @non_golden ], ordered.to_a
# Preserves base ordering as subordering
@non_golden.gild
base_ordered = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).to_a
with_golden = Card.where(id: [ @golden.id, @non_golden.id ]).order(id: :desc).with_golden_first.to_a
assert_equal base_ordered, with_golden
end
test "gilding a card touches both the card and the board" do
card = cards(:text)
board = card.board
board = @non_golden.board
card_updated_at = card.updated_at
card_updated_at = @non_golden.updated_at
board_updated_at = board.updated_at
travel 1.minute do
card.gild
@non_golden.gild
end
assert card.reload.updated_at > card_updated_at
assert @non_golden.reload.updated_at > card_updated_at
assert board.reload.updated_at > board_updated_at
end
end

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