Merge branch 'main' into mobile-app/scoped-stylesheets
* main: (70 commits) bin/ci: run OSS suite in SAAS mode for full coverage (#2029) Fix flaky tests caused by leaky show_exceptions twiddling (#2028) Fix ActiveStorage FileNotFoundError with immediate variant processing (#2022) Immediate avatar and embed variants (#2002) Should be in global gitignore Account for browser button styled and adjust mobile padding for perma BG Wrap the overflow menu doc: update style doc reference for LLM agents Fix Identity destruction callback ordering Ensure user toggles on board edit page are cached properly Adopt a cooldown period for dependency updates Add lazy error context for Rails error reporter (#2014) Disable board edit buttons for select all/none when not privileged Autotuner: use structured logging instead of Sentry (#2011) Add datetime to the search results Fix involvement button label Bump mittens to 0.3.1 (#2006) No need to pass Currentl.user since it is the default value for the param Validate email before creating identity during join code redemption Ignore RubyMine project files in .gitignore ...
This commit is contained in:
@@ -20,8 +20,14 @@ updates:
|
||||
dependency-type: "development"
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 14
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
cooldown:
|
||||
default-days: 7
|
||||
semver-major-days: 14
|
||||
|
||||
@@ -148,4 +148,4 @@ Use Chrome MCP tools to interact with the running dev app for UI testing and deb
|
||||
|
||||
## Coding style
|
||||
|
||||
Please read the separate file `STYLE.md` for some guidance on coding style.
|
||||
@STYLE.md
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ RUN apt-get update -qq && \
|
||||
ENV RAILS_ENV="production" \
|
||||
BUNDLE_DEPLOYMENT="1" \
|
||||
BUNDLE_PATH="/usr/local/bundle" \
|
||||
BUNDLE_WITHOUT="development" \
|
||||
BUNDLE_WITHOUT="development:test" \
|
||||
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
|
||||
|
||||
# Throw-away build stage to reduce size of final image
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 3c3f6c8a253c8a0f346695374f927c9ab32fbefb
|
||||
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
|
||||
branch: main
|
||||
specs:
|
||||
actioncable (8.2.0.alpha)
|
||||
@@ -237,7 +237,7 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lexxy (0.1.20.beta)
|
||||
lexxy (0.1.23.beta)
|
||||
rails (>= 8.0.2)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
@@ -267,7 +267,7 @@ GEM
|
||||
railties (>= 7.1)
|
||||
stimulus-rails
|
||||
turbo-rails
|
||||
mittens (0.3.0)
|
||||
mittens (0.3.1)
|
||||
mocha (2.8.2)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
|
||||
+5
-5
@@ -21,7 +21,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/basecamp/fizzy-saas
|
||||
revision: 97d126e0a6084905aceaadf4facaa2a48b44e124
|
||||
revision: 43dbc896ce7b6a08194a92ddd1695d3f1ebf554b
|
||||
specs:
|
||||
fizzy-saas (0.1.0)
|
||||
audits1984
|
||||
@@ -75,7 +75,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 690ec8898318b8f50714e86676353ebe1551261e
|
||||
revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0
|
||||
branch: main
|
||||
specs:
|
||||
actioncable (8.2.0.alpha)
|
||||
@@ -292,7 +292,7 @@ GEM
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
json (2.17.1)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.9.0)
|
||||
@@ -313,7 +313,7 @@ GEM
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
lexxy (0.1.20.beta)
|
||||
lexxy (0.1.23.beta)
|
||||
rails (>= 8.0.2)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
@@ -552,7 +552,7 @@ GEM
|
||||
ostruct
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.8)
|
||||
stringio (3.1.9)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
|
||||
@@ -2,6 +2,99 @@
|
||||
|
||||
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.
|
||||
|
||||
(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).)
|
||||
|
||||
This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it.
|
||||
|
||||
The steps to configure your very own Fizzy are:
|
||||
|
||||
1. Fork the repo
|
||||
2. Edit few things in config/deploy.yml, .kamal/secrets, and config/environments/production.rb
|
||||
3. Run `kamal setup` to do your first deploy.
|
||||
|
||||
We'll go through each of these in turn.
|
||||
|
||||
### Fork the repo
|
||||
|
||||
To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo.
|
||||
That allows you to commit your changes, and track them over time.
|
||||
You can always re-sync your fork to pick up new changes from the main repo over time.
|
||||
|
||||
Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed.
|
||||
|
||||
### Editing the configuration
|
||||
|
||||
The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance.
|
||||
To get started, the parts you need to change are all in the "About your deployment" section.
|
||||
We've added comments to that file to highlight what each setting needs to be, but the main ones are:
|
||||
|
||||
- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`.
|
||||
- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here.
|
||||
- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off.
|
||||
- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy.
|
||||
|
||||
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.
|
||||
- `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}"
|
||||
```
|
||||
|
||||
- `SMTP_USERNAME`/`SMTP_PASSWORD` are credentials you should get from your email provider.
|
||||
|
||||
Lastly, you'll need to set up the rest of your email configuration in `config/environments/production.rb`. There is an example configuration in comments at the top of that file. The actual settings you use here will depend on your email provider, but in most cases will look similar to that section, so you can uncomment it and edit to suit. Note that it will use the `SMTP_USERNAME` and `SMTP_PASSWORD` values you entered in your secrets.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Setting up
|
||||
@@ -23,15 +116,35 @@ 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:
|
||||
|
||||
bin/rails test
|
||||
```sh
|
||||
bin/rails test
|
||||
```
|
||||
|
||||
The full continuous integration tests can be run with:
|
||||
|
||||
bin/ci
|
||||
```sh
|
||||
bin/ci
|
||||
```
|
||||
|
||||
### Database configuration
|
||||
|
||||
@@ -50,37 +163,18 @@ 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:
|
||||
|
||||
bin/rails dev:email
|
||||
```sh
|
||||
bin/rails dev:email
|
||||
```
|
||||
|
||||
Under the hood, this will create or remove `tmp/email-dev.txt`.
|
||||
|
||||
## Deployment
|
||||
|
||||
We recommend [Kamal](https://kamal-deploy.org/) for deploying Fizzy. This project comes with a vanilla Rails template. You can find our production setup in [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).
|
||||
|
||||
### Web Push Notifications
|
||||
|
||||
Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. 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}"
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -88,4 +182,3 @@ We welcome contributions! Please read our [style guide](STYLE.md) before submitt
|
||||
## License
|
||||
|
||||
Fizzy is released under the [O'Saasy License](LICENSE.md).
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
--block-space-double: calc(var(--block-space) * 2);
|
||||
|
||||
/* Text */
|
||||
--font-sans: system-ui;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-serif: ui-serif, serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
|
||||
@@ -219,6 +219,27 @@
|
||||
--color-card-7: oklch(var(--lch-purple-medium));
|
||||
--color-card-8: oklch(var(--lch-pink-medium));
|
||||
|
||||
/* Colors: Highlighter */
|
||||
--highlight-1: rgb(136, 118, 38);
|
||||
--highlight-2: rgb(185, 94, 6);
|
||||
--highlight-3: rgb(207, 0, 0);
|
||||
--highlight-4: rgb(216, 28, 170);
|
||||
--highlight-5: rgb(144, 19, 254);
|
||||
--highlight-6: rgb(5, 98, 185);
|
||||
--highlight-7: rgb(17, 138, 15);
|
||||
--highlight-8: rgb(148, 82, 22);
|
||||
--highlight-9: rgb(102, 102, 102);
|
||||
|
||||
--highlight-bg-1: rgba(229, 223, 6, 0.3);
|
||||
--highlight-bg-2: rgba(255, 185, 87, 0.3);
|
||||
--highlight-bg-3: rgba(255, 118, 118, 0.3);
|
||||
--highlight-bg-4: rgba(248, 137, 216, 0.3);
|
||||
--highlight-bg-5: rgba(190, 165, 255, 0.3);
|
||||
--highlight-bg-6: rgba(124, 192, 252, 0.3);
|
||||
--highlight-bg-7: rgba(140, 255, 129, 0.3);
|
||||
--highlight-bg-8: rgba(221, 170, 123, 0.3);
|
||||
--highlight-bg-9: rgba(200, 200, 200, 0.3);
|
||||
|
||||
/* Colors: Syntax highlighting */
|
||||
--color-code-token__att: oklch(var(--lch-blue-dark));
|
||||
--color-code-token__comment: oklch(var(--lch-ink-medium));
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-up-fade-in {
|
||||
from { transform: translateY(2rem); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(2rem); }
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 799px) {
|
||||
--padding-inline: var(--main-padding);
|
||||
--padding-inline: 1.5ch;
|
||||
|
||||
column-gap: 0;
|
||||
grid-template-areas:
|
||||
@@ -150,6 +150,10 @@
|
||||
border-radius: 0.2em;
|
||||
grid-area: card;
|
||||
padding: clamp(2rem, 4vw, var(--padding-block));
|
||||
|
||||
@media (max-width: 799px) {
|
||||
padding-inline: var(--padding-inline);
|
||||
}
|
||||
}
|
||||
|
||||
.card-perma__actions {
|
||||
|
||||
@@ -1,61 +1,39 @@
|
||||
@layer components {
|
||||
/* Editor
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
lexxy-editor {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
figure.node--selected {
|
||||
&:not(:has(img)) {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
&:has(img) {
|
||||
img {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Lexical still uses the `lexxy-editor--empty` class if you have a list
|
||||
started with no other characters. Here, we won't show the placeholder if
|
||||
you've clicked the List button in the toolbar. */
|
||||
&.lexxy-editor--empty {
|
||||
.lexxy-editor__content:not(:has(ul, ol))::before {
|
||||
content: attr(placeholder);
|
||||
color: currentColor;
|
||||
cursor: text;
|
||||
opacity: 0.66;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
white-space: pre-line;
|
||||
&:not(:has(img)) {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-dialog-actions {
|
||||
display: flex;
|
||||
font-size: var(--text-x-small);
|
||||
flex: 1 1 0%;
|
||||
gap: var(--inline-space-half);
|
||||
margin-block-start: var(--block-space-half);
|
||||
|
||||
.btn {
|
||||
--radius: 0.3em;
|
||||
|
||||
inline-size: 100%;
|
||||
justify-content: center;
|
||||
|
||||
&:is([type="submit"]) {
|
||||
--btn-background: var(--card-color, var(--color-link));
|
||||
--btn-color: var(--color-ink-inverted);
|
||||
--focus-ring-color: var(--card-color, var(--color-link));
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
inline-size: 100%;
|
||||
/* Lexical uses the `lexxy-editor--empty` class even if you have a list
|
||||
* started but haven't added other characters. Here, we hide the placeholder
|
||||
* when you click the List button in the toolbar. */
|
||||
.lexxy-editor--empty {
|
||||
.lexxy-editor__content:not(:has(ul, ol))::before {
|
||||
content: attr(placeholder);
|
||||
color: currentColor;
|
||||
cursor: text;
|
||||
opacity: 0.66;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,14 +42,9 @@
|
||||
min-block-size: calc(7lh + var(--block-space));
|
||||
outline: 0;
|
||||
|
||||
* {
|
||||
&:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
/* Allow color highlights to show through a bit */
|
||||
::selection {
|
||||
background: oklch(var(--lch-blue-light) / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +54,40 @@
|
||||
outline: 2px dashed var(--color-selected-dark);
|
||||
}
|
||||
|
||||
.lexxy-code-language-picker {
|
||||
-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-position: center right 0.9em;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.5em;
|
||||
border: 1px solid var(--color-ink-light);
|
||||
border-radius: 99rem;
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-base);
|
||||
font-size: var(--text-x-small);
|
||||
font-weight: 500;
|
||||
inset-inline-end: 0;
|
||||
line-height: 1.15lh;
|
||||
margin: 0.75ch 0.75ch 0 0;
|
||||
padding-inline: 1.5ch 1.8em;
|
||||
text-align: start;
|
||||
|
||||
@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");
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
lexxy-toolbar {
|
||||
--lexxy-toolbar-icon-size: 1em;
|
||||
|
||||
@@ -96,21 +103,6 @@
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
z-index: 1;
|
||||
|
||||
dialog {
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-ink);
|
||||
padding: var(--block-space) calc(var(--inline-space) * 1.5);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
.input[type="url"] {
|
||||
min-inline-size: 30ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-button {
|
||||
@@ -157,48 +149,130 @@
|
||||
border-radius: 0.5ch;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
inset-inline-end: 0;
|
||||
padding: 4px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:where(.lexxy-editor__toolbar-spacer) {
|
||||
.lexxy-editor__toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Based on .input, .input--select */
|
||||
.lexxy-code-language-picker {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
/* Dropdowns
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-content {
|
||||
--lexxy-dropdown-padding: 0.75rem;
|
||||
--lexxy-dropdown-btn-size: 2rem;
|
||||
|
||||
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-position: center right 0.9em;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.5em;
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.3em;
|
||||
border-radius: 0.5em;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-base);
|
||||
font-size: var(--text-x-small);
|
||||
font-weight: 500;
|
||||
inset-inline-end: 0;
|
||||
line-height: inherit;
|
||||
margin: 0.3em 0.3em 0 0;
|
||||
padding: 0.5em 1.8em 0.5em 1.2em;
|
||||
text-align: start;
|
||||
font-size: var(--text-small);
|
||||
padding: var(--lexxy-dropdown-padding);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
@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");
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
button {
|
||||
block-size: var(--lexxy-dropdown-btn-size);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt
|
||||
.lexxy-editor__toolbar-dropdown-actions {
|
||||
display: flex;
|
||||
font-size: var(--text-x-small);
|
||||
flex: 1 1 0%;
|
||||
gap: var(--lexxy-dropdown-padding);
|
||||
margin-block-start: var(--lexxy-dropdown-padding);
|
||||
|
||||
.btn {
|
||||
--radius: 99rem;
|
||||
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.btn[type="submit"] {
|
||||
--btn-background: var(--card-color, var(--color-link));
|
||||
--btn-color: var(--color-ink-inverted);
|
||||
--focus-ring-color: var(--card-color, var(--color-link));
|
||||
}
|
||||
|
||||
span {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
lexxy-link-dropdown {
|
||||
.input {
|
||||
min-inline-size: 30ch;
|
||||
}
|
||||
}
|
||||
|
||||
lexxy-highlight-dropdown {
|
||||
--gap: 0.5ch;
|
||||
|
||||
[data-button-group] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap);
|
||||
|
||||
+ & {
|
||||
margin-block-start: var(--gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-highlight-button {
|
||||
--outline-color: oklch(var(--lch-ink-darkest) / 0.2);
|
||||
|
||||
appearance: none;
|
||||
background: var(--color-canvas);
|
||||
border: none;
|
||||
border-radius: 0.5ch;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
font-weight: 500;
|
||||
inline-size: var(--lexxy-dropdown-btn-size);
|
||||
place-content: center;
|
||||
position: relative;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "Aa";
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[aria-pressed="true"] {
|
||||
box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color);
|
||||
}
|
||||
|
||||
&[aria-pressed="true"] {
|
||||
--outline-color: var(--color-link);
|
||||
|
||||
&:after {
|
||||
content: "✓";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-reset {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-light);
|
||||
border-radius: 99rem;
|
||||
inline-size: 100%;
|
||||
margin-block-start: var(--lexxy-dropdown-padding);
|
||||
|
||||
&[disabled] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt ,enu (@mentions, etc.)
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.lexxy-prompt-menu {
|
||||
|
||||
@@ -28,6 +28,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
:where(ol, ul) {
|
||||
padding-inline-start: 3ch;
|
||||
}
|
||||
|
||||
:where(li:has(li)) {
|
||||
list-style: none;
|
||||
|
||||
:where(ol, ul) {
|
||||
margin: 0;
|
||||
padding-inline-start: 2ch;
|
||||
}
|
||||
}
|
||||
|
||||
:where(b, strong, .lexxy-content__bold) {
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -40,6 +53,10 @@
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
:where(mark, .lexxy-content__highlight) {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:where(p, blockquote) {
|
||||
letter-spacing: -0.005ch;
|
||||
@@ -201,14 +218,17 @@
|
||||
color: color-mix(in oklch, var(--color-ink) 66%, transparent);
|
||||
font-size: var(--text-small);
|
||||
|
||||
.input {
|
||||
textarea {
|
||||
--input-border-radius: 0.3em;
|
||||
--input-border-size: 0;
|
||||
--input-padding: 0;
|
||||
|
||||
background-color: var(--input-background, transparent);
|
||||
border: none;
|
||||
color: inherit;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
resize: none;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
@@ -274,7 +294,8 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1ch;
|
||||
inline-size: auto;
|
||||
inline-size: 100%;
|
||||
margin-inline: 0;
|
||||
|
||||
.attachment__caption {
|
||||
display: grid;
|
||||
|
||||
@@ -2,12 +2,12 @@ class Cards::ClosuresController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
def create
|
||||
@card.close(user: Current.user)
|
||||
@card.close
|
||||
render_card_replacement
|
||||
end
|
||||
|
||||
def destroy
|
||||
@card.reopen(user: Current.user)
|
||||
@card.reopen
|
||||
render_card_replacement
|
||||
end
|
||||
end
|
||||
|
||||
@@ -97,6 +97,12 @@ module Authentication
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_to_session_magic_link(magic_link, return_to: nil)
|
||||
serve_development_magic_link(magic_link)
|
||||
session[:return_to_after_authenticating] = return_to if return_to
|
||||
redirect_to session_magic_link_url(script_name: nil)
|
||||
end
|
||||
|
||||
def serve_development_magic_link(magic_link)
|
||||
if Rails.env.development?
|
||||
flash[:magic_link_code] = magic_link&.code
|
||||
|
||||
@@ -3,6 +3,7 @@ class JoinCodesController < ApplicationController
|
||||
|
||||
before_action :set_join_code
|
||||
before_action :ensure_join_code_is_valid
|
||||
before_action :set_identity, only: :create
|
||||
|
||||
layout "public"
|
||||
|
||||
@@ -10,22 +11,35 @@ class JoinCodesController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
identity = Identity.find_or_create_by!(email_address: params.expect(:email_address))
|
||||
@join_code.redeem_if { |account| @identity.join(account) }
|
||||
user = User.active.find_by!(account: @join_code.account, identity: @identity)
|
||||
|
||||
@join_code.redeem_if { |account| identity.join(account) }
|
||||
user = User.active.find_by!(account: @join_code.account, identity: identity)
|
||||
|
||||
if identity == Current.identity && user.setup?
|
||||
if @identity == Current.identity && user.setup?
|
||||
redirect_to landing_url(script_name: @join_code.account.slug)
|
||||
elsif identity == Current.identity
|
||||
redirect_to new_users_join_url(script_name: @join_code.account.slug)
|
||||
elsif @identity == Current.identity
|
||||
redirect_to new_users_verification_url(script_name: @join_code.account.slug)
|
||||
else
|
||||
logout_and_send_new_magic_link(identity)
|
||||
redirect_to session_magic_link_url(script_name: nil)
|
||||
terminate_session if Current.identity
|
||||
|
||||
redirect_to_session_magic_link \
|
||||
@identity.send_magic_link,
|
||||
return_to: new_users_verification_url(script_name: @join_code.account.slug)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_identity
|
||||
@identity = Identity.find_or_initialize_by(email_address: params.expect(:email_address))
|
||||
|
||||
if @identity.new_record?
|
||||
if @identity.invalid?
|
||||
head :unprocessable_entity
|
||||
else
|
||||
@identity.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def set_join_code
|
||||
@join_code ||= Account::JoinCode.find_by(code: params.expect(:code), account: Current.account)
|
||||
end
|
||||
@@ -37,13 +51,4 @@ class JoinCodesController < ApplicationController
|
||||
render :inactive, status: :gone
|
||||
end
|
||||
end
|
||||
|
||||
def logout_and_send_new_magic_link(identity)
|
||||
terminate_session if Current.identity
|
||||
|
||||
magic_link = identity.send_magic_link
|
||||
serve_development_magic_link(magic_link)
|
||||
|
||||
session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,17 +9,16 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
identity = Identity.find_by_email_address(email_address)
|
||||
|
||||
magic_link = if identity
|
||||
identity.send_magic_link
|
||||
if identity = Identity.find_by_email_address(email_address)
|
||||
redirect_to_session_magic_link identity.send_magic_link
|
||||
else
|
||||
Signup.new(email_address: email_address).create_identity
|
||||
signup = Signup.new(email_address: email_address)
|
||||
if signup.valid?(:identity_creation)
|
||||
redirect_to_session_magic_link signup.create_identity
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
serve_development_magic_link(magic_link)
|
||||
|
||||
redirect_to session_magic_link_path
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -11,8 +11,12 @@ class SignupsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
Signup.new(signup_params).create_identity
|
||||
redirect_to session_magic_link_path
|
||||
signup = Signup.new(signup_params)
|
||||
if signup.valid?(:identity_creation)
|
||||
redirect_to_session_magic_link signup.create_identity
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Users::VerificationsController < ApplicationController
|
||||
layout "public"
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
Current.user.verify
|
||||
redirect_to new_users_join_path
|
||||
end
|
||||
end
|
||||
@@ -9,8 +9,11 @@ class UsersController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
@user.update! user_params
|
||||
redirect_to @user
|
||||
if @user.update(user_params)
|
||||
redirect_to @user
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -12,7 +12,7 @@ module AccessesHelper
|
||||
render partial: "boards/access_toggle",
|
||||
collection: users, as: :user,
|
||||
locals: { selected: selected, disabled: disabled },
|
||||
cached: ->(user) { [ user, selected ] }
|
||||
cached: ->(user) { [ user, selected, disabled ] }
|
||||
end
|
||||
|
||||
def access_involvement_advance_button(board, user, show_watchers: true, icon_only: false)
|
||||
@@ -40,13 +40,15 @@ module AccessesHelper
|
||||
end
|
||||
|
||||
def involvement_button(board, access, show_watchers, icon_only)
|
||||
label_text = access.access_only? ? "Watch this" : "Stop watching"
|
||||
button_to(
|
||||
board_involvement_path(board), method: :put,
|
||||
params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only },
|
||||
aria: { labelledby: dom_id(board, :involvement_label) }, title: access.access_only? ? "Watch this" : "Stop watching",
|
||||
aria: { labelledby: dom_id(board, :involvement_label) },
|
||||
title: (label_text if icon_only),
|
||||
class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do
|
||||
icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") +
|
||||
tag.span(class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label))
|
||||
tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -18,14 +18,6 @@ module CardsHelper
|
||||
&block
|
||||
end
|
||||
|
||||
def button_to_delete_card(card)
|
||||
button_to card_path(card),
|
||||
method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_frame: "_top", turbo_confirm: "Are you sure you want to permanently delete this card?" } do
|
||||
concat(icon_tag("trash"))
|
||||
concat(tag.span("Delete this card"))
|
||||
end
|
||||
end
|
||||
|
||||
def card_title_tag(card)
|
||||
title = [
|
||||
card.title,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { fallbackDestination: String }
|
||||
|
||||
connect() {
|
||||
if (history.state.turbo?.restorationIndex > 0) {
|
||||
this.element.href = "javascript:history.back()"
|
||||
} else {
|
||||
this.element.href = this.fallbackDestinationValue
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,21 @@ export default class extends Controller {
|
||||
debounceTimeout: { type: Number, default: 300 }
|
||||
}
|
||||
|
||||
#isComposing = false
|
||||
|
||||
initialize() {
|
||||
this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), this.debounceTimeoutValue)
|
||||
}
|
||||
|
||||
// IME Composition tracking
|
||||
compositionStart() {
|
||||
this.#isComposing = true
|
||||
}
|
||||
|
||||
compositionEnd() {
|
||||
this.#isComposing = false
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.element.requestSubmit()
|
||||
}
|
||||
@@ -32,6 +43,12 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
preventComposingSubmit(event) {
|
||||
if (this.#isComposing) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
debouncedSubmit(event) {
|
||||
this.submit(event)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,6 @@ import { Controller } from "@hotwired/stimulus"
|
||||
export default class extends Controller {
|
||||
static targets = [ "caption", "image", "dialog", "zoomedImage" ]
|
||||
|
||||
connect() {
|
||||
this.dialogTarget.addEventListener('transitionend', this.handleTransitionEnd.bind(this))
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.dialogTarget.removeEventListener('transitionend', this.handleTransitionEnd.bind(this))
|
||||
}
|
||||
|
||||
open(event) {
|
||||
this.dialogTarget.showModal()
|
||||
this.#set(event.target.closest("a"))
|
||||
|
||||
@@ -4,12 +4,18 @@ import { onNextEventLoopTick } from "helpers/timing_helpers"
|
||||
export default class extends Controller {
|
||||
static targets = [ "input" ]
|
||||
|
||||
submitOnEnter(event) {
|
||||
event.preventDefault()
|
||||
this.submit()
|
||||
}
|
||||
|
||||
submitOnPaste() {
|
||||
onNextEventLoopTick(() => this.submit())
|
||||
}
|
||||
|
||||
submit() {
|
||||
onNextEventLoopTick(() => {
|
||||
if (!this.inputTarget.disabled) {
|
||||
this.element.submit()
|
||||
this.inputTarget.disabled = true
|
||||
}
|
||||
})
|
||||
if (this.inputTarget.disabled) return
|
||||
this.element.submit()
|
||||
this.inputTarget.disabled = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +245,9 @@ export default class extends Controller {
|
||||
}
|
||||
},
|
||||
Enter(event) {
|
||||
// Skip handling during IME composition (e.g., Japanese input)
|
||||
if (event.isComposing) { return }
|
||||
|
||||
if (event.shiftKey) {
|
||||
this.#toggleCurrentItem(event)
|
||||
} else {
|
||||
|
||||
@@ -21,7 +21,7 @@ class Account < ApplicationRecord
|
||||
def create_with_owner(account:, owner:)
|
||||
create!(**account).tap do |account|
|
||||
account.users.create!(role: :system, name: "System")
|
||||
account.users.create!(**owner.reverse_merge(role: "owner"))
|
||||
account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
module Attachments
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# The variants we use must all declared here so that we can preprocess them.
|
||||
# Variants used by ActionText embeds. Processed immediately on attachment to avoid
|
||||
# read replica issues (lazy variants would attempt writes on read replicas).
|
||||
#
|
||||
# If they are not preprocessed, then Rails will attempt to transform the image on-the-fly when
|
||||
# they are first viewed, which may be on the read replica where writing to the database is not
|
||||
# allowed. Chaos will ensue if that happens.
|
||||
#
|
||||
# These variants are patched into ActionText::RichText in config/initializers/action_text.rb
|
||||
# Patched into ActionText::RichText in config/initializers/action_text.rb
|
||||
VARIANTS = {
|
||||
# vipsthumbnail used to create sized image variants has a intent setting to manage colors during
|
||||
# resize. By setting an invalid intent value the gif-incompatible intent filtering is skipped and
|
||||
|
||||
@@ -8,7 +8,7 @@ class Identity < ApplicationRecord
|
||||
|
||||
has_one_attached :avatar
|
||||
|
||||
before_destroy :deactivate_users
|
||||
before_destroy :deactivate_users, prepend: true
|
||||
|
||||
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
|
||||
|
||||
@@ -1,14 +1,54 @@
|
||||
class Push::Subscription < ApplicationRecord
|
||||
PERMITTED_ENDPOINT_HOSTS = %w[
|
||||
fcm.googleapis.com
|
||||
updates.push.services.mozilla.com
|
||||
web.push.apple.com
|
||||
notify.windows.com
|
||||
].freeze
|
||||
|
||||
belongs_to :account, default: -> { user.account }
|
||||
belongs_to :user
|
||||
|
||||
validates :endpoint, presence: true
|
||||
validate :validate_endpoint_url
|
||||
|
||||
def notification(**params)
|
||||
WebPush::Notification.new(
|
||||
**params,
|
||||
badge: user.notifications.unread.count,
|
||||
endpoint: endpoint,
|
||||
endpoint_ip: resolved_endpoint_ip,
|
||||
p256dh_key: p256dh_key,
|
||||
auth_key: auth_key
|
||||
)
|
||||
end
|
||||
|
||||
def resolved_endpoint_ip
|
||||
return @resolved_endpoint_ip if defined?(@resolved_endpoint_ip)
|
||||
@resolved_endpoint_ip = SsrfProtection.resolve_public_ip(endpoint_uri&.host)
|
||||
end
|
||||
|
||||
private
|
||||
def endpoint_uri
|
||||
@endpoint_uri ||= URI.parse(endpoint) if endpoint.present?
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_endpoint_url
|
||||
if endpoint_uri.nil?
|
||||
errors.add(:endpoint, "is not a valid URL")
|
||||
elsif endpoint_uri.scheme != "https"
|
||||
errors.add(:endpoint, "must use HTTPS")
|
||||
elsif !permitted_endpoint_host?
|
||||
errors.add(:endpoint, "is not a permitted push service")
|
||||
elsif resolved_endpoint_ip.nil?
|
||||
errors.add(:endpoint, "resolves to a private or invalid IP address")
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_endpoint_host?
|
||||
host = endpoint_uri&.host&.downcase
|
||||
PERMITTED_ENDPOINT_HOSTS.any? { |permitted| host&.end_with?(permitted) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,9 +6,8 @@ class Signup
|
||||
attr_accessor :full_name, :email_address, :identity
|
||||
attr_reader :account, :user
|
||||
|
||||
with_options on: :completion do
|
||||
validates_presence_of :full_name, :identity
|
||||
end
|
||||
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation
|
||||
validates :full_name, :identity, presence: true, on: :completion
|
||||
|
||||
def initialize(...)
|
||||
super
|
||||
|
||||
@@ -3,14 +3,21 @@ module SsrfProtection
|
||||
|
||||
DNS_RESOLUTION_TIMEOUT = 2
|
||||
|
||||
DNS_NAMESERVERS = %w[
|
||||
1.1.1.1
|
||||
8.8.8.8
|
||||
]
|
||||
|
||||
DISALLOWED_IP_RANGES = [
|
||||
IPAddr.new("0.0.0.0/8") # Broadcasts
|
||||
IPAddr.new("0.0.0.0/8"), # "This" network (RFC1700)
|
||||
IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598)
|
||||
IPAddr.new("198.18.0.0/15") # Benchmark testing (RFC2544)
|
||||
].freeze
|
||||
|
||||
def resolve_public_ip(hostname)
|
||||
ip_addresses = resolve_dns(hostname)
|
||||
public_ips = ip_addresses.reject { |ip| private_address?(ip) }
|
||||
public_ips.first&.to_s
|
||||
public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s
|
||||
end
|
||||
|
||||
def private_address?(ip)
|
||||
@@ -22,7 +29,7 @@ module SsrfProtection
|
||||
def resolve_dns(hostname)
|
||||
ip_addresses = []
|
||||
|
||||
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
|
||||
Resolv::DNS.open(nameserver: DNS_NAMESERVERS, timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
|
||||
dns.each_address(hostname) do |ip_address|
|
||||
ip_addresses << IPAddr.new(ip_address.to_s)
|
||||
end
|
||||
|
||||
@@ -28,6 +28,14 @@ class User < ApplicationRecord
|
||||
name != identity.email_address
|
||||
end
|
||||
|
||||
def verified?
|
||||
verified_at.present?
|
||||
end
|
||||
|
||||
def verify
|
||||
update!(verified_at: Time.current) unless verified?
|
||||
end
|
||||
|
||||
private
|
||||
def close_remote_connections
|
||||
ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false)
|
||||
|
||||
@@ -5,7 +5,7 @@ module User::Avatar
|
||||
|
||||
included do
|
||||
has_one_attached :avatar do |attachable|
|
||||
attachable.variant :thumb, resize_to_fill: [ 256, 256 ]
|
||||
attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately
|
||||
end
|
||||
|
||||
validate :avatar_content_type_allowed
|
||||
|
||||
@@ -30,15 +30,15 @@ class User::DayTimeline
|
||||
end
|
||||
|
||||
def added_column
|
||||
@added_column ||= build_column("Added", 1, events.where(action: %w[card_published card_reopened]))
|
||||
@added_column ||= build_column(:added, "Added", 1, events.where(action: %w[card_published card_reopened]))
|
||||
end
|
||||
|
||||
def updated_column
|
||||
@updated_column ||= build_column("Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened]))
|
||||
@updated_column ||= build_column(:updated, "Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened]))
|
||||
end
|
||||
|
||||
def closed_column
|
||||
@closed_column ||= build_column("Done", 3, events.where(action: "card_closed"))
|
||||
@closed_column ||= build_column(:closed, "Done", 3, events.where(action: "card_closed"))
|
||||
end
|
||||
|
||||
def cache_key
|
||||
@@ -84,8 +84,8 @@ class User::DayTimeline
|
||||
filtered_events.where(created_at: ...day.beginning_of_day).chronologically.last
|
||||
end
|
||||
|
||||
def build_column(base_title, index, events)
|
||||
Column.new(self, base_title, index, events)
|
||||
def build_column(id, base_title, index, events)
|
||||
Column.new(self, id, base_title, index, events)
|
||||
end
|
||||
|
||||
def window
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
class User::DayTimeline::Column
|
||||
include ActionView::Helpers::TagHelper, ActionView::Helpers::OutputSafetyHelper, TimeHelper
|
||||
|
||||
attr_reader :index, :base_title, :day_timeline, :events
|
||||
attr_reader :index, :id, :base_title, :day_timeline, :events
|
||||
|
||||
def initialize(day_timeline, base_title, index, events)
|
||||
def initialize(day_timeline, id, base_title, index, events)
|
||||
@id = id
|
||||
@day_timeline = day_timeline
|
||||
@base_title = base_title
|
||||
@index = index
|
||||
@@ -17,15 +18,6 @@ class User::DayTimeline::Column
|
||||
safe_join(parts, " ")
|
||||
end
|
||||
|
||||
def base_title_for_route
|
||||
case base_title
|
||||
when "Done"
|
||||
"closed"
|
||||
else
|
||||
base_title.downcase
|
||||
end
|
||||
end
|
||||
|
||||
def events_by_hour
|
||||
limited_events.group_by { it.created_at.hour }
|
||||
end
|
||||
@@ -38,6 +30,10 @@ class User::DayTimeline::Column
|
||||
full_events_count - limited_events.count
|
||||
end
|
||||
|
||||
def to_param
|
||||
id
|
||||
end
|
||||
|
||||
private
|
||||
def limited_events
|
||||
@limited_events ||= events.limit(100).load
|
||||
|
||||
@@ -21,7 +21,7 @@ class User::Settings < ApplicationRecord
|
||||
end
|
||||
|
||||
def bundling_emails?
|
||||
!bundle_email_never? && !user.system? && user.active?
|
||||
!bundle_email_never? && !user.system? && user.active? && user.verified?
|
||||
end
|
||||
|
||||
def timezone
|
||||
|
||||
@@ -78,7 +78,8 @@ class Webhook::Delivery < ApplicationRecord
|
||||
end
|
||||
|
||||
def http
|
||||
Net::HTTP.new(uri.host, uri.port, ipaddr: resolved_ip).tap do |http|
|
||||
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
||||
http.ipaddr = resolved_ip
|
||||
http.use_ssl = (uri.scheme == "https")
|
||||
http.open_timeout = ENDPOINT_TIMEOUT
|
||||
http.read_timeout = ENDPOINT_TIMEOUT
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<%= form_with model: board, class: "txt-align-center margin-block-start-auto", method: :delete do |form| %>
|
||||
<%= form.button class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to permanently delete this board and all the cards on it? This can't be undone." } do %>
|
||||
<div class="txt-align-center margin-block-start-auto" data-controller="dialog" data-dialog-modal-value="true" data-action="keydown.esc->dialog#close:stop">
|
||||
<button type="button" class="btn txt-negative borderless txt-small" data-action="dialog#open">
|
||||
<%= icon_tag "trash" %>
|
||||
<span>Delete this board</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
</button>
|
||||
<dialog class="dialog panel fill-white shadow gap flex-column" style="max-width: 40ch" data-dialog-target="dialog">
|
||||
<h3 class="txt-large txt-bold">Delete this board?</h3>
|
||||
<p class="txt-medium margin-block-half">Are you sure you want to permanently delete this board and all the cards on it? This can't be undone.</p>
|
||||
<div class="flex gap-half justify-center margin-block-start">
|
||||
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
|
||||
<%= button_to board_path(board), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %>
|
||||
<span>Delete board</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<% disabled = !Current.user.can_administer_board?(board) %>
|
||||
<header>
|
||||
<h2 class="divider txt-medium margin-block-start">Who can access this board?</h2>
|
||||
</header>
|
||||
@@ -16,7 +17,7 @@
|
||||
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-ink-medium); --border-style: dashed" aria-hidden="true" />
|
||||
|
||||
<label for="board_all_access" class="switch">
|
||||
<%= form.check_box :all_access, class: "switch__input", checked: board.all_access?, disabled: !Current.user.can_administer_board?(board), data: { action: "change->toggle-class#toggle" } %>
|
||||
<%= form.check_box :all_access, class: "switch__input", checked: board.all_access?, disabled:, data: { action: "change->toggle-class#toggle" } %>
|
||||
<span class="switch__btn round"></span>
|
||||
<span class="for-screen-reader">Give everyone access to this board</span>
|
||||
</label>
|
||||
@@ -26,14 +27,14 @@
|
||||
|
||||
<div class="toggler__visible-when-off">
|
||||
<div class="flex align-center justify-end gap-half">
|
||||
<button type="button" class="btn btn--plain txt-x-small txt-link font-weight-normal" data-action="click->toggle-class#checkAll">Select all</button>
|
||||
<%= button_tag "Select all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkAll" }, disabled: %>
|
||||
<span class="txt-subtle">·</span>
|
||||
<button type="button" class="btn btn--plain txt-x-small txt-link font-weight-normal" data-action="click->toggle-class#checkNone">Select none</button>
|
||||
<%= button_tag "Select none", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkNone" }, disabled: %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="settings__user-list" data-filter-target="list">
|
||||
<%= access_toggles_for selected_users, selected: true, disabled: !Current.user.can_administer_board?(board) %>
|
||||
<%= access_toggles_for unselected_users, selected: false, disabled: !Current.user.can_administer_board?(board) %>
|
||||
<%= access_toggles_for selected_users, selected: true, disabled: %>
|
||||
<%= access_toggles_for unselected_users, selected: false, disabled: %>
|
||||
</ul>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<div data-controller="dialog" data-dialog-modal-value="true" data-action="keydown.esc->dialog#close:stop">
|
||||
<button type="button" class="btn txt-negative borderless txt-small" data-action="dialog#open">
|
||||
<%= icon_tag "trash" %>
|
||||
<span>Delete this card</span>
|
||||
</button>
|
||||
<dialog class="dialog panel fill-white shadow gap flex-column" style="max-width: 40ch" data-dialog-target="dialog">
|
||||
<h3 class="txt-large txt-bold">Delete this card?</h3>
|
||||
<p class="txt-medium margin-block-half">Are you sure you want to permanently delete this card?</p>
|
||||
<div class="flex gap-half justify-center margin-block-start">
|
||||
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
|
||||
<%= button_to card_path(card), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %>
|
||||
<span>Delete card</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -9,7 +9,7 @@
|
||||
<% if Current.user.can_administer_card?(card) %>
|
||||
<footer class="delete-card txt-align-center full-width flex flex-column margin-block-start-double">
|
||||
<hr class="separator--horizontal full-width margin-block" style="--border-color: var(--color-ink-lighter)">
|
||||
<%= button_to_delete_card(card) %>
|
||||
<%= render "cards/delete", card: card %>
|
||||
</footer>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<%= form_with model: [ @comment.card, @comment, Reaction.new ],
|
||||
class: "reaction reaction__form expanded",
|
||||
html: { aria: { label: "New reaction" } },
|
||||
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit" } do |form| %>
|
||||
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
|
||||
<label class="reaction__form-label flex gap" style="--column-gap: 0.4ch;">
|
||||
<figure class="reaction__avatar margin-none flex-item-no-shrink">
|
||||
<%= avatar_tag Current.user %>
|
||||
</figure>
|
||||
|
||||
<%= form.text_field :content, autofocus: true, autocomplete: "off", autocorrect: "off", maxlength: 16,
|
||||
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input" }, aria: { label: "Add a reaction" } %>
|
||||
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a reaction" } %>
|
||||
</label>
|
||||
|
||||
<%= render "cards/comments/reactions/menu", comment: @comment %>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<% unless card.closed? %>
|
||||
<li id="<%= dom_id(card, :new_step) %>" class="step">
|
||||
<input type="checkbox" class="step__checkbox" disabled>
|
||||
<%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit turbo:submit-end->form#reset" } do |form| %>
|
||||
<%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true" }, aria: { label: "Add a step" } %>
|
||||
<%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit turbo:submit-end->form#reset" } do |form| %>
|
||||
<%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a step" } %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
|
||||
<%= form_with url: card_taggings_path(@card),
|
||||
id: dom_id(@card, :tags_form),
|
||||
data: { controller: "form", action: "submit->form#preventEmptySubmit" },
|
||||
data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit" },
|
||||
class: "flex flex-column gap-half full-width margin-block-half" do |form| %>
|
||||
<%= form.text_field :tag_title, placeholder: @tags.any? ? "Add a new tag or filter…" : "Name this tag…", class: "input txt-small full-width",
|
||||
autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter" } %>
|
||||
autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter compositionstart->form#compositionStart compositionend->form#compositionEnd" } %>
|
||||
<% end %>
|
||||
|
||||
<ul class="popup__list" data-filter-target="list">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<%= column.title %>
|
||||
|
||||
<% if column.events_by_hour.any? %>
|
||||
<%= link_to events_day_timeline_column_path(id: column.base_title_for_route, day: column.day_timeline.day.to_date), class: "events__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
|
||||
<%= link_to events_day_timeline_column_path(column, day: column.day_timeline.day.to_date), class: "events__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
|
||||
<%= icon_tag "grid", class: "translucent" %>
|
||||
<span class="for-screen-reader">Expand column</span>
|
||||
<% end %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= back_link_to "Actvity", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||
<%= back_link_to "Activity", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||
</div>
|
||||
|
||||
<h1 class="header__title">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= tag.dialog class:"lightbox", aria: { label: "Image Viewer (Press escape to close)" },
|
||||
data: { controller: "dialog", dialog_target: "dialog", dialog_modal_value: "true", lightbox_target: "dialog", action: "keydown.esc->dialog#close:stop" } do %>
|
||||
data: { controller: "dialog", dialog_target: "dialog", dialog_modal_value: "true", lightbox_target: "dialog", action: "keydown.esc->dialog#close:stop transitionend->lightbox#handleTransitionEnd" } do %>
|
||||
|
||||
<figure class="lightbox__figure">
|
||||
<img src="" class="lightbox__image" data-lightbox-target="zoomedImage" />
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="overflow-ellipsis translucent txt-ink">
|
||||
<%= result.card.board.name %>
|
||||
<%= result.card.board.name %> · <%= local_datetime_tag(result.created_at, style: :timeordate) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
@@ -10,16 +10,18 @@
|
||||
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large",
|
||||
autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true,
|
||||
autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code],
|
||||
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submit paste->magic-link#submit" } %>
|
||||
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>
|
||||
</div>
|
||||
|
||||
<% if Rails.env.development? %>
|
||||
<script>
|
||||
console.log("Magic link code: <%= flash[:magic_link_code].presence || "not generated" %>");
|
||||
</script>
|
||||
<% if Rails.env.development? && flash[:magic_link_code].present? %>
|
||||
<div class="flex align-center justify-center gap fill-shade border-radius pad margin-block-start full-width position-relative" style="animation: slide-up-fade-in 400ms ease-out both; max-width: 42ch; overflow: hidden;">
|
||||
<span class="txt-uppercase translucent txt-tight-lines">Psst, here's your code:</span>
|
||||
<code class="txt-large font-weight-black txt-tight-lines"><%= flash[:magic_link_code] %></code>
|
||||
<span class="txt-xx-small txt-uppercase font-weight-black txt-align-center flex align-center justify-center" style="position: absolute; top: 0; bottom: 0; left: 0; background: var(--color-ink-lighter); padding: 0 0.5em; writing-mode: vertical-rl; transform: rotate(180deg); line-height: 1;">DEV</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% content_for :footer do %>
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
<p>You're one step away. Just enter your name to get your own Fizzy account.</p>
|
||||
|
||||
<% if @signup.errors.any? %>
|
||||
<div class="margin-block-half">
|
||||
<ul class="margin-block-none txt-negative txt-small">
|
||||
<div class="margin-block-half txt-negative txt-small" style="text-align: left;">
|
||||
<p class="margin-block-none font-weight-bold">Your changes couldn't be saved:</p>
|
||||
<ul class="margin-block-none">
|
||||
<% @signup.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<label class="avatar btn btn--circle input--file txt-xx-large center fill-white">
|
||||
<%= image_tag user_avatar_path(@user), aria: { hidden: "true" }, class: "avatar", size: 128, data: { upload_preview_target: "image" } %>
|
||||
<%= form.file_field :avatar, id: "file", class: "input", accept: "image/jpeg, image/png, image/gif, image/webp",
|
||||
<%= form.file_field :avatar, id: "file", class: "input", accept: User::Avatar::ALLOWED_AVATAR_CONTENT_TYPES.join(","),
|
||||
data: { upload_preview_target: "input", action: "upload-preview#previewImage" } %>
|
||||
<span class="for-screen-reader">Profile avatar for <%= @user.name %></span>
|
||||
</label>
|
||||
@@ -38,6 +38,18 @@
|
||||
<%= link_to "Change email", new_user_email_address_path(user_id: Current.user.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @user.errors.any? %>
|
||||
<div class="margin-block-half txt-negative txt-small" style="text-align: left;">
|
||||
<p class="margin-block-none font-weight-bold">Your changes couldn't be saved:</p>
|
||||
<ul class="margin-block-none">
|
||||
<% @user.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<button type="submit" id="log_in" class="btn btn--reversed center" data-form-target="submit">
|
||||
<span>Save changes</span>
|
||||
</button>
|
||||
|
||||
@@ -18,20 +18,25 @@
|
||||
<div class="flex flex-column gap-half margin-block-end">
|
||||
<h1 class="txt-x-large margin-none"><%= @user.name %></h1>
|
||||
<div class="txt-medium">
|
||||
<% if @user.active? %>
|
||||
<%= mail_to @user.identity.email_address %>
|
||||
<% else %>
|
||||
<% if !@user.active? %>
|
||||
<%= @user.name %> is no longer on this account
|
||||
<% elsif !@user.verified? %>
|
||||
Unverified
|
||||
<div class="txt-small txt-tinted">A sign-in code has been sent to this email address, but the user has not yet logged in to confirm their identity.</div>
|
||||
<% else %>
|
||||
<%= mail_to @user.identity.email_address %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-inline center justify-center flex-wrap gap">
|
||||
<%= link_to "Which cards are assigned to #{me_or_you}?",
|
||||
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
|
||||
<%= link_to "Which cards were added by #{me_or_you}?",
|
||||
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
|
||||
</div>
|
||||
<% if @user.verified? %>
|
||||
<div class="flex-inline center justify-center flex-wrap gap">
|
||||
<%= link_to "Which cards are assigned to #{me_or_you}?",
|
||||
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
|
||||
<%= link_to "Which cards were added by #{me_or_you}?",
|
||||
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -48,4 +53,6 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag "user_events", src: user_events_path(@user) %>
|
||||
<% if @user.verified? %>
|
||||
<%= turbo_frame_tag "user_events", src: user_events_path(@user) %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<%= auto_submit_form_with url: users_verifications_path, method: :post %>
|
||||
@@ -22,7 +22,7 @@
|
||||
</header>
|
||||
<% unless @webhook.active? %>
|
||||
<div>
|
||||
<%= button_to "Reactivate", board_webhook_activation_path(@webhook), method: :post %>
|
||||
<%= button_to "Reactivate", board_webhook_activation_path(@webhook.board_id, @webhook), method: :post %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ CI.run do
|
||||
if Fizzy.saas?
|
||||
step "Tests: SaaS", "#{SAAS_ENV} bin/rails test"
|
||||
step "Tests: SaaS System", "#{SAAS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system"
|
||||
step "Tests: OSS", "#{OSS_ENV} bin/rails test"
|
||||
step "Tests: OSS System", "#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system"
|
||||
else
|
||||
step "Tests: SQLite", "#{OSS_ENV} bin/rails test"
|
||||
step "Tests: SQLite System", "#{OSS_ENV} #{SYSTEM_TEST_ENV} bin/rails test:system"
|
||||
|
||||
+27
-86
@@ -1,65 +1,45 @@
|
||||
# Name of your application. Used to uniquely configure containers.
|
||||
# Name of this app
|
||||
service: fizzy
|
||||
|
||||
# Name of the container image (use your-user/app-name on external registries).
|
||||
image: fizzy
|
||||
|
||||
# Deploy to these servers.
|
||||
|
||||
#-- About your deployment --#
|
||||
|
||||
# Where to deploy fizzy
|
||||
servers:
|
||||
web:
|
||||
- 192.168.0.1
|
||||
# job:
|
||||
# hosts:
|
||||
# - 192.168.0.1
|
||||
# cmd: bin/jobs
|
||||
- fizzy.example.com # Set your server name here
|
||||
|
||||
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
|
||||
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
|
||||
#
|
||||
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
|
||||
#
|
||||
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
|
||||
#
|
||||
# proxy:
|
||||
# ssl: true
|
||||
# host: app.example.com
|
||||
# How you connect to your server
|
||||
ssh:
|
||||
user: root # If you use a different username to SSH to your server, specify it here
|
||||
|
||||
# Where you keep your container images.
|
||||
registry:
|
||||
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
|
||||
server: localhost:5555
|
||||
# Automatic SSL
|
||||
proxy:
|
||||
ssl: true # Set this to false if you *don't* want SSL
|
||||
host: fizzy.example.com # Set your server name here to use automatic SSL
|
||||
|
||||
# Needed for authenticated registries.
|
||||
# username: your-user
|
||||
|
||||
# Always use an access token rather than real password when possible.
|
||||
# password:
|
||||
# - KAMAL_REGISTRY_PASSWORD
|
||||
|
||||
# Inject ENV variables into containers (secrets come from .kamal/secrets).
|
||||
# Your application configuration (secrets come from .kamal/secrets).
|
||||
env:
|
||||
secret:
|
||||
- RAILS_MASTER_KEY
|
||||
- SECRET_KEY_BASE
|
||||
- VAPID_PUBLIC_KEY
|
||||
- VAPID_PRIVATE_KEY
|
||||
- SMTP_USERNAME
|
||||
- SMTP_PASSWORD
|
||||
clear:
|
||||
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
|
||||
# When you start using multiple servers, you should split out job processing to a dedicated machine.
|
||||
SOLID_QUEUE_IN_PUMA: true
|
||||
MAILER_FROM_ADDRESS: support@example.com # The email address that Fizzy sends email from
|
||||
SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container
|
||||
|
||||
# Set number of processes dedicated to Solid Queue (default: 1)
|
||||
# JOB_CONCURRENCY: 3
|
||||
|
||||
# Set number of cores available to the application on each server (default: 1).
|
||||
# WEB_CONCURRENCY: 2
|
||||
#-- General configuration --#
|
||||
|
||||
# Match this to any external database server to configure Active Record correctly
|
||||
# Use fizzy-db for a db accessory server on same machine via local kamal docker network.
|
||||
# DB_HOST: 192.168.0.2
|
||||
# Use a local registry to deploy
|
||||
registry:
|
||||
server: localhost:5555
|
||||
|
||||
# Log everything from Rails
|
||||
# RAILS_LOG_LEVEL: debug
|
||||
|
||||
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
|
||||
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
|
||||
# Handy aliases for interacting with your deployment. For eaxmple: `bin/kamal console` will connect to a
|
||||
# Rails console in production.
|
||||
aliases:
|
||||
console: app exec --interactive --reuse "bin/rails console"
|
||||
shell: app exec --interactive --reuse "bash"
|
||||
@@ -76,45 +56,6 @@ volumes:
|
||||
# version inside the asset_path.
|
||||
asset_path: /rails/public/assets
|
||||
|
||||
|
||||
# Configure the image builder.
|
||||
builder:
|
||||
arch: amd64
|
||||
|
||||
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
|
||||
# remote: ssh://docker@docker-builder-server
|
||||
#
|
||||
# # Pass arguments and secrets to the Docker build process
|
||||
# args:
|
||||
# RUBY_VERSION: ruby-3.4.7
|
||||
# secrets:
|
||||
# - GITHUB_TOKEN
|
||||
# - RAILS_MASTER_KEY
|
||||
|
||||
# Use a different ssh user than root
|
||||
# ssh:
|
||||
# user: app
|
||||
|
||||
# Use accessory services (secrets come from .kamal/secrets).
|
||||
# accessories:
|
||||
# db:
|
||||
# image: mysql:8.0
|
||||
# host: 192.168.0.2
|
||||
# # Change to 3306 to expose port to the world instead of just local network.
|
||||
# port: "127.0.0.1:3306:3306"
|
||||
# env:
|
||||
# clear:
|
||||
# MYSQL_ROOT_HOST: '%'
|
||||
# secret:
|
||||
# - MYSQL_ROOT_PASSWORD
|
||||
# files:
|
||||
# - config/mysql/production.cnf:/etc/mysql/my.cnf
|
||||
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
|
||||
# directories:
|
||||
# - data:/var/lib/mysql
|
||||
# redis:
|
||||
# image: valkey/valkey:8
|
||||
# host: 192.168.0.2
|
||||
# port: 6379
|
||||
# directories:
|
||||
# - data:/data
|
||||
|
||||
@@ -3,6 +3,21 @@ require "active_support/core_ext/integer/time"
|
||||
Rails.application.configure do
|
||||
# Settings specified here will take precedence over those in config/application.rb.
|
||||
|
||||
# Email provider Settings
|
||||
#
|
||||
# Configure these according to whichever email provider you use. An example setup
|
||||
# using SMTP looks like the following:
|
||||
#
|
||||
# config.action_mailer.smtp_settings = {
|
||||
# address: 'smtp.example.com', # The address of your email provider's SMTP server
|
||||
# port: 2525,
|
||||
# domain: 'example.com', # Your domain, which Fizzy will send email from
|
||||
# user_name: ENV["SMTP_USERNAME"],
|
||||
# password: ENV["SMTP_PASSWORD"],
|
||||
# authentication: :plain,
|
||||
# enable_starttls_auto: true
|
||||
# }
|
||||
|
||||
# Code is not reloaded between requests.
|
||||
config.enable_reloading = false
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ module ActionText
|
||||
# This overrides the default :embeds association!
|
||||
has_many_attached :embeds do |attachable|
|
||||
::Attachments::VARIANTS.each do |variant_name, variant_options|
|
||||
attachable.variant variant_name, variant_options.merge(preprocessed: true)
|
||||
attachable.variant variant_name, **variant_options, process: :immediately
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,9 +4,17 @@ ActiveSupport.on_load(:active_storage_blob) do
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport.on_load(:active_storage_record) do
|
||||
configure_replica_connections
|
||||
end
|
||||
# Don't configure replica connections for ActiveStorage::Record.
|
||||
# When ActiveStorage uses `connects_to`, it creates a separate connection pool
|
||||
# from ApplicationRecord. This causes after_commit callbacks to fire in
|
||||
# non-deterministic order - the Attachment's create_variants callback can fire
|
||||
# before the User model's upload callback, causing FileNotFoundError when
|
||||
# using `process: :immediately` for variants.
|
||||
# See: https://github.com/rails/rails/issues/53694
|
||||
#
|
||||
# ActiveSupport.on_load(:active_storage_record) do
|
||||
# configure_replica_connections
|
||||
# end
|
||||
|
||||
module ActiveStorageControllerExtensions
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
Autotuner.enabled = true
|
||||
|
||||
# This callback is called whenever a suggestion is provided by this gem.
|
||||
# You can output this report to your logging pipeline, stdout, a file,
|
||||
# or somewhere else!
|
||||
Autotuner.reporter = proc do |report|
|
||||
# Log to structured logging for query/analysis in Loki. This is called
|
||||
# once per autotuner heuristic.
|
||||
Autotuner.reporter = proc do |heuristic_report|
|
||||
report = heuristic_report.to_s
|
||||
|
||||
Rails.logger.info "GCAUTOTUNE: #{report}"
|
||||
|
||||
if Fizzy.saas?
|
||||
Sentry.capture_message "Autotuner suggestion", level: :info, extra: { report: report.to_s }
|
||||
end
|
||||
RailsStructuredLogging.instrument_script "autotuner" do
|
||||
Rails.logger.info report.to_s
|
||||
end if defined? RailsStructuredLogging
|
||||
end
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
# Define an application-wide content security policy.
|
||||
# See the Securing Rails Applications Guide for more information:
|
||||
# https://guides.rubyonrails.org/security.html#content-security-policy-header
|
||||
# Define an application-wide Content Security Policy.
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
|
||||
|
||||
# Rails.application.configure do
|
||||
# config.content_security_policy do |policy|
|
||||
# policy.default_src :self, :https
|
||||
# policy.font_src :self, :https, :data
|
||||
# policy.img_src :self, :https, :data
|
||||
# policy.object_src :none
|
||||
# policy.script_src :self, :https
|
||||
# policy.style_src :self, :https
|
||||
# # Specify URI for violation reports
|
||||
# # policy.report_uri "/csp-violation-report-endpoint"
|
||||
# end
|
||||
#
|
||||
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
|
||||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
||||
# config.content_security_policy_nonce_directives = %w(script-src style-src)
|
||||
#
|
||||
# # Report violations without enforcing the policy.
|
||||
# # config.content_security_policy_report_only = true
|
||||
# end
|
||||
Rails.application.configure do
|
||||
# Configure with environment variables with fallback to config.x values (via fizzy-sass)
|
||||
report_uri = ENV.fetch("CSP_REPORT_URI") { config.x.content_security_policy.report_uri }
|
||||
report_only =
|
||||
if ENV.key?("CSP_REPORT_ONLY")
|
||||
ENV["CSP_REPORT_ONLY"] == "true"
|
||||
else
|
||||
config.x.content_security_policy.report_only
|
||||
end
|
||||
|
||||
# Generate nonces for importmap and inline scripts
|
||||
config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
|
||||
config.content_security_policy_nonce_directives = %w[ script-src ]
|
||||
|
||||
config.content_security_policy do |policy|
|
||||
policy.default_src :self
|
||||
policy.script_src :self, "https://challenges.cloudflare.com"
|
||||
policy.connect_src :self, "https://storage.basecamp.com"
|
||||
policy.frame_src :self, "https://challenges.cloudflare.com"
|
||||
|
||||
# Don't fight user tools: permit inline styles, data:/https: sources, and
|
||||
# blob: workers for accessibility extensions, privacy tools, and custom fonts.
|
||||
policy.style_src :self, :unsafe_inline
|
||||
policy.img_src :self, "blob:", "data:", "https:"
|
||||
policy.font_src :self, "data:", "https:"
|
||||
policy.media_src :self, "blob:", "data:", "https:"
|
||||
policy.worker_src :self, "blob:"
|
||||
|
||||
policy.object_src :none
|
||||
policy.base_uri :none
|
||||
policy.form_action :self
|
||||
policy.frame_ancestors :self
|
||||
|
||||
# Specify URI for violation reports (e.g., Sentry CSP endpoint)
|
||||
policy.report_uri report_uri if report_uri
|
||||
end
|
||||
|
||||
# Report violations without enforcing the policy.
|
||||
config.content_security_policy_report_only = report_only
|
||||
end unless ENV["DISABLE_CSP"]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Lazily add identity and account context to error reports.
|
||||
# Only evaluated when an error is actually reported.
|
||||
Rails.error.add_middleware ->(error, context:, **) do
|
||||
context.merge \
|
||||
identity_id: Current.identity&.id,
|
||||
account_id: Current.account&.external_account_id
|
||||
end
|
||||
@@ -17,7 +17,15 @@ end
|
||||
|
||||
module WebPush::PersistentRequest
|
||||
def perform
|
||||
if @options[:connection]
|
||||
endpoint_ip = @options[:endpoint_ip]
|
||||
|
||||
if endpoint_ip
|
||||
http = Net::HTTP.new(uri.host, uri.port, ipaddr: endpoint_ip)
|
||||
http.use_ssl = true
|
||||
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
|
||||
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
|
||||
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
|
||||
elsif @options[:connection]
|
||||
http = @options[:connection]
|
||||
else
|
||||
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ if !Rails.env.local?
|
||||
# worker per CPU, 1 thread per worker and tune it from there.
|
||||
#
|
||||
# https://edgeguides.rubyonrails.org/tuning_performance_for_deployment.html#puma
|
||||
workers Concurrent.physical_processor_count
|
||||
workers Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
|
||||
threads 1, 1
|
||||
|
||||
# Tell the Ruby VM that we're finished booting up.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ default: &default
|
||||
workers:
|
||||
- queues: [ "default", "solid_queue_recurring", "backend", "webhooks" ]
|
||||
threads: 3
|
||||
processes: <%= ENV.fetch("JOB_CONCURRENCY", Concurrent.physical_processor_count) %>
|
||||
processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %>
|
||||
polling_interval: 0.1
|
||||
|
||||
development: *default
|
||||
|
||||
@@ -138,6 +138,7 @@ Rails.application.routes.draw do
|
||||
|
||||
namespace :users do
|
||||
resources :joins
|
||||
resources :verifications, only: %i[ new create ]
|
||||
end
|
||||
|
||||
resource :session do
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddVerifiedAtToUsers < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_column :users, :verified_at, :datetime
|
||||
end
|
||||
end
|
||||
Generated
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
|
||||
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "accessed_at"
|
||||
t.uuid "account_id", null: false
|
||||
@@ -726,6 +726,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
|
||||
t.string "name", null: false
|
||||
t.string "role", default: "member", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "verified_at"
|
||||
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
|
||||
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
|
||||
t.index ["identity_id"], name: "index_users_on_identity_id"
|
||||
|
||||
+2
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
|
||||
create_table "accesses", id: :uuid, force: :cascade do |t|
|
||||
t.datetime "accessed_at"
|
||||
t.uuid "account_id", null: false
|
||||
@@ -499,6 +499,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
|
||||
t.string "name", limit: 255, null: false
|
||||
t.string "role", limit: 255, default: "member", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.datetime "verified_at"
|
||||
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
|
||||
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
|
||||
t.index ["identity_id"], name: "index_users_on_identity_id"
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ else
|
||||
if user = identity.users.find_by(account: Current.account)
|
||||
user
|
||||
else
|
||||
User.create!(name: full_name, identity: identity, account: Current.account)
|
||||
User.create!(name: full_name, identity: identity, account: Current.account, verified_at: Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
class WebPush::Notification
|
||||
def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:)
|
||||
def initialize(title:, body:, path:, badge:, endpoint:, endpoint_ip:, p256dh_key:, auth_key:)
|
||||
@title, @body, @path, @badge = title, body, path, badge
|
||||
@endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key
|
||||
@endpoint, @endpoint_ip, @p256dh_key, @auth_key = endpoint, endpoint_ip, p256dh_key, auth_key
|
||||
end
|
||||
|
||||
def deliver(connection: nil)
|
||||
WebPush.payload_send \
|
||||
message: encoded_message,
|
||||
endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key,
|
||||
endpoint: @endpoint, endpoint_ip: @endpoint_ip, p256dh: @p256dh_key, auth: @auth_key,
|
||||
vapid: vapid_identification,
|
||||
connection: connection,
|
||||
urgency: "high"
|
||||
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require_relative "../../config/environment"
|
||||
|
||||
BACKFILL_TIMESTAMP = Time.parse("2025-12-02 12:00:00 UTC")
|
||||
|
||||
def collect_verified_user_ids
|
||||
verified_ids = Set.new
|
||||
|
||||
# Owners (they created the account)
|
||||
verified_ids.merge(User.where(role: :owner).pluck(:id))
|
||||
puts "After owners: #{verified_ids.size} users"
|
||||
|
||||
# Card creators
|
||||
verified_ids.merge(Card.distinct.pluck(:creator_id).compact)
|
||||
puts "After card creators: #{verified_ids.size} users"
|
||||
|
||||
# Comment creators
|
||||
verified_ids.merge(Comment.distinct.pluck(:creator_id).compact)
|
||||
puts "After comment creators: #{verified_ids.size} users"
|
||||
|
||||
# Board creators
|
||||
verified_ids.merge(Board.distinct.pluck(:creator_id).compact)
|
||||
puts "After board creators: #{verified_ids.size} users"
|
||||
|
||||
# Event creators
|
||||
verified_ids.merge(Event.distinct.pluck(:creator_id).compact)
|
||||
puts "After event creators: #{verified_ids.size} users"
|
||||
|
||||
# Assigners (not assignees - they could be assigned without logging in)
|
||||
verified_ids.merge(Assignment.distinct.pluck(:assigner_id).compact)
|
||||
puts "After assigners: #{verified_ids.size} users"
|
||||
|
||||
# Manual closers (user_id is nil for automatic closures)
|
||||
verified_ids.merge(Closure.where.not(user_id: nil).distinct.pluck(:user_id).compact)
|
||||
puts "After closers: #{verified_ids.size} users"
|
||||
|
||||
# Manual postponers (user_id is nil for automatic entropy postponements)
|
||||
verified_ids.merge(Card::NotNow.where.not(user_id: nil).distinct.pluck(:user_id).compact)
|
||||
puts "After postponers: #{verified_ids.size} users"
|
||||
|
||||
# Reactors
|
||||
verified_ids.merge(Reaction.distinct.pluck(:reacter_id).compact)
|
||||
puts "After reactors: #{verified_ids.size} users"
|
||||
|
||||
# Filter creators
|
||||
verified_ids.merge(Filter.distinct.pluck(:creator_id).compact)
|
||||
puts "After filter creators: #{verified_ids.size} users"
|
||||
|
||||
# Pinners
|
||||
verified_ids.merge(Pin.distinct.pluck(:user_id).compact)
|
||||
puts "After pinners: #{verified_ids.size} users"
|
||||
|
||||
# Board accessors (accessed_at is touched when viewing boards)
|
||||
verified_ids.merge(Access.where.not(accessed_at: nil).distinct.pluck(:user_id).compact)
|
||||
puts "After board accessors: #{verified_ids.size} users"
|
||||
|
||||
# Export requesters
|
||||
verified_ids.merge(Account::Export.distinct.pluck(:user_id).compact)
|
||||
puts "After export requesters: #{verified_ids.size} users"
|
||||
|
||||
# Push subscribers
|
||||
verified_ids.merge(Push::Subscription.distinct.pluck(:user_id).compact)
|
||||
puts "After push subscribers: #{verified_ids.size} users"
|
||||
|
||||
# Users who completed setup (name != email)
|
||||
verified_ids.merge(
|
||||
User.joins(:identity)
|
||||
.where.not("users.name = identities.email_address")
|
||||
.pluck(:id)
|
||||
)
|
||||
puts "After setup completers: #{verified_ids.size} users"
|
||||
|
||||
# Users whose identity has at least one session
|
||||
verified_ids.merge(
|
||||
User.where(identity_id: Session.distinct.select(:identity_id)).pluck(:id)
|
||||
)
|
||||
puts "After identity sessions: #{verified_ids.size} users"
|
||||
|
||||
verified_ids
|
||||
end
|
||||
|
||||
puts "Collecting verified user IDs..."
|
||||
verified_user_ids = collect_verified_user_ids
|
||||
|
||||
puts "\nFiltering to unverified users only..."
|
||||
users_to_update = User.where(id: verified_user_ids.to_a)
|
||||
.where(verified_at: nil)
|
||||
.where(active: true)
|
||||
.where.not(identity_id: nil)
|
||||
.where.not(role: :system)
|
||||
|
||||
update_count = users_to_update.count
|
||||
puts "Found #{update_count} users to backfill"
|
||||
|
||||
# Report remaining unverified users (before update)
|
||||
remaining_before = User.where(verified_at: nil, active: true)
|
||||
.where.not(identity_id: nil)
|
||||
.where.not(role: :system)
|
||||
.count
|
||||
remaining_after = remaining_before - update_count
|
||||
puts "\nCurrently unverified active users: #{remaining_before}"
|
||||
puts "After backfill, remaining unverified: #{remaining_after}"
|
||||
puts "These users will need to verify on next login."
|
||||
|
||||
if update_count > 0
|
||||
puts "\nBackfilling verified_at..."
|
||||
updated = users_to_update.update_all(verified_at: BACKFILL_TIMESTAMP)
|
||||
puts "Updated #{updated} users"
|
||||
end
|
||||
|
||||
puts "\nDone!"
|
||||
@@ -145,4 +145,49 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "disables select all/none buttons for non-privileged user" do
|
||||
logout_and_sign_in_as :jz
|
||||
assert_not users(:jz).can_administer_board?(boards(:writebook))
|
||||
|
||||
get edit_board_path(boards(:writebook))
|
||||
|
||||
assert_response :success
|
||||
assert_select "button[disabled]", text: "Select all"
|
||||
assert_select "button[disabled]", text: "Select none"
|
||||
end
|
||||
|
||||
test "enables select all/none buttons for privileged user" do
|
||||
assert users(:kevin).can_administer_board?(boards(:writebook))
|
||||
|
||||
get edit_board_path(boards(:writebook))
|
||||
|
||||
assert_response :success
|
||||
assert_select "button:not([disabled])", text: "Select all"
|
||||
assert_select "button:not([disabled])", text: "Select none"
|
||||
end
|
||||
|
||||
test "access toggle disabled state is cached correctly" do
|
||||
board = boards(:writebook)
|
||||
david = users(:david)
|
||||
|
||||
with_actionview_partial_caching do
|
||||
# privileged user
|
||||
assert users(:kevin).can_administer_board?(board)
|
||||
|
||||
get edit_board_path(board)
|
||||
|
||||
assert_response :success
|
||||
assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}']:not([disabled])"
|
||||
|
||||
# unprivileged user
|
||||
logout_and_sign_in_as :jz
|
||||
assert_not users(:jz).can_administer_board?(board)
|
||||
|
||||
get edit_board_path(board)
|
||||
|
||||
assert_response :success
|
||||
assert_select "input.switch__input[name='user_ids[]'][value='#{david.id}'][disabled]"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
assert_redirected_to session_magic_link_url(script_name: nil)
|
||||
assert_equal new_users_join_url(script_name: @account.slug), session[:return_to_after_authenticating]
|
||||
assert_equal new_users_verification_url(script_name: @account.slug), session[:return_to_after_authenticating]
|
||||
end
|
||||
|
||||
test "create for existing identity" do
|
||||
@@ -55,7 +55,7 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_redirected_to landing_url(script_name: @account.slug)
|
||||
end
|
||||
|
||||
test "create for signed-in identity without a user in the account redirects to user setup" do
|
||||
test "create for signed-in identity without a user in the account redirects to verification" do
|
||||
identity = identities(:mike)
|
||||
sign_in_as :mike
|
||||
|
||||
@@ -67,6 +67,31 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to new_users_join_url(script_name: @account.slug)
|
||||
assert_redirected_to new_users_verification_url(script_name: @account.slug)
|
||||
end
|
||||
|
||||
test "create for different identity terminates existing session" do
|
||||
sign_in_as :kevin
|
||||
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
assert_difference -> { User.count }, 1 do
|
||||
post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "new_user@example.com" }
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to session_magic_link_url(script_name: nil)
|
||||
assert_not_predicate cookies[:session_token], :present?
|
||||
end
|
||||
|
||||
test "create with invalid email address" do
|
||||
# Avoid Sentry exceptions when attackers try to stuff invalid emails into the system
|
||||
without_action_dispatch_exception_handling do
|
||||
assert_no_difference -> { Identity.count } do
|
||||
assert_no_difference -> { User.count } do
|
||||
post join_path(code: @join_code.code, script_name: @account.slug), params: { email_address: "not-a-valid-email" }
|
||||
end
|
||||
end
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
require "test_helper"
|
||||
|
||||
class Notifications::SettingsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:david)
|
||||
|
||||
sign_in_as @user
|
||||
end
|
||||
|
||||
test "show" do
|
||||
get notifications_settings_path
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "update email frequency" do
|
||||
assert_changes -> { @user.reload.settings.bundle_email_frequency }, from: "never", to: "every_few_hours" do
|
||||
put notifications_settings_path, params: { user_settings: { bundle_email_frequency: "every_few_hours" } }
|
||||
end
|
||||
|
||||
assert_redirected_to notifications_settings_path
|
||||
assert_equal "Settings updated", flash[:notice]
|
||||
end
|
||||
end
|
||||
@@ -36,15 +36,29 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
test "destroy" do
|
||||
sign_in_as :kevin
|
||||
|
||||
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
|
||||
# returning proper validation errors.
|
||||
without_action_dispatch_exception_handling do
|
||||
untenanted do
|
||||
delete session_path
|
||||
assert_no_difference -> { Identity.count } do
|
||||
post session_path, params: { email_address: "not-a-valid-email" }
|
||||
end
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_not cookies[:session_token].present?
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
sign_in_as :kevin
|
||||
|
||||
untenanted do
|
||||
delete session_path
|
||||
|
||||
assert_redirected_to new_session_path
|
||||
assert_not cookies[:session_token].present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,15 +34,17 @@ class SignupsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
end
|
||||
|
||||
test "create with email address containing blanks" do
|
||||
untenanted do
|
||||
assert_no_difference -> { Identity.count } do
|
||||
assert_no_difference -> { MagicLink.count } do
|
||||
post signup_path, params: { signup: { email_address: "sam smith@example.com" } }
|
||||
test "create with invalid email address" do
|
||||
without_action_dispatch_exception_handling do
|
||||
untenanted do
|
||||
assert_no_difference -> { Identity.count } do
|
||||
assert_no_difference -> { MagicLink.count } do
|
||||
post signup_path, params: { signup: { email_address: "not-a-valid-email" } }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
require "test_helper"
|
||||
|
||||
class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
||||
PUBLIC_TEST_IP = "142.250.185.206"
|
||||
|
||||
setup do
|
||||
sign_in_as :david
|
||||
stub_dns_resolution(PUBLIC_TEST_IP)
|
||||
end
|
||||
|
||||
test "create new push subscription" do
|
||||
subscription_params = { "endpoint" => "https://apple", "p256dh_key" => "123", "auth_key" => "456" }
|
||||
subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
|
||||
|
||||
post user_push_subscriptions_path(users(:david)),
|
||||
params: { push_subscription: subscription_params }, headers: { "HTTP_USER_AGENT" => "Mozilla/5.0" }
|
||||
@@ -19,7 +22,7 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "touch existing subscription" do
|
||||
existing_subscription = users(:david).push_subscriptions.create!(
|
||||
endpoint: "https://apple",
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
|
||||
p256dh_key: "123",
|
||||
auth_key: "456"
|
||||
)
|
||||
@@ -37,7 +40,7 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "destroy a push subscription" do
|
||||
subscription = users(:david).push_subscriptions.create!(
|
||||
endpoint: "https://apple",
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
|
||||
p256dh_key: "123",
|
||||
auth_key: "456"
|
||||
)
|
||||
@@ -47,4 +50,35 @@ class Users::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_redirected_to user_push_subscriptions_path(users(:david))
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects subscription with non-permitted endpoint" do
|
||||
subscription_params = { "endpoint" => "https://attacker.example.com/steal", "p256dh_key" => "123", "auth_key" => "456" }
|
||||
|
||||
assert_no_difference -> { Push::Subscription.count } do
|
||||
post user_push_subscriptions_path(users(:david)),
|
||||
params: { push_subscription: subscription_params }
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "rejects subscription with endpoint resolving to private IP" do
|
||||
stub_dns_resolution("192.168.1.1")
|
||||
|
||||
subscription_params = { "endpoint" => "https://fcm.googleapis.com/fcm/send/abc123", "p256dh_key" => "123", "auth_key" => "456" }
|
||||
|
||||
assert_no_difference -> { Push::Subscription.count } do
|
||||
post user_push_subscriptions_path(users(:david)),
|
||||
params: { push_subscription: subscription_params }
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
def stub_dns_resolution(*ips)
|
||||
dns_mock = mock("dns")
|
||||
dns_mock.stubs(:each_address).multiple_yields(*ips)
|
||||
Resolv::DNS.stubs(:open).yields(dns_mock)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
require "test_helper"
|
||||
|
||||
class Users::VerificationsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "new renders the auto-submit form" do
|
||||
sign_in_as :david
|
||||
|
||||
get new_users_verification_path
|
||||
|
||||
assert_response :ok
|
||||
end
|
||||
|
||||
test "create verifies the user and redirects to join" do
|
||||
sign_in_as :david
|
||||
|
||||
user = users(:david)
|
||||
user.update_column(:verified_at, nil)
|
||||
assert_not user.verified?
|
||||
|
||||
post users_verifications_path
|
||||
|
||||
assert_redirected_to new_users_join_path
|
||||
assert user.reload.verified?
|
||||
end
|
||||
end
|
||||
@@ -64,4 +64,26 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
delete user_path(users(:david))
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "update with invalid avatar shows validation error" do
|
||||
sign_in_as :kevin
|
||||
|
||||
svg_file = fixture_file_upload("avatar.svg", "image/svg+xml")
|
||||
|
||||
put user_path(users(:kevin)), params: { user: { avatar: svg_file } }
|
||||
assert_response :unprocessable_entity
|
||||
assert_select "form[action='#{user_path(users(:kevin))}']"
|
||||
assert_select ".txt-negative", text: /must be a JPEG, PNG, GIF, or WebP image/
|
||||
end
|
||||
|
||||
test "update with valid avatar" do
|
||||
sign_in_as :kevin
|
||||
|
||||
png_file = fixture_file_upload("avatar.png", "image/png")
|
||||
|
||||
put user_path(users(:kevin)), params: { user: { avatar: png_file } }
|
||||
assert_redirected_to user_path(users(:kevin))
|
||||
assert users(:kevin).reload.avatar.attached?
|
||||
assert_equal "image/png", users(:kevin).avatar.content_type
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,6 +14,10 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
|
||||
webhook = webhooks(:active)
|
||||
get board_webhook_path(webhook.board, webhook)
|
||||
assert_response :success
|
||||
|
||||
webhook = webhooks(:inactive)
|
||||
get board_webhook_path(webhook.board, webhook)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "new" do
|
||||
@@ -63,6 +67,11 @@ class WebhooksControllerTest < ActionDispatch::IntegrationTest
|
||||
get edit_board_webhook_path(webhook.board, webhook)
|
||||
assert_response :success
|
||||
assert_select "form"
|
||||
|
||||
webhook = webhooks(:inactive)
|
||||
get edit_board_webhook_path(webhook.board, webhook)
|
||||
assert_response :success
|
||||
assert_select "form"
|
||||
end
|
||||
|
||||
test "update with valid params" do
|
||||
|
||||
Vendored
+5
@@ -4,6 +4,7 @@ david:
|
||||
role: member
|
||||
identity: david
|
||||
account: 37s_uuid
|
||||
verified_at: <%= Time.current.to_fs(:db) %>
|
||||
|
||||
jz:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %>
|
||||
@@ -11,6 +12,7 @@ jz:
|
||||
role: member
|
||||
identity: jz
|
||||
account: 37s_uuid
|
||||
verified_at: <%= Time.current.to_fs(:db) %>
|
||||
|
||||
jason:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("jason", :uuid) %>
|
||||
@@ -18,6 +20,7 @@ jason:
|
||||
role: owner
|
||||
identity: jason
|
||||
account: 37s_uuid
|
||||
verified_at: <%= Time.current.to_fs(:db) %>
|
||||
|
||||
kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("kevin", :uuid) %>
|
||||
@@ -25,6 +28,7 @@ kevin:
|
||||
role: admin
|
||||
identity: kevin
|
||||
account: 37s_uuid
|
||||
verified_at: <%= Time.current.to_fs(:db) %>
|
||||
|
||||
system:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("system", :uuid) %>
|
||||
@@ -38,6 +42,7 @@ mike:
|
||||
role: admin
|
||||
identity: mike
|
||||
account: initech_uuid
|
||||
verified_at: <%= Time.current.to_fs(:db) %>
|
||||
|
||||
system_initech:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("system_initech", :uuid) %>
|
||||
|
||||
@@ -44,6 +44,8 @@ class AccountTest < ActiveSupport::TestCase
|
||||
assert owner.admin?, "owner should also be considered an admin"
|
||||
|
||||
assert_predicate account.system_user, :present?
|
||||
|
||||
assert owner.verified?, "owner should be verified on account creation"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,4 +4,24 @@ class CommentTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
Current.session = sessions(:david)
|
||||
end
|
||||
|
||||
test "rich text embed variants are processed immediately on attachment" do
|
||||
comment = cards(:logo).comments.create!(body: "Check this out")
|
||||
comment.body.body.attachables # force load
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload! \
|
||||
io: File.open(file_fixture("moon.jpg")),
|
||||
filename: "moon.jpg",
|
||||
content_type: "image/jpeg"
|
||||
|
||||
comment.body.body = ActionText::Content.new(comment.body.body.to_html).append_attachables(blob)
|
||||
comment.save!
|
||||
|
||||
embed = comment.body.embeds.sole
|
||||
|
||||
Attachments::VARIANTS.each_key do |variant_name|
|
||||
variant = embed.variant(variant_name)
|
||||
assert variant.processed?, "Expected #{variant_name} variant to be processed immediately"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,4 +46,19 @@ class IdentityTest < ActiveSupport::TestCase
|
||||
assert_equal identity.email_address, user.name
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy deactivates users before nullifying identity" do
|
||||
identity = identities(:kevin)
|
||||
user = users(:kevin)
|
||||
|
||||
assert_predicate user, :active?
|
||||
assert_predicate user.accesses, :any?
|
||||
|
||||
identity.destroy!
|
||||
user.reload
|
||||
|
||||
assert_nil user.identity_id, "identity should be nullified"
|
||||
assert_not_predicate user, :active?
|
||||
assert_empty user.accesses, "user accesses should be removed"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
require "test_helper"
|
||||
|
||||
class Push::SubscriptionTest < ActiveSupport::TestCase
|
||||
PUBLIC_TEST_IP = "142.250.185.206" # google.com IP
|
||||
|
||||
setup do
|
||||
stub_dns_resolution(PUBLIC_TEST_IP)
|
||||
end
|
||||
|
||||
test "valid subscription with permitted endpoint" do
|
||||
subscription = Push::Subscription.new(
|
||||
user: users(:david),
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
|
||||
p256dh_key: "test_key",
|
||||
auth_key: "test_auth"
|
||||
)
|
||||
|
||||
assert subscription.valid?
|
||||
end
|
||||
|
||||
test "rejects endpoint with non-https scheme" do
|
||||
subscription = Push::Subscription.new(
|
||||
user: users(:david),
|
||||
endpoint: "http://fcm.googleapis.com/fcm/send/abc123",
|
||||
p256dh_key: "test_key",
|
||||
auth_key: "test_auth"
|
||||
)
|
||||
|
||||
assert_not subscription.valid?
|
||||
assert_includes subscription.errors[:endpoint], "must use HTTPS"
|
||||
end
|
||||
|
||||
test "rejects endpoint with non-permitted host" do
|
||||
subscription = Push::Subscription.new(
|
||||
user: users(:david),
|
||||
endpoint: "https://attacker.example.com/webhook",
|
||||
p256dh_key: "test_key",
|
||||
auth_key: "test_auth"
|
||||
)
|
||||
|
||||
assert_not subscription.valid?
|
||||
assert_includes subscription.errors[:endpoint], "is not a permitted push service"
|
||||
end
|
||||
|
||||
test "rejects endpoint that resolves to private IP" do
|
||||
stub_dns_resolution("192.168.1.1")
|
||||
|
||||
subscription = Push::Subscription.new(
|
||||
user: users(:david),
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
|
||||
p256dh_key: "test_key",
|
||||
auth_key: "test_auth"
|
||||
)
|
||||
|
||||
assert_not subscription.valid?
|
||||
assert_includes subscription.errors[:endpoint], "resolves to a private or invalid IP address"
|
||||
end
|
||||
|
||||
test "rejects endpoint that resolves to loopback IP" do
|
||||
stub_dns_resolution("127.0.0.1")
|
||||
|
||||
subscription = Push::Subscription.new(
|
||||
user: users(:david),
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
|
||||
p256dh_key: "test_key",
|
||||
auth_key: "test_auth"
|
||||
)
|
||||
|
||||
assert_not subscription.valid?
|
||||
assert_includes subscription.errors[:endpoint], "resolves to a private or invalid IP address"
|
||||
end
|
||||
|
||||
test "rejects endpoint that resolves to link-local IP (AWS IMDS)" do
|
||||
stub_dns_resolution("169.254.169.254")
|
||||
|
||||
subscription = Push::Subscription.new(
|
||||
user: users(:david),
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
|
||||
p256dh_key: "test_key",
|
||||
auth_key: "test_auth"
|
||||
)
|
||||
|
||||
assert_not subscription.valid?
|
||||
assert_includes subscription.errors[:endpoint], "resolves to a private or invalid IP address"
|
||||
end
|
||||
|
||||
test "resolved_endpoint_ip returns pinned public IP" do
|
||||
subscription = Push::Subscription.new(
|
||||
user: users(:david),
|
||||
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
|
||||
p256dh_key: "test_key",
|
||||
auth_key: "test_auth"
|
||||
)
|
||||
|
||||
assert_equal PUBLIC_TEST_IP, subscription.resolved_endpoint_ip
|
||||
end
|
||||
|
||||
test "accepts all permitted push service domains" do
|
||||
permitted_endpoints = [
|
||||
"https://fcm.googleapis.com/fcm/send/token123",
|
||||
"https://updates.push.services.mozilla.com/wpush/v2/token123",
|
||||
"https://web.push.apple.com/QaBC123",
|
||||
"https://wns2-db5p.notify.windows.com/w/?token=abc123"
|
||||
]
|
||||
|
||||
permitted_endpoints.each do |endpoint|
|
||||
subscription = Push::Subscription.new(
|
||||
user: users(:david),
|
||||
endpoint: endpoint,
|
||||
p256dh_key: "test_key",
|
||||
auth_key: "test_auth"
|
||||
)
|
||||
|
||||
assert subscription.valid?, "Expected #{endpoint} to be valid, got errors: #{subscription.errors.full_messages}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def stub_dns_resolution(*ips)
|
||||
dns_mock = mock("dns")
|
||||
dns_mock.stubs(:each_address).multiple_yields(*ips)
|
||||
Resolv::DNS.stubs(:open).yields(dns_mock)
|
||||
end
|
||||
end
|
||||
@@ -1,15 +1,26 @@
|
||||
require "test_helper"
|
||||
|
||||
class SignupTest < ActiveSupport::TestCase
|
||||
test "validates email format for identity creation" do
|
||||
signup = Signup.new(email_address: "not-an-email")
|
||||
assert_not signup.valid?(:identity_creation)
|
||||
assert signup.errors[:email_address].any?
|
||||
|
||||
signup = Signup.new(email_address: "valid@example.com")
|
||||
assert signup.valid?(:identity_creation)
|
||||
end
|
||||
|
||||
test "#create_identity" do
|
||||
signup = Signup.new(email_address: "brian@example.com")
|
||||
|
||||
magic_link = nil
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
assert signup.create_identity
|
||||
magic_link = signup.create_identity
|
||||
end
|
||||
end
|
||||
|
||||
assert_kind_of MagicLink, magic_link
|
||||
assert_empty signup.errors
|
||||
assert signup.identity
|
||||
assert signup.identity.persisted?
|
||||
@@ -18,10 +29,12 @@ class SignupTest < ActiveSupport::TestCase
|
||||
|
||||
assert_no_difference -> { Identity.count } do
|
||||
assert_difference -> { MagicLink.count }, 1 do
|
||||
assert signup_existing.create_identity, "Should send magic link for existing identity"
|
||||
magic_link = signup_existing.create_identity
|
||||
end
|
||||
end
|
||||
|
||||
assert_kind_of MagicLink, magic_link
|
||||
|
||||
signup_invalid = Signup.new(email_address: "")
|
||||
assert_raises do
|
||||
signup_invalid.create_identity
|
||||
@@ -48,4 +61,15 @@ class SignupTest < ActiveSupport::TestCase
|
||||
assert_not_empty signup_invalid.errors[:full_name]
|
||||
end
|
||||
end
|
||||
|
||||
test "#complete with invalid data" do
|
||||
Current.without_account do
|
||||
signup = Signup.new
|
||||
assert_not signup.complete
|
||||
assert signup.errors[:full_name].any?
|
||||
assert signup.errors[:identity].any?
|
||||
assert_nil signup.account
|
||||
assert_nil signup.user
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
require "test_helper"
|
||||
|
||||
class SsrfProtectionTest < ActiveSupport::TestCase
|
||||
test "blocks loopback addresses" do
|
||||
stub_dns_resolution("127.0.0.1")
|
||||
assert_nil SsrfProtection.resolve_public_ip("localhost")
|
||||
end
|
||||
|
||||
test "blocks private 10.x.x.x addresses" do
|
||||
stub_dns_resolution("10.0.0.1")
|
||||
assert_nil SsrfProtection.resolve_public_ip("internal.example.com")
|
||||
end
|
||||
|
||||
test "blocks private 172.16.x.x addresses" do
|
||||
stub_dns_resolution("172.16.0.1")
|
||||
assert_nil SsrfProtection.resolve_public_ip("internal.example.com")
|
||||
end
|
||||
|
||||
test "blocks private 192.168.x.x addresses" do
|
||||
stub_dns_resolution("192.168.1.1")
|
||||
assert_nil SsrfProtection.resolve_public_ip("internal.example.com")
|
||||
end
|
||||
|
||||
test "blocks link-local addresses (AWS metadata endpoint)" do
|
||||
stub_dns_resolution("169.254.169.254")
|
||||
assert_nil SsrfProtection.resolve_public_ip("metadata.example.com")
|
||||
end
|
||||
|
||||
test "blocks carrier-grade NAT addresses" do
|
||||
stub_dns_resolution("100.64.0.1")
|
||||
assert_nil SsrfProtection.resolve_public_ip("cgnat.example.com")
|
||||
end
|
||||
|
||||
test "blocks benchmark testing addresses" do
|
||||
stub_dns_resolution("198.18.0.1")
|
||||
assert_nil SsrfProtection.resolve_public_ip("benchmark.example.com")
|
||||
end
|
||||
|
||||
test "blocks broadcast addresses" do
|
||||
stub_dns_resolution("0.0.0.1")
|
||||
assert_nil SsrfProtection.resolve_public_ip("broadcast.example.com")
|
||||
end
|
||||
|
||||
test "allows public addresses" do
|
||||
stub_dns_resolution("93.184.216.34")
|
||||
assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("example.com")
|
||||
end
|
||||
|
||||
test "returns first public IP when multiple addresses resolve" do
|
||||
stub_dns_resolution("10.0.0.1", "93.184.216.34", "192.168.1.1")
|
||||
assert_equal "93.184.216.34", SsrfProtection.resolve_public_ip("multi.example.com")
|
||||
end
|
||||
|
||||
private
|
||||
def stub_dns_resolution(*ips)
|
||||
dns_mock = mock("dns")
|
||||
dns_mock.stubs(:each_address).multiple_yields(*ips)
|
||||
Resolv::DNS.stubs(:open).yields(dns_mock)
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,7 @@ class User::AvatarTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "allows valid image content types" do
|
||||
users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "test.jpg")
|
||||
users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "test.jpg", content_type: "image/jpeg")
|
||||
|
||||
assert users(:david).valid?
|
||||
end
|
||||
@@ -27,4 +27,10 @@ class User::AvatarTest < ActiveSupport::TestCase
|
||||
assert_not users(:david).valid?
|
||||
assert_includes users(:david).errors[:avatar], "must be a JPEG, PNG, GIF, or WebP image"
|
||||
end
|
||||
|
||||
test "thumb variant is processed immediately on attachment" do
|
||||
users(:david).avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "avatar.png", content_type: "image/png")
|
||||
|
||||
assert users(:david).avatar.variant(:thumb).processed?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,5 +49,9 @@ class User::SettingsTest < ActiveSupport::TestCase
|
||||
|
||||
@user.update!(role: :member, active: false)
|
||||
assert_not @user.settings.bundling_emails?, "Inactive users should not receive bundled emails"
|
||||
|
||||
@user.update!(active: true)
|
||||
@user.update_column(:verified_at, nil)
|
||||
assert_not @user.settings.bundling_emails?, "Unverified users should not receive bundled emails"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,4 +48,36 @@ class UserTest < ActiveSupport::TestCase
|
||||
user.update!(name: "Kevin")
|
||||
assert user.setup?
|
||||
end
|
||||
|
||||
test "verified? returns true when verified_at is present" do
|
||||
user = users(:david)
|
||||
user.update_column(:verified_at, Time.current)
|
||||
|
||||
assert user.verified?
|
||||
end
|
||||
|
||||
test "verified? returns false when verified_at is nil" do
|
||||
user = users(:david)
|
||||
user.update_column(:verified_at, nil)
|
||||
|
||||
assert_not user.verified?
|
||||
end
|
||||
|
||||
test "verify sets verified_at when not already verified" do
|
||||
user = users(:david)
|
||||
user.update_column(:verified_at, nil)
|
||||
|
||||
assert_nil user.verified_at
|
||||
user.verify
|
||||
assert_not_nil user.reload.verified_at
|
||||
end
|
||||
|
||||
test "verify does not update verified_at when already verified" do
|
||||
user = users(:david)
|
||||
original_time = 1.day.ago
|
||||
user.update_column(:verified_at, original_time)
|
||||
|
||||
user.verify
|
||||
assert_equal original_time.to_i, user.reload.verified_at.to_i
|
||||
end
|
||||
end
|
||||
|
||||
@@ -298,11 +298,12 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
|
||||
# Verify Net::HTTP.new is called with the pinned IP
|
||||
http_mock = mock("http")
|
||||
http_mock.stubs(:use_ssl=)
|
||||
http_mock.stubs(:ipaddr=)
|
||||
http_mock.stubs(:open_timeout=)
|
||||
http_mock.stubs(:read_timeout=)
|
||||
http_mock.stubs(:request).returns(stub(code: "200"))
|
||||
|
||||
Net::HTTP.expects(:new).with("example.com", 443, ipaddr: PUBLIC_TEST_IP).returns(http_mock)
|
||||
Net::HTTP.expects(:new).with("example.com", 443).returns(http_mock)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
require "application_system_test_case"
|
||||
|
||||
class SmokeTest < ApplicationSystemTestCase
|
||||
test "joining an account" do
|
||||
account = accounts("37s")
|
||||
|
||||
visit join_url(code: account.join_code.code, script_name: account.slug)
|
||||
fill_in "Email address", with: "newbie@example.com"
|
||||
click_on "Continue"
|
||||
|
||||
assert_selector "h1", text: "Check your email"
|
||||
identity = Identity.find_by!(email_address: "newbie@example.com")
|
||||
code = identity.magic_links.active.first.code
|
||||
fill_in "code", with: code
|
||||
send_keys :enter
|
||||
|
||||
assert_selector "input[id=user_name]"
|
||||
assert account.users.find_by!(identity:).verified?, "User was not properly verified"
|
||||
fill_in "Full name", with: "New Bee"
|
||||
click_on "Continue"
|
||||
|
||||
assert_selector "h1", text: "Writebook"
|
||||
end
|
||||
|
||||
test "create a card" do
|
||||
sign_in_as(users(:david))
|
||||
|
||||
@@ -24,7 +45,7 @@ class SmokeTest < ApplicationSystemTestCase
|
||||
|
||||
within("form lexxy-editor figure.attachment[data-content-type='image/jpeg']") do
|
||||
assert_selector "img[src*='/rails/active_storage']"
|
||||
assert_selector "figcaption input[placeholder='moon.jpg']"
|
||||
assert_selector "figcaption textarea[placeholder='moon.jpg']"
|
||||
end
|
||||
|
||||
click_on "Post"
|
||||
|
||||
+12
-1
@@ -44,7 +44,7 @@ module ActiveSupport
|
||||
fixtures :all
|
||||
|
||||
include ActiveJob::TestHelper
|
||||
include ActionTextTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
|
||||
include ActionTextTestHelper, CachingTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
|
||||
include Turbo::Broadcastable::TestHelper
|
||||
|
||||
setup do
|
||||
@@ -61,6 +61,17 @@ class ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
integration_session.default_url_options[:script_name] = "/#{ActiveRecord::FixtureSet.identify("37signals")}"
|
||||
end
|
||||
|
||||
private
|
||||
def without_action_dispatch_exception_handling
|
||||
original = Rails.application.config.action_dispatch.show_exceptions
|
||||
Rails.application.config.action_dispatch.show_exceptions = :none
|
||||
Rails.application.instance_variable_set(:@app_env_config, nil) # Clear memoized env_config
|
||||
yield
|
||||
ensure
|
||||
Rails.application.config.action_dispatch.show_exceptions = original
|
||||
Rails.application.instance_variable_set(:@app_env_config, nil) # Reset env_config
|
||||
end
|
||||
end
|
||||
|
||||
class ActionDispatch::SystemTestCase
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
module CachingTestHelper
|
||||
def with_actionview_partial_caching
|
||||
was_cache = ActionView::PartialRenderer.collection_cache
|
||||
was_perform_caching = ApplicationController.perform_caching
|
||||
begin
|
||||
ActionView::PartialRenderer.collection_cache = ActiveSupport::Cache.lookup_store(:memory_store)
|
||||
ApplicationController.perform_caching = true
|
||||
yield
|
||||
ensure
|
||||
ActionView::PartialRenderer.collection_cache = was_cache
|
||||
ApplicationController.perform_caching = was_perform_caching
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user