From a7198be1c37b96a9a9f34d6acf8d980ada429642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nishiki=20=28=E9=8C=A6=E8=8F=AF=29?= Date: Sun, 9 Jun 2024 22:00:39 -0700 Subject: [PATCH] feat: proof of concept (#1) # Overview This is v1 of Naisho. See deployed app for more details. --- .github/workflows/ci.yml | 28 +++ .gitignore | 6 + Gemfile | 4 +- Gemfile.lock | 17 +- LICENSE.txt | 21 ++ README.md | 20 +- .../stylesheets/1-config/animations.css | 13 ++ app/assets/stylesheets/1-config/base.css | 91 +++++++++ app/assets/stylesheets/1-config/utilities.css | 3 + app/assets/stylesheets/1-config/variables.css | 26 +++ .../stylesheets/2-components/button.css | 46 +++++ .../stylesheets/2-components/company-grid.css | 41 ++++ .../stylesheets/2-components/container.css | 5 + app/assets/stylesheets/2-components/faq.css | 40 ++++ .../stylesheets/2-components/footer.css | 11 ++ .../stylesheets/2-components/header.css | 23 +++ app/assets/stylesheets/2-components/hero.css | 36 ++++ app/assets/stylesheets/2-components/logo.css | 18 ++ app/assets/stylesheets/2-components/nav.css | 49 +++++ .../2-components/requester-form.css | 139 ++++++++++++++ .../2-components/steps-content.css | 59 ++++++ app/components/application_component.rb | 2 + app/components/button_component.html.erb | 7 + app/components/button_component.rb | 23 +++ .../company_grid_component.html.erb | 9 + app/components/company_grid_component.rb | 10 + app/components/container_component.html.erb | 3 + app/components/container_component.rb | 3 + app/components/faq_component.html.erb | 12 ++ app/components/faq_component.rb | 11 ++ app/components/footer_component.html.erb | 3 + app/components/footer_component.rb | 3 + app/components/header_component.html.erb | 18 ++ app/components/header_component.rb | 3 + app/components/hero_component.html.erb | 6 + app/components/hero_component.rb | 21 ++ app/components/logo_component.html.erb | 2 + app/components/logo_component.rb | 3 + app/components/nav_component.html.erb | 14 ++ app/components/nav_component.rb | 11 ++ .../requester_form_component.html.erb | 181 ++++++++++++++++++ app/components/requester_form_component.rb | 105 ++++++++++ .../steps_content_component.html.erb | 12 ++ app/components/steps_content_component.rb | 12 ++ .../bulk_deletion_requests_controller.rb | 25 +++ app/controllers/companies_controller.rb | 7 + app/controllers/pages_controller.rb | 10 + .../controllers/hello_controller.js | 7 - app/mailers/deletion_request_mailer.rb | 23 +++ app/models/bulk_deletion_request.rb | 73 +++++++ app/models/company.rb | 62 ++++++ .../california_data_brokers_requestable.rb | 35 ++++ .../company/data_brokers_watch_requestable.rb | 33 ++++ .../concerns/deletion_request/serializable.rb | 32 ++++ .../concerns/deletion_request/validator.rb | 74 +++++++ .../smtp_config/providers_supportable.rb | 63 ++++++ app/models/deletion_request.rb | 31 +++ app/models/region.rb | 64 +++++++ app/models/smtp_config.rb | 25 +++ app/views/bulk_deletion_requests/new.html.erb | 23 +++ app/views/companies/index.html.erb | 12 ++ .../deletion_request.text.erb | 1 + app/views/layouts/application.html.erb | 14 +- app/views/layouts/mailer.html.erb | 13 -- app/views/pages/about.html.erb | 32 ++++ app/views/pages/home.html.erb | 23 +++ bin/docker-entrypoint | 4 +- config/application.rb | 11 ++ config/dockerfile.yml | 6 + config/environments/development.rb | 4 +- config/environments/production.rb | 1 + config/locales/en.yml | 147 +++++++++++++- config/routes.rb | 12 +- db/migrate/20240606193101_create_companies.rb | 11 ++ db/schema.rb | 25 +++ lib/tasks/auto_annotate_models.rake | 59 ++++++ lib/tasks/sync_companies.rake | 6 + public/favicon.ico | Bin 0 -> 177709 bytes public/robots.txt | 28 ++- test/fixtures/companies.yml | 22 +++ .../deletion_request_flows_test.rb | 45 +++++ test/integration/smoke_test.rb | 31 +++ test/models/company_test.rb | 78 ++++++++ test/models/deletion_request_test.rb | 164 ++++++++++++++++ test/models/smtp_config_test.rb | 53 +++++ test/test_helper.rb | 9 + 86 files changed, 2524 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE.txt create mode 100644 app/assets/stylesheets/1-config/animations.css create mode 100644 app/assets/stylesheets/1-config/base.css create mode 100644 app/assets/stylesheets/1-config/utilities.css create mode 100644 app/assets/stylesheets/1-config/variables.css create mode 100644 app/assets/stylesheets/2-components/button.css create mode 100644 app/assets/stylesheets/2-components/company-grid.css create mode 100644 app/assets/stylesheets/2-components/container.css create mode 100644 app/assets/stylesheets/2-components/faq.css create mode 100644 app/assets/stylesheets/2-components/footer.css create mode 100644 app/assets/stylesheets/2-components/header.css create mode 100644 app/assets/stylesheets/2-components/hero.css create mode 100644 app/assets/stylesheets/2-components/logo.css create mode 100644 app/assets/stylesheets/2-components/nav.css create mode 100644 app/assets/stylesheets/2-components/requester-form.css create mode 100644 app/assets/stylesheets/2-components/steps-content.css create mode 100644 app/components/application_component.rb create mode 100644 app/components/button_component.html.erb create mode 100644 app/components/button_component.rb create mode 100644 app/components/company_grid_component.html.erb create mode 100644 app/components/company_grid_component.rb create mode 100644 app/components/container_component.html.erb create mode 100644 app/components/container_component.rb create mode 100644 app/components/faq_component.html.erb create mode 100644 app/components/faq_component.rb create mode 100644 app/components/footer_component.html.erb create mode 100644 app/components/footer_component.rb create mode 100644 app/components/header_component.html.erb create mode 100644 app/components/header_component.rb create mode 100644 app/components/hero_component.html.erb create mode 100644 app/components/hero_component.rb create mode 100644 app/components/logo_component.html.erb create mode 100644 app/components/logo_component.rb create mode 100644 app/components/nav_component.html.erb create mode 100644 app/components/nav_component.rb create mode 100644 app/components/requester_form_component.html.erb create mode 100644 app/components/requester_form_component.rb create mode 100644 app/components/steps_content_component.html.erb create mode 100644 app/components/steps_content_component.rb create mode 100644 app/controllers/bulk_deletion_requests_controller.rb create mode 100644 app/controllers/companies_controller.rb create mode 100644 app/controllers/pages_controller.rb delete mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/mailers/deletion_request_mailer.rb create mode 100644 app/models/bulk_deletion_request.rb create mode 100644 app/models/company.rb create mode 100644 app/models/concerns/company/california_data_brokers_requestable.rb create mode 100644 app/models/concerns/company/data_brokers_watch_requestable.rb create mode 100644 app/models/concerns/deletion_request/serializable.rb create mode 100644 app/models/concerns/deletion_request/validator.rb create mode 100644 app/models/concerns/smtp_config/providers_supportable.rb create mode 100644 app/models/deletion_request.rb create mode 100644 app/models/region.rb create mode 100644 app/models/smtp_config.rb create mode 100644 app/views/bulk_deletion_requests/new.html.erb create mode 100644 app/views/companies/index.html.erb create mode 100644 app/views/deletion_request_mailer/deletion_request.text.erb delete mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/pages/about.html.erb create mode 100644 app/views/pages/home.html.erb create mode 100644 config/dockerfile.yml create mode 100644 db/migrate/20240606193101_create_companies.rb create mode 100644 db/schema.rb create mode 100644 lib/tasks/auto_annotate_models.rake create mode 100644 lib/tasks/sync_companies.rake create mode 100644 test/fixtures/companies.yml create mode 100644 test/integration/deletion_request_flows_test.rb create mode 100644 test/integration/smoke_test.rb create mode 100644 test/models/company_test.rb create mode 100644 test/models/deletion_request_test.rb create mode 100644 test/models/smtp_config_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ebd8307 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: [pull_request] +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Set up project + run: bin/setup + + - name: Run Standard + run: bundle exec standardrb --fail-level A + + - name: Run Annotate + run: bundle exec annotate --frozen + + - name: Run Chusaku + run: bundle exec chusaku --dry-run --exit-with-error-on-annotation + + - name: Run test suite + run: bin/rails test diff --git a/.gitignore b/.gitignore index c8d0b5b..46f760a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,9 @@ # Ignore default Litestack SQLite databases. /db/**/*.sqlite3 /db/**/*.sqlite3-* + +# Codecov. +/coverage + +# Fly.io. +fly.toml diff --git a/Gemfile b/Gemfile index d7de12e..d291170 100644 --- a/Gemfile +++ b/Gemfile @@ -5,6 +5,8 @@ ruby "3.3.2" gem "bcrypt", "~> 3.1.7" gem "bootsnap", require: false +gem "csv" +gem "dockerfile-rails", ">= 1.6", group: :development gem "importmap-rails" gem "jbuilder" gem "litestack", github: "oldmoe/litestack" @@ -15,6 +17,7 @@ gem "sqlite3", "~> 1.4" gem "stimulus-rails" gem "turbo-rails" gem "tzinfo-data", platforms: %i[windows jruby] +gem "view_component" group :development, :test do gem "debug", platforms: %i[mri windows] @@ -29,7 +32,6 @@ end group :test do gem "capybara" - gem "mocktail" gem "selenium-webdriver" gem "simplecov", require: false end diff --git a/Gemfile.lock b/Gemfile.lock index b7a7440..cab9ed5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -114,11 +114,14 @@ GEM concurrent-ruby (1.3.1) connection_pool (2.4.1) crass (1.0.6) + csv (3.3.0) date (3.3.4) debug (1.9.2) irb (~> 1.10) reline (>= 0.3.8) docile (1.4.0) + dockerfile-rails (1.6.16) + rails (>= 3.0.0) drb (2.2.1) erubi (1.12.0) globalid (1.2.1) @@ -149,11 +152,9 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.2) + method_source (1.1.0) mini_mime (1.1.5) minitest (5.23.1) - mocktail (2.0.0) - sorbet-eraser (~> 0.3.1) - sorbet-runtime (~> 0.5.9204) msgpack (1.7.2) mutex_m (0.2.0) net-imap (0.4.12) @@ -265,8 +266,6 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) - sorbet-eraser (0.3.1) - sorbet-runtime (0.5.11415) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -306,6 +305,10 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) + view_component (3.12.1) + activesupport (>= 5.2.0, < 8.0) + concurrent-ruby (~> 1.0) + method_source (~> 1.0) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -334,11 +337,12 @@ DEPENDENCIES bootsnap capybara chusaku + csv debug + dockerfile-rails (>= 1.6) importmap-rails jbuilder litestack! - mocktail puma (>= 5.0) rails (~> 7.1.3, >= 7.1.3.4) selenium-webdriver @@ -349,6 +353,7 @@ DEPENDENCIES stimulus-rails turbo-rails tzinfo-data + view_component web-console RUBY VERSION diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f4185cb --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Nishiki Liu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index add8d47..4147852 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ -# PIDR +# Naisho -**P**ersonal **I**nformation **D**eletion **R**equester. +Naisho is a free and open-source tool for sending personal information data deletion request emails to data brokers via SMTP. + +## Stack + +- [Ruby on Rails](https://rubyonrails.org/) v7.1.3.4 +- [Hotwire](https://hotwired.dev/) +- [Litestack](https://github.com/oldmoe/litestack) +- [ViewComponent](https://viewcomponent.org/) + +## Local setup (native) + +1. Clone the repo. +2. Install [Ruby](https://www.ruby-lang.org/en/) v3.3.2. +3. Install [SQLite3](https://www.sqlite.org/index.html). +4. Run `bin/setup` to install Ruby dependencies and set up the database. +5. Run `bin/rake sync_companies` to pull the latest data broker companies from sources. +6. Run `bin/rails server` to start the Rails server. diff --git a/app/assets/stylesheets/1-config/animations.css b/app/assets/stylesheets/1-config/animations.css new file mode 100644 index 0000000..d2d46d6 --- /dev/null +++ b/app/assets/stylesheets/1-config/animations.css @@ -0,0 +1,13 @@ +@keyframes slide-in { + from { + max-height: 0; + opacity: 0; + overflow-y: hidden; + } + + to { + max-height: var(--slide-in-height); + opacity: 1; + overflow-y: visible; + } +} diff --git a/app/assets/stylesheets/1-config/base.css b/app/assets/stylesheets/1-config/base.css new file mode 100644 index 0000000..052bb21 --- /dev/null +++ b/app/assets/stylesheets/1-config/base.css @@ -0,0 +1,91 @@ +*, *::before, *::after { + box-sizing: border-box; + font-family: inherit; + font-size: inherit; +} + +body { + --color-background: var(--color-cream); + --color-text: var(--color-ash); + + background-color: var(--color-background); + margin: 0; + font-family: var(--font-body); + line-height: 1.5; + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --color-background: var(--color-night); + --color-text: var(--color-dusk); + } +} + +p, h1, h2, h3, h4, h5, h6, ul, ol { + margin: 0; +} + +img, svg { + max-width: 100%; + height: auto; +} + +h1, h2, h3 { + line-height: 1.2; + text-wrap: balance; +} + +h1, h2, h3, h4, h5, h6 { + --color-text: var(--color-black); + + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } +} + +input, textarea, select { + --background: + linear-gradient( + to bottom right, + var(--background-from), + var(--background-to) + ); + --background-from: rgba(0, 0, 0, 0.05); + --background-to: rgba(0, 0, 0, 0.025); + --color-text: var(--color-night); + + background: var(--background); + appearance: none; + padding: var(--spacing-md); + border: none; + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: inherit; + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --background-from: rgba(0, 0, 0, 0.2); + --background-to: rgba(0, 0, 0, 0.15); + --color-text: var(--color-cream); + } + + &:focus { + --background: rgba(0, 0, 0, 0.01); + --color-outline: var(--color-water); + --color-text: var(--color-black); + + outline: 2px solid var(--color-outline); + + @media (prefers-color-scheme: dark) { + --background: rgba(0, 0, 0, 0.3); + --color-text: var(--color-white); + } + } +} + +textarea { + min-height: 30em; + resize: vertical; +} diff --git a/app/assets/stylesheets/1-config/utilities.css b/app/assets/stylesheets/1-config/utilities.css new file mode 100644 index 0000000..a30aa72 --- /dev/null +++ b/app/assets/stylesheets/1-config/utilities.css @@ -0,0 +1,3 @@ +.mt-sm { + margin-top: var(--spacing-sm); +} diff --git a/app/assets/stylesheets/1-config/variables.css b/app/assets/stylesheets/1-config/variables.css new file mode 100644 index 0000000..c0365d7 --- /dev/null +++ b/app/assets/stylesheets/1-config/variables.css @@ -0,0 +1,26 @@ +:root { + --color-white: #fff; + --color-black: #000; + --color-cream: #fafafa; + --color-ash: #6b7079; + --color-dusk: #979fad; + --color-night: #282c34; + --color-water: #00b0ab; + --color-ocean: #007c72; + + --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; + --font-mono: SFMono-Regular, "Consolas", "Liberation Mono", "Menlo", monospace; + + --transition: all 0.2s ease-in-out; + + --radius: 4px; + --radius-lg: 20px; + + --spacing-xs: 0.25rem; + --spacing-sm: 0.5rem; + --spacing-md: 1rem; + --spacing-lg: 2rem; + --spacing-xl: 4rem; +} diff --git a/app/assets/stylesheets/2-components/button.css b/app/assets/stylesheets/2-components/button.css new file mode 100644 index 0000000..398c65b --- /dev/null +++ b/app/assets/stylesheets/2-components/button.css @@ -0,0 +1,46 @@ +.button { + --background: + linear-gradient( + to bottom, + var(--background-from), + var(--background-to) + ); + --background-from: var(--color-water); + --background-to: var(--color-ocean); + --color-outline: var(--color-water); + --color-text: var(--color-cream); + + background: var(--background); + padding: var(--spacing-md); + border: none; + border-radius: var(--radius); + outline: none; + font-weight: 600; + text-align: center; + text-decoration: none; + color: var(--color-text); + cursor: pointer; + + &:focus { + outline: 2px solid var(--color-outline); + } + + &:active { + --background: var(--color-ocean); + } +} + +.button--secondary { + --background: transparent; + --color-text: var(--color-water); + + outline: 2px dotted var(--color-outline); + + &:active { + --background: transparent; + } + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } +} diff --git a/app/assets/stylesheets/2-components/company-grid.css b/app/assets/stylesheets/2-components/company-grid.css new file mode 100644 index 0000000..296879a --- /dev/null +++ b/app/assets/stylesheets/2-components/company-grid.css @@ -0,0 +1,41 @@ +.company-grid { + display: grid; + gap: var(--spacing-md); + padding: 0; + list-style: none; + + @media (min-width: 600px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: 860px) { + grid-template-columns: repeat(3, 1fr); + } +} + +.company-grid__item { + --color-background: var(--color-white); + + background-color: var(--color-background); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + padding: var(--spacing-md); + border-radius: var(--radius); + + @media (prefers-color-scheme: dark) { + --color-background: rgba(0, 0, 0, 0.1); + } +} + +.company-grid__text { + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-size: 0.75rem; +} + +.company-grid__text--alt { + opacity: 0.5; +} diff --git a/app/assets/stylesheets/2-components/container.css b/app/assets/stylesheets/2-components/container.css new file mode 100644 index 0000000..b4de662 --- /dev/null +++ b/app/assets/stylesheets/2-components/container.css @@ -0,0 +1,5 @@ +.container { + max-width: 1152px; + padding-inline: var(--spacing-md); + margin-inline: auto; +} diff --git a/app/assets/stylesheets/2-components/faq.css b/app/assets/stylesheets/2-components/faq.css new file mode 100644 index 0000000..71dc2a0 --- /dev/null +++ b/app/assets/stylesheets/2-components/faq.css @@ -0,0 +1,40 @@ +.faq { + max-width: 768px; + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + margin-inline: auto; + + * + & { + margin-top: var(--spacing-xl); + } + + @media (min-width: 768px) { + align-items: center; + } +} + +.faq__heading { + font-size: 2rem; +} + +.faq__details { + width: 100%; +} + +.faq__details__question { + --color-text: var(--color-night); + + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + cursor: pointer; + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } +} + +.faq__details__answer { + margin-top: var(--spacing-sm); +} diff --git a/app/assets/stylesheets/2-components/footer.css b/app/assets/stylesheets/2-components/footer.css new file mode 100644 index 0000000..f6d745f --- /dev/null +++ b/app/assets/stylesheets/2-components/footer.css @@ -0,0 +1,11 @@ +.footer { + display: flex; + flex-direction: column; + align-items: center; + padding-block: var(--spacing-xl); + text-align: center; +} + +.footer__text { + max-width: 37em; +} diff --git a/app/assets/stylesheets/2-components/header.css b/app/assets/stylesheets/2-components/header.css new file mode 100644 index 0000000..9c7a95f --- /dev/null +++ b/app/assets/stylesheets/2-components/header.css @@ -0,0 +1,23 @@ +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-md); + padding-block: var(--spacing-md); +} + +.header__logo { + --color-text: var(--color-black); + + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-weight: 700; + font-size: 1.125rem; + text-decoration: none; + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-white); + } +} diff --git a/app/assets/stylesheets/2-components/hero.css b/app/assets/stylesheets/2-components/hero.css new file mode 100644 index 0000000..58662db --- /dev/null +++ b/app/assets/stylesheets/2-components/hero.css @@ -0,0 +1,36 @@ +.hero { + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + padding-block: var(--spacing-xl); + + @media (min-width: 768px) { + align-items: center; + text-align: center; + } + + form { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + + @media (min-width: 768px) { + flex-direction: row; + align-items: center; + } + } +} + +.hero--tall { + padding-block: calc(var(--spacing-xl) * 2); +} + +.hero__heading { + font-size: 3rem; +} + +.hero__description { + max-width: 35em; + font-size: 1.125rem; +} diff --git a/app/assets/stylesheets/2-components/logo.css b/app/assets/stylesheets/2-components/logo.css new file mode 100644 index 0000000..23d3bbe --- /dev/null +++ b/app/assets/stylesheets/2-components/logo.css @@ -0,0 +1,18 @@ +.logo { + --color: var(--color-water); + --size: 2.5rem; + + display: block; + width: var(--size); + height: var(--size); + border: 2px solid var(--color); + border-radius: 100%; + + a:is(:hover, :focus) & { + background-color: var(--color); + } + + @media (prefers-reduced-motion: no-preference) { + transition: var(--transition); + } +} diff --git a/app/assets/stylesheets/2-components/nav.css b/app/assets/stylesheets/2-components/nav.css new file mode 100644 index 0000000..633d643 --- /dev/null +++ b/app/assets/stylesheets/2-components/nav.css @@ -0,0 +1,49 @@ +.nav { + display: flex; + gap: var(--spacing-md); + padding: 0; + list-style: none; +} + +.nav__link { + --color-text: var(--color-black); + + position: relative; + text-decoration: none; + color: var(--color-text); + + &::after { + --color-border: transparent; + + content: ""; + display: block; + position: absolute; + bottom: -4px; + left: 0; + right: 0; + border-bottom: 2px solid var(--color-border); + transform: scale3d(0, 1, 1); + transform-origin: left; + pointer-events: none; + } + + &:is(:hover, :focus)::after { + --color-border: var(--color-water); + + transform: scale3d(1, 1, 1); + } + + &:active { + opacity: 0.5; + } + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } + + @media (prefers-reduced-motion: no-preference) { + &::after { + transition: var(--transition); + } + } +} diff --git a/app/assets/stylesheets/2-components/requester-form.css b/app/assets/stylesheets/2-components/requester-form.css new file mode 100644 index 0000000..f4d46ff --- /dev/null +++ b/app/assets/stylesheets/2-components/requester-form.css @@ -0,0 +1,139 @@ +.requester-form { + --background: var(--color-white); + --spacing-fields: calc(var(--spacing-sm) * 3); + + background: var(--background); + display: grid; + grid-template-rows: auto auto; + gap: var(--spacing-lg); + padding: var(--spacing-lg) var(--spacing-md); + border-radius: var(--radius-lg); + box-shadow: 0 7px 10px 0 rgba(0, 0, 0, 0.1); + + @media (prefers-color-scheme: dark) { + --background: + linear-gradient( + to bottom, + var(--background-from), + var(--background-to) + ); + --background-from: rgba(255, 255, 255, 0.05); + --background-to: transparent; + + box-shadow: none; + } + + @media (min-width: 768px) { + grid-template-rows: auto; + grid-template-columns: 0.6fr 0.4fr; + padding: var(--spacing-lg); + } +} + +.requester-form__primary { + display: flex; + flex-direction: column; + gap: var(--spacing-fields); +} + +.requester-form__secondary { + display: flex; + flex-direction: column; + gap: var(--spacing-fields); +} + +.requester-form__field { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.requester-form__label { + font-family: var(--font-mono); + font-size: 0.875rem; + text-transform: uppercase; + color: var(--color-text); + + .requester-form__field:has(:focus) & { + --color-text: var(--color-water); + } +} + +.requester-form__flash { + --color-background: #00ff3433; + --color-border: #00821b; + --color-text: var(--color-night); + + background-color: var(--color-background); + padding: var(--spacing-md); + border: 2px solid var(--color-border); + border-radius: var(--radius); + color: var(--color-text); + + @media (prefers-color-scheme: dark) { + --color-text: var(--color-cream); + } + + @media (prefers-reduced-motion: no-preference) { + --slide-in-height: 50em; + + animation: slide-in 1s ease-in-out; + } +} + +ul.requester-form__flash { + --color-background: #ff000033; + --color-border: #ff0000; + + padding-left: var(--spacing-lg); + list-style: square; +} + +.requester-form__actions { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.requester-form__popover { + --color-background: var(--color-white); + --color-text: var(--color-text); + + background-color: var(--color-background); + max-width: 768px; + max-height: calc(100vh - var(--spacing-xl)); + padding: var(--spacing-lg); + border: none; + border-radius: var(--radius-lg); + color: var(--color-text); + overflow-y: auto; + + @media (prefers-color-scheme: dark) { + --color-background: var(--color-night); + } + + &::backdrop { + --color-background: rgba(0, 0, 0, 0.1); + + background: var(--color-background); + backdrop-filter: blur(2px); + } + + a { + --color-text: var(--color-water); + + color: var(--color-text); + } +} + +.requester-form__popover__inner { + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +} + +.requester-form__popover__section { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} diff --git a/app/assets/stylesheets/2-components/steps-content.css b/app/assets/stylesheets/2-components/steps-content.css new file mode 100644 index 0000000..2a91f36 --- /dev/null +++ b/app/assets/stylesheets/2-components/steps-content.css @@ -0,0 +1,59 @@ +.steps-content { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-lg); + + @media (min-width: 768px) { + text-align: center; + } +} + +.steps-content__heading { + width: 100%; + font-size: 2rem; +} + +.steps-content__steps { + display: grid; + gap: var(--spacing-lg); + padding: 0; + list-style: none; + + @media (min-width: 768px) { + grid-template-columns: repeat(3, 1fr); + } +} + +.steps-content__step { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + + @media (min-width: 768px) { + align-items: center; + } + + &::before { + --size: 3rem; + --color-background: var(--color-water); + --color-text: var(--color-cream); + + content: attr(data-step); + background-color: var(--color-background); + display: flex; + justify-content: center; + align-items: center; + width: var(--size); + height: var(--size); + border-radius: 100%; + font-size: 1.25rem; + font-weight: 800; + color: var(--color-text); + } +} + +.steps-content__title { + font-size: 1.125rem; +} diff --git a/app/components/application_component.rb b/app/components/application_component.rb new file mode 100644 index 0000000..3db4d04 --- /dev/null +++ b/app/components/application_component.rb @@ -0,0 +1,2 @@ +class ApplicationComponent < ViewComponent::Base +end diff --git a/app/components/button_component.html.erb b/app/components/button_component.html.erb new file mode 100644 index 0000000..f86b48d --- /dev/null +++ b/app/components/button_component.html.erb @@ -0,0 +1,7 @@ +<% if @href.present? %> + <%= link_to @text, @href, class: "button #{modifier_class}", **@html %> +<% else %> + <%= button_tag class: "button #{modifier_class}", **@html do %> + <%= @text %> + <% end %> +<% end %> diff --git a/app/components/button_component.rb b/app/components/button_component.rb new file mode 100644 index 0000000..4d8609f --- /dev/null +++ b/app/components/button_component.rb @@ -0,0 +1,23 @@ +# Renders a button-like element, depending on whether an `href` is provided or not. +class ButtonComponent < ApplicationComponent + # Constructor. + # + # @param text [String] The button's text + # @param href [String, nil] The button's URL, if any + # @param variant [Symbol, nil] [:primary, :secondary] + # @param html [Hash] Additional HTML attributes + # @return [ButtonComponent] + def initialize(text:, href: nil, variant: :primary, **html) + @text = text + @href = href + @variant = variant + @html = html + end + + # Outputs a modifier class based on the variant. + # + # @return [String] + def modifier_class + "button--#{@variant}" + end +end diff --git a/app/components/company_grid_component.html.erb b/app/components/company_grid_component.html.erb new file mode 100644 index 0000000..1f4cd5a --- /dev/null +++ b/app/components/company_grid_component.html.erb @@ -0,0 +1,9 @@ + diff --git a/app/components/company_grid_component.rb b/app/components/company_grid_component.rb new file mode 100644 index 0000000..33a0e18 --- /dev/null +++ b/app/components/company_grid_component.rb @@ -0,0 +1,10 @@ +# Renders a grid of companies. +class CompanyGridComponent < ApplicationComponent + # Constructor. + # + # @param companies [Company::ActiveRecord_Relation] + # @return [CompanyGridComponent] + def initialize(companies:) + @companies = companies + end +end diff --git a/app/components/container_component.html.erb b/app/components/container_component.html.erb new file mode 100644 index 0000000..a83d7f5 --- /dev/null +++ b/app/components/container_component.html.erb @@ -0,0 +1,3 @@ +
+ <%= content %> +
diff --git a/app/components/container_component.rb b/app/components/container_component.rb new file mode 100644 index 0000000..0f6e9f2 --- /dev/null +++ b/app/components/container_component.rb @@ -0,0 +1,3 @@ +# Wrapper component to keep inner content within a visual container. +class ContainerComponent < ApplicationComponent +end diff --git a/app/components/faq_component.html.erb b/app/components/faq_component.html.erb new file mode 100644 index 0000000..613c57b --- /dev/null +++ b/app/components/faq_component.html.erb @@ -0,0 +1,12 @@ +
+ <% if @heading.present? %> +

<%= @heading %>

+ <% end %> + + <% @questions.each do |question| %> +
+ <%= question[:question] %> +

<%= question[:answer] %>

+
+ <% end %> +
diff --git a/app/components/faq_component.rb b/app/components/faq_component.rb new file mode 100644 index 0000000..808ba75 --- /dev/null +++ b/app/components/faq_component.rb @@ -0,0 +1,11 @@ +class FaqComponent < ApplicationComponent + # Constructor. + # + # @param questions [Array<{question: String, answer: String}>] + # @param heading [String, nil] Optional heading + # @return [FaqComponent] + def initialize(questions:, heading: nil) + @questions = questions + @heading = heading + end +end diff --git a/app/components/footer_component.html.erb b/app/components/footer_component.html.erb new file mode 100644 index 0000000..fcab35f --- /dev/null +++ b/app/components/footer_component.html.erb @@ -0,0 +1,3 @@ + diff --git a/app/components/footer_component.rb b/app/components/footer_component.rb new file mode 100644 index 0000000..09abdc4 --- /dev/null +++ b/app/components/footer_component.rb @@ -0,0 +1,3 @@ +# Renders the site footer. +class FooterComponent < ApplicationComponent +end diff --git a/app/components/header_component.html.erb b/app/components/header_component.html.erb new file mode 100644 index 0000000..19463c6 --- /dev/null +++ b/app/components/header_component.html.erb @@ -0,0 +1,18 @@ +
+ <%= link_to root_path, class: "header__logo" do %> + <%= render LogoComponent.new %> + Naisho + <% end %> + + <%= + render( + NavComponent.new( + items: [ + {text: t("components.header.nav.about"), url: about_path}, + {text: t("components.header.nav.companies"), url: companies_path}, + {text: t("components.header.nav.github"), url: "https://github.com/nshki/naisho", external: true} + ] + ) + ) + %> +
diff --git a/app/components/header_component.rb b/app/components/header_component.rb new file mode 100644 index 0000000..d892727 --- /dev/null +++ b/app/components/header_component.rb @@ -0,0 +1,3 @@ +# Wrapper for the header. +class HeaderComponent < ApplicationComponent +end diff --git a/app/components/hero_component.html.erb b/app/components/hero_component.html.erb new file mode 100644 index 0000000..72fc3a4 --- /dev/null +++ b/app/components/hero_component.html.erb @@ -0,0 +1,6 @@ +
+

<%= @heading %>

+

<%= @description %>

+ + <%= actions %> +
diff --git a/app/components/hero_component.rb b/app/components/hero_component.rb new file mode 100644 index 0000000..a6a21fe --- /dev/null +++ b/app/components/hero_component.rb @@ -0,0 +1,21 @@ +# Renders a large section that gives the TL;DR of the page. +class HeroComponent < ApplicationComponent + renders_one :actions + + # Constructor. + # + # @param heading [String] + # @param description [String] + # @return [HeroComponent] + def initialize(heading:, description:) + @heading = heading + @description = description + end + + # Calculates a modifier class, if any. + # + # @return [String] + def modifier_class + actions.present? ? "hero--tall" : "" + end +end diff --git a/app/components/logo_component.html.erb b/app/components/logo_component.html.erb new file mode 100644 index 0000000..b31f8cc --- /dev/null +++ b/app/components/logo_component.html.erb @@ -0,0 +1,2 @@ + diff --git a/app/components/logo_component.rb b/app/components/logo_component.rb new file mode 100644 index 0000000..8f21f31 --- /dev/null +++ b/app/components/logo_component.rb @@ -0,0 +1,3 @@ +# Generates the logo for the application. +class LogoComponent < ApplicationComponent +end diff --git a/app/components/nav_component.html.erb b/app/components/nav_component.html.erb new file mode 100644 index 0000000..a66f3f8 --- /dev/null +++ b/app/components/nav_component.html.erb @@ -0,0 +1,14 @@ + diff --git a/app/components/nav_component.rb b/app/components/nav_component.rb new file mode 100644 index 0000000..e0c273f --- /dev/null +++ b/app/components/nav_component.rb @@ -0,0 +1,11 @@ +# Main navigation of the site. +class NavComponent < ApplicationComponent + # Constructor. + # + # @param items [Array<{text: String, url: String, external: Boolean|nil}>] + # List of links to render + # @return [NavComponent] + def initialize(items:) + @items = items + end +end diff --git a/app/components/requester_form_component.html.erb b/app/components/requester_form_component.html.erb new file mode 100644 index 0000000..57cdf8d --- /dev/null +++ b/app/components/requester_form_component.html.erb @@ -0,0 +1,181 @@ +<%= form_with(url: @url, class: "requester-form") do |form| %> +
+
+ <%= + form.label( + :email_subject, + t("components.requester_form.email_subject_label"), + class: "requester-form__label" + ) + %> + <%= + form.text_field( + :email_subject, + required: true, + value: email_subject_value, + class: "requester-form__input" + ) + %> +
+ +
+ <%= + form.label( + :email_body, + t("components.requester_form.email_body_label"), + class: "requester-form__label" + ) + %> + <%= + form.text_area( + :email_body, + required: true, + value: email_body_value, + class: "requester-form__textarea" + ) + %> +
+
+ +
+
+ <%= + form.label( + :smtp_provider, + t("components.requester_form.smtp_provider_label"), + class: "requester-form__label" + ) + %> + <%= + form.select( + :smtp_provider, + smtp_provider_options, + selected: smtp_provider_selected, + class: "requester-form__input" + ) + %> +
+ +
+ <%= + form.label( + :smtp_username, + t("components.requester_form.smtp_username_label"), + class: "requester-form__label" + ) + %> + <%= + form.text_field( + :smtp_username, + required: true, + value: smtp_username_value, + class: "requester-form__input" + ) + %> +
+ +
+ <%= + form.label( + :smtp_password, + t("components.requester_form.smtp_password_label"), + class: "requester-form__label" + ) + %> + <%= + form.password_field( + :smtp_password, + required: true, + class: "requester-form__input" + ) + %> +
+ + <% if show_flash? %> + <% if errors? %> + + <% else %> +

<%= flash_message %>

+ <% end %> + <% end %> + +
+ <%= + render( + ButtonComponent.new( + text: t("components.requester_form.submit"), + type: "submit", + name: nil + ) + ) + %> + + <%= + render( + ButtonComponent.new( + text: t("components.requester_form.help"), + variant: :secondary, + type: "button", + popovertarget: "requester-form-popover" + ) + ) + %> +
+
+<% end %> + +
+
+
+

+ <%= t("components.requester_form.popover.smtp_heading") %> +

+ +

+ <%= t("components.requester_form.popover.smtp_text") %> +

+
+ +
+

+ <%= t("components.requester_form.popover.creds_heading") %> +

+ +

+ <%= t("components.requester_form.popover.creds_text") %> +

+ +
    + <% providers.each do |provider| %> +
  • + <%= link_to provider[:label], provider[:app_password_article_url] %> +
  • + <% end %> +
+
+ +
+

+ <%= t("components.requester_form.popover.tokens_heading") %> +

+ +

+ <%= t("components.requester_form.popover.tokens_text") %> +

+
+ +
+

+ <%= t("components.requester_form.popover.after_heading") %> +

+ +

+ <%= t("components.requester_form.popover.after_text") %> +

+
+
+
diff --git a/app/components/requester_form_component.rb b/app/components/requester_form_component.rb new file mode 100644 index 0000000..21b7f9f --- /dev/null +++ b/app/components/requester_form_component.rb @@ -0,0 +1,105 @@ +# Form that allows the user to request data deletion from various companies. +class RequesterFormComponent < ApplicationComponent + # Constructor. + # + # @param bulk_deletion_request [BulkDeletionRequest, nil] + # Optional, for retaining submitted data + # @param url [String] The URL to submit the form to + # @param flash [ActionDispatch::Flash::FlashHash, nil] Flash, if any + # @param regulation [Hash, nil] See `Region::REGULATIONS` + # @return [RequesterFormComponent] + def initialize(url:, bulk_deletion_request: nil, flash: nil, regulation: nil) + @bulk_deletion_request = bulk_deletion_request + @url = url + @flash = flash + @regulation = regulation + end + + # Gives the email subject field's value. + # + # @return [String] + def email_subject_value + retained_value = @bulk_deletion_request&.params&.dig("email_subject") + i18n_value = I18n.t("components.requester_form.email_subject_value") + retained_value || i18n_value + end + + # Gives the email body field's value. + # + # @return [String] + def email_body_value + retained_value = @bulk_deletion_request&.params&.dig("email_body") + + # Stripping the i18n value to avoid extra whitespace in a textarea. + i18n_value = I18n + .t( + "components.requester_form.email_body_value", + regulation_name: @regulation&.dig(:name), + regulation_code: @regulation&.dig(:id)&.upcase, + region: @regulation&.dig(:region) + ) + .strip + + retained_value || i18n_value + end + + # Gives an array of options for the SMTP provider dropdown. + # + # @return [Array] + def smtp_provider_options + SmtpConfig::PROVIDERS.map { |key, value| [value[:label], key] } + end + + # Gives the SMTP provider field's selected value. + # + # @return [String] + def smtp_provider_selected + retained_value = @bulk_deletion_request&.params&.dig("smtp_provider") + first_provider = SmtpConfig::PROVIDERS.keys.first + retained_value || first_provider + end + + # Gives the SMTP provider field's value, if any. + # + # @return [String, nil] + def smtp_username_value + @bulk_deletion_request&.params&.dig("smtp_username") + end + + # Determines if the flash should be rendered. + # + # @return [Boolean] + def show_flash? + @flash.present? + end + + # Gives the flash message, if any. + # + # @return [String, nil] + def flash_message + @flash[:notice] + end + + # Determines if the flash has errors. + # + # @return [Boolean] + def errors? + @flash[:alert].present? && @flash[:alert].is_a?(Array) + end + + # Fetches errors from the flash. + # + # @return [Array] + def errors + @flash[:alert] + end + + # Gets a list of providers and links to their app password articles. + # + # @return [Array] + def providers + SmtpConfig::PROVIDERS.map do |_key, value| + value.slice(:label, :app_password_article_url) + end + end +end diff --git a/app/components/steps_content_component.html.erb b/app/components/steps_content_component.html.erb new file mode 100644 index 0000000..e2bd0ee --- /dev/null +++ b/app/components/steps_content_component.html.erb @@ -0,0 +1,12 @@ +
+

<%= @heading %>

+ +
    + <% @steps.each.with_index do |step, index| %> +
  1. +

    <%= step[:title] %>

    +

    <%= step[:description] %>

    +
  2. + <% end %> +
+
diff --git a/app/components/steps_content_component.rb b/app/components/steps_content_component.rb new file mode 100644 index 0000000..9277a48 --- /dev/null +++ b/app/components/steps_content_component.rb @@ -0,0 +1,12 @@ +# Renders a list of steps with a title and a description. +class StepsContentComponent < ApplicationComponent + # Constructor. + # + # @param heading [String, nil] Optional heading + # @param steps [Array<{title: String, description: String}>] + # @return [StepsContentComponent] + def initialize(steps:, heading: nil) + @steps = steps + @heading = heading + end +end diff --git a/app/controllers/bulk_deletion_requests_controller.rb b/app/controllers/bulk_deletion_requests_controller.rb new file mode 100644 index 0000000..7391c05 --- /dev/null +++ b/app/controllers/bulk_deletion_requests_controller.rb @@ -0,0 +1,25 @@ +class BulkDeletionRequestsController < ApplicationController + # @route GET /bulk_deletion_requests/new (new_bulk_deletion_request) + def new + @regulation = Region::REGULATIONS.find { |r| r[:id] == params[:regulation] } + redirect_to root_path if @regulation.blank? + end + + # @route POST /bulk_deletion_requests (bulk_deletion_requests) + def create + @bulk_deletion_request = BulkDeletionRequest.new(params) + if @bulk_deletion_request.invalid? + flash.now[:alert] = @bulk_deletion_request.errors + render(:new, status: :unprocessable_entity) && return + end + + begin + @bulk_deletion_request.deliver_emails + rescue Net::SMTPAuthenticationError + flash.now[:alert] = t(".smtp_authentication_alert") + render(:new, status: :unprocessable_entity) && return + end + + redirect_to root_path, notice: t(".notice") + end +end diff --git a/app/controllers/companies_controller.rb b/app/controllers/companies_controller.rb new file mode 100644 index 0000000..fa02f2f --- /dev/null +++ b/app/controllers/companies_controller.rb @@ -0,0 +1,7 @@ +class CompaniesController < ApplicationController + # @route GET /companies (companies) + def index + @companies = Company.all.order(:website) + @last_updated_at = Company.maximum(:updated_at).to_fs(:long) + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb new file mode 100644 index 0000000..23e9a25 --- /dev/null +++ b/app/controllers/pages_controller.rb @@ -0,0 +1,10 @@ +class PagesController < ApplicationController + # @route GET / (root) + def home + @regulations = Region::REGULATIONS + end + + # @route GET /about (about) + def about + end +end diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js deleted file mode 100644 index 5975c07..0000000 --- a/app/javascript/controllers/hello_controller.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - connect() { - this.element.textContent = "Hello World!" - } -} diff --git a/app/mailers/deletion_request_mailer.rb b/app/mailers/deletion_request_mailer.rb new file mode 100644 index 0000000..2f40eac --- /dev/null +++ b/app/mailers/deletion_request_mailer.rb @@ -0,0 +1,23 @@ +class DeletionRequestMailer < ApplicationMailer + # Personal information deletion request email generated from a given `DeletionRequest`. + # + # @param serialized_deletion_request [Hash] + # @return [Mail::Message] + def deletion_request(serialized_deletion_request) + @deletion_request = DeletionRequest.deserialize(serialized_deletion_request) + + delivery_options = { + user_name: @deletion_request.smtp_config.username, + password: @deletion_request.smtp_config.password, + address: @deletion_request.smtp_config.address, + port: @deletion_request.smtp_config.port, + authentication: :login + } + + mail \ + from: @deletion_request.smtp_config.username, + to: @deletion_request.company.email, + subject: @deletion_request.email_subject, + delivery_method_options: delivery_options + end +end diff --git a/app/models/bulk_deletion_request.rb b/app/models/bulk_deletion_request.rb new file mode 100644 index 0000000..bb1b637 --- /dev/null +++ b/app/models/bulk_deletion_request.rb @@ -0,0 +1,73 @@ +# Responsible for handling server requests to send out many deletion request emails. +class BulkDeletionRequest + attr_reader :params, :errors + + # Constructor. + # + # @param params [ActionControler::Parameters] + # @return [BulkDeletionRequest] + def initialize(params = {}) + @params = params + @emails = [] + @errors = nil + + generate_emails + end + + # Check if the bulk deletion request is valid. + # + # @return [Boolean] + def valid? + @errors.blank? + end + + # Check if the bulk deletion request is invalid. + # + # @return [Boolean] + def invalid? + !valid? + end + + # Send out all generated deletion request emails. + # + # @raise [Net::SMTPAuthenticationError] If SMTP authentication fails + # @return [void] + def deliver_emails + @emails.each.with_index do |email, index| + index.zero? ? email.deliver_now : email.deliver_later + end + end + + private + + # Generate a deletion request email for each California data brokers. + # + # @return [void] + def generate_emails + Company.california_data_brokers.find_each.with_index do |company, index| + deletion_request = DeletionRequest.new \ + company: company, + smtp_config: smtp_config, + email_subject: @params[:email_subject], + email_body: @params[:email_body] + + if deletion_request.invalid? + @errors = deletion_request.errors.full_messages + break + end + + serialized_deletion_request = deletion_request.serialize + @emails << DeletionRequestMailer.deletion_request(serialized_deletion_request) + end + end + + # Construct a memoized SMTP configuration object from the given parameters. + # + # @return [SmtpConfig] + def smtp_config + @_smtp_config ||= SmtpConfig.new \ + provider: @params[:smtp_provider], + username: @params[:smtp_username], + password: @params[:smtp_password] + end +end diff --git a/app/models/company.rb b/app/models/company.rb new file mode 100644 index 0000000..c9727cd --- /dev/null +++ b/app/models/company.rb @@ -0,0 +1,62 @@ +# == Schema Information +# +# Table name: companies +# +# id :integer not null, primary key +# category :string not null +# email :string not null +# name :string not null +# website :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_companies_on_category (category) +# index_companies_on_website (website) UNIQUE +# +class Company < ApplicationRecord + include CaliforniaDataBrokersRequestable + include DataBrokersWatchRequestable + + CATEGORIES = { + california_data_broker: "california_data_broker", + data_brokers_watch: "data_brokers_watch" + }.freeze + + validates :category, inclusion: {in: CATEGORIES.values} + validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP} + validates :name, presence: true + validates :website, presence: true, uniqueness: true + + before_save :domainify_website! + before_save :downcase_email! + + # Ensures we're only saving website domains to better enforce uniqueness. + # + # @return [void] + def domainify_website! + hostified_website = URI(website).host || website + self.website = hostified_website.gsub(/^www\./, "") + end + + # Ensures we're normalizing emails before saving. + # + # @return [void] + def downcase_email! + email.downcase! + end + + # Generates a human-readable source from which this company was gathered. + # + # @return [String] + def source + humanized_category = + { + CATEGORIES[:california_data_broker] => "CPPA", + CATEGORIES[:data_brokers_watch] => "DataBrokersWatch.org" + }[category] + + I18n.t("models.company.source", source: humanized_category) + end +end diff --git a/app/models/concerns/company/california_data_brokers_requestable.rb b/app/models/concerns/company/california_data_brokers_requestable.rb new file mode 100644 index 0000000..5785d8c --- /dev/null +++ b/app/models/concerns/company/california_data_brokers_requestable.rb @@ -0,0 +1,35 @@ +module Company::CaliforniaDataBrokersRequestable + require "net/http" + require "csv" + + extend ActiveSupport::Concern + + REGISTRY_CSV_URI = URI("https://cppa.ca.gov/data_broker_registry/registry.csv").freeze + + included do + scope :california_data_brokers, -> { where(category: Company::CATEGORIES[:california_data_broker]) } + end + + class_methods do + # Attempts to fetch and update all registered California data brokers. + # + # @return [void] + def update_california_data_brokers + registry_csv = Net::HTTP.get(REGISTRY_CSV_URI) + + CSV.parse(registry_csv, headers: true, row_sep: "\r\n") do |row| + email = row["Business primary contact email address"] + name = row["Business name"] + website = row["Business primary website"] + next if email.blank? || name.blank? || website.blank? + + company = Company.find_or_initialize_by(email: email) + company.update \ + category: Company::CATEGORIES[:california_data_broker], + name: name, + website: website + rescue ActiveRecord::RecordNotUnique + end + end + end +end diff --git a/app/models/concerns/company/data_brokers_watch_requestable.rb b/app/models/concerns/company/data_brokers_watch_requestable.rb new file mode 100644 index 0000000..d6257f8 --- /dev/null +++ b/app/models/concerns/company/data_brokers_watch_requestable.rb @@ -0,0 +1,33 @@ +module Company::DataBrokersWatchRequestable + require "net/http" + + extend ActiveSupport::Concern + + API_URI = URI("https://databrokerswatch.org/api/databrokers").freeze + + included do + scope :data_brokers_watch, -> { where(category: Company::CATEGORIES[:data_brokers_watch]) } + end + + class_methods do + def update_data_brokers_watch_companies + response = Net::HTTP.get(API_URI) + response_json = JSON.parse(response) + companies = response_json.dig("DataBrokers") + + companies.each do |company| + email = company.dig("Emails").split(";").first + name = company.dig("Company Name") || company.dig("Domain") + website = company.dig("Domain") + next if email.blank? || name.blank? || website.blank? + + company = Company.find_or_initialize_by(email: email) + company.update \ + category: Company::CATEGORIES[:data_brokers_watch], + name: name, + website: website + rescue ActiveRecord::RecordNotUnique + end + end + end +end diff --git a/app/models/concerns/deletion_request/serializable.rb b/app/models/concerns/deletion_request/serializable.rb new file mode 100644 index 0000000..09fc528 --- /dev/null +++ b/app/models/concerns/deletion_request/serializable.rb @@ -0,0 +1,32 @@ +# Responsible for custom serialization and deserialization of `DeletionRequest` objects. +# Opted to write a custom class outside of ActiveJob's support for custom serializers +# to avoid messing with autoload config. +module DeletionRequest::Serializable + extend ActiveSupport::Concern + + class_methods do + # Deserializes the given serialized deletion request. + # + # @param hash [Hash] + # @return [DeletionRequest] + def deserialize(hash) + DeletionRequest.new \ + company: Company.find_by(id: hash.dig("company", "id")), + smtp_config: SmtpConfig.new(hash["smtp_config"]), + email_subject: hash["email_subject"], + email_body: hash["email_body"] + end + end + + # Serializes the deletion request. + # + # @return [Hash] + def serialize + { + "company" => {"id" => company.id}, + "smtp_config" => smtp_config.serializable_hash, + "email_subject" => email_subject, + "email_body" => email_body + } + end +end diff --git a/app/models/concerns/deletion_request/validator.rb b/app/models/concerns/deletion_request/validator.rb new file mode 100644 index 0000000..e04f835 --- /dev/null +++ b/app/models/concerns/deletion_request/validator.rb @@ -0,0 +1,74 @@ +class DeletionRequest::Validator < ActiveModel::Validator + # Validates a `DeletionRequest`. + # + # @param record [DeletionRequest] + # @return [void] + def validate(record) + validate_company_attached(record) + validate_smtp_config_attached(record) + validate_email_subject(record) + validate_email_body(record) + end + + private + + # Validates that a corresponding, valid `Company` object is attached. + # + # @param record [DeletionRequest] + # @return [void] + def validate_company_attached(record) + if !record.company&.is_a?(Company) || record.company.invalid? + record.errors.add(:company, I18n.t("models.deletion_request.company_missing")) + end + end + + # Validates that a corresponding, valid `SmtpConfig` object is attached. + # + # @param record [DeletionRequest] + # @return [void] + def validate_smtp_config_attached(record) + if !record.smtp_config&.is_a?(SmtpConfig) || record.smtp_config.invalid? + record.errors.add(:smtp_config, I18n.t("models.deletion_request.smtp_config_missing")) + end + end + + # Validates that the email subject is present. + # + # @param record [DeletionRequest] + # @return [void] + def validate_email_subject(record) + if record.email_subject.blank? + record.errors.add(:email_subject, I18n.t("models.deletion_request.email_subject_missing")) + end + end + + # Validates that the body has been edited to no longer contain placeholder copy. + # + # @param record [DeletionRequest] + # @return [void] + def validate_email_body(record) + if record.email_body.blank? + record.errors.add(:email_body, I18n.t("models.deletion_request.email_body.missing")) + end + + if record.email_body&.include?(DeletionRequest::TOKENS[:full_name]) + record.errors.add(:email_body, I18n.t("models.deletion_request.email_body.full_name_missing")) + end + + if record.email_body&.include?(DeletionRequest::TOKENS[:full_address]) + record.errors.add(:email_body, I18n.t("models.deletion_request.email_body.full_address_missing")) + end + + if record.email_body&.include?(DeletionRequest::TOKENS[:email_addresses]) + record.errors.add(:email_body, I18n.t("models.deletion_request.email_body.email_addresses_missing")) + end + + if record.email_body&.include?(DeletionRequest::TOKENS[:phone_numbers]) + record.errors.add(:email_body, I18n.t("models.deletion_request.email_body.phone_numbers_missing")) + end + + if record.email_body&.include?(DeletionRequest::TOKENS[:signature_name]) + record.errors.add(:email_body, I18n.t("models.deletion_request.email_body.signature_name_missing")) + end + end +end diff --git a/app/models/concerns/smtp_config/providers_supportable.rb b/app/models/concerns/smtp_config/providers_supportable.rb new file mode 100644 index 0000000..1975658 --- /dev/null +++ b/app/models/concerns/smtp_config/providers_supportable.rb @@ -0,0 +1,63 @@ +module SmtpConfig::ProvidersSupportable + extend ActiveSupport::Concern + + PROVIDERS = + { + # @see https://support.google.com/a/answer/176600 + gmail: { + label: "Gmail", + address: "smtp.gmail.com", + port: 587, + authentication: :login, + app_password_article_url: "https://support.google.com/accounts/answer/185833" + }, + + # @see https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-for-outlook-com-d088b986-291d-42b8-9564-9c414e2aa040 + outlook: { + label: "Outlook", + address: "smtp-mail.outlook.com", + port: 587, + authentication: :login, + app_password_article_url: "https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9" + }, + + # @see https://proton.me/support/smtp-submission + protonmail: { + label: "Proton Mail", + address: "smtp.protonmail.ch", + port: 587, + authentication: :plain, + app_password_article_url: "https://proton.me/support/smtp-submission#setup" + }, + + # @see https://www.fastmail.help/hc/en-us/articles/1500000279921-IMAP-POP-and-SMTP#01H8Q4KWEZ2JK8YS0FP85FM0H7 + fastmail: { + label: "Fastmail", + address: "smtp.fastmail.com", + port: 587, + authentication: :login, + app_password_article_url: "https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords" + } + }.freeze + + # Returns the address of the SMTP provider. + # + # @return [String] + def address + PROVIDERS.dig(provider.to_sym, :address) + end + + # Returns the port of the SMTP provider. + # + # @return [Integer] + def port + PROVIDERS.dig(provider.to_sym, :port) + end + + # Returns the authentication method of the SMTP provider. + # + # @return [Symbol] + def authentication + PROVIDERS.dig(provider.to_sym, :authentication) + end +end diff --git a/app/models/deletion_request.rb b/app/models/deletion_request.rb new file mode 100644 index 0000000..7d078d5 --- /dev/null +++ b/app/models/deletion_request.rb @@ -0,0 +1,31 @@ +# Unpersisted object representing a new personal information deletion request to +# a company. +class DeletionRequest + include ActiveModel::API + include ActiveModel::Attributes + include Serializable + + TOKENS = + { + company_name: "{COMPANY NAME}", + full_name: "[YOUR FULL NAME]", + full_address: "[YOUR FULL ADDRESS]", + email_addresses: "[YOUR EMAIL ADDRESS(ES)]", + phone_numbers: "[YOUR PHONE NUMBER(S)]", + signature_name: "[YOUR SIGNATURE NAME]" + }.freeze + + attr_accessor :company + attr_accessor :smtp_config + attribute :email_subject, :string + attribute :email_body, :string + + validates_with Validator + + # Gives the email body but with applicable tokens interpolated with relevant data. + # + # @return [String] + def interpolated_email_body + email_body.gsub(TOKENS[:company_name], company.name) + end +end diff --git a/app/models/region.rb b/app/models/region.rb new file mode 100644 index 0000000..b07a88e --- /dev/null +++ b/app/models/region.rb @@ -0,0 +1,64 @@ +class Region + REGULATIONS = + [ + { + id: "lgpd", + name: "Brazilian General Data Protection Law", + region: "Brazil" + }, + + { + id: "ccpa", + name: "California Consumer Privacy Act", + region: "California" + }, + + { + id: "pipeda", + name: "Personal Information Protection and Electronic Documents Act", + region: "Canada" + }, + + { + id: "cpa", + name: "Colorado Privacy Act", + region: "Colorado" + }, + + { + id: "ctdpa", + name: "Connecticut Data Privacy Act", + region: "Connecticut" + }, + + { + id: "gdpr", + name: "General Data Protections Regulation", + region: "European Union" + }, + + { + id: "appi", + name: "Act on the Protection of Personal Information", + region: "Japan" + }, + + { + id: "dpa", + name: "Data Protection Act", + region: "UK" + }, + + { + id: "ucpa", + name: "Utah Consumer Privacy Act", + region: "Utah" + }, + + { + id: "vcdpa", + name: "Virginia Consumer Data Protection Act", + region: "Virginia" + } + ].freeze +end diff --git a/app/models/smtp_config.rb b/app/models/smtp_config.rb new file mode 100644 index 0000000..a05c3af --- /dev/null +++ b/app/models/smtp_config.rb @@ -0,0 +1,25 @@ +# Unpersisted object representing SMTP configuration. +class SmtpConfig + include ActiveModel::API + include ActiveModel::Attributes + include ActiveModel::Serialization + include ProvidersSupportable + + attribute :provider, :string + attribute :username, :string + attribute :password, :string + + validates :provider, inclusion: {in: PROVIDERS.keys.map(&:to_s)} + validates :username, :password, presence: true + + # To support `ActiveModel::Serialization`. + # + # @return [Hash] + def attributes + { + "provider" => nil, + "username" => nil, + "password" => nil + } + end +end diff --git a/app/views/bulk_deletion_requests/new.html.erb b/app/views/bulk_deletion_requests/new.html.erb new file mode 100644 index 0000000..c697e4c --- /dev/null +++ b/app/views/bulk_deletion_requests/new.html.erb @@ -0,0 +1,23 @@ +<% content_for(:title, t(".title")) %> + +<%= + render( + HeroComponent.new( + heading: t(".hero.heading", company_count: Company.count), + description: t(".hero.description"), + ) + ) +%> + +<%= turbo_frame_tag "requester_form" do %> + <%= + render( + RequesterFormComponent.new( + bulk_deletion_request: @bulk_deletion_request, + url: bulk_deletion_requests_path, + flash: flash, + regulation: @regulation + ) + ) + %> +<% end %> diff --git a/app/views/companies/index.html.erb b/app/views/companies/index.html.erb new file mode 100644 index 0000000..6f77979 --- /dev/null +++ b/app/views/companies/index.html.erb @@ -0,0 +1,12 @@ +<% content_for(:title, t(".title")) %> + +<%= + render( + HeroComponent.new( + heading: t(".hero.heading"), + description: t(".hero.description", last_updated_at: @last_updated_at) + ) + ) +%> + +<%= render CompanyGridComponent.new(companies: @companies) %> diff --git a/app/views/deletion_request_mailer/deletion_request.text.erb b/app/views/deletion_request_mailer/deletion_request.text.erb new file mode 100644 index 0000000..269a07f --- /dev/null +++ b/app/views/deletion_request_mailer/deletion_request.text.erb @@ -0,0 +1 @@ +<%= @deletion_request.interpolated_email_body %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index cf1bf4f..874bdd0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,7 +1,7 @@ - + - Pidr + <%= content_for?(:title) ? "#{yield(:title)} | Naisho" : "Naisho" %> <%= csrf_meta_tags %> <%= csp_meta_tag %> @@ -11,6 +11,14 @@ - <%= yield %> + <%= render ContainerComponent.new do %> + <%= render HeaderComponent.new %> + +
+ <%= yield %> +
+ + <%= render FooterComponent.new %> + <% end %> diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb deleted file mode 100644 index 3aac900..0000000 --- a/app/views/layouts/mailer.html.erb +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - <%= yield %> - - diff --git a/app/views/pages/about.html.erb b/app/views/pages/about.html.erb new file mode 100644 index 0000000..28c7dc0 --- /dev/null +++ b/app/views/pages/about.html.erb @@ -0,0 +1,32 @@ +<% content_for(:title, t(".title")) %> + +<%= + render( + HeroComponent.new( + heading: t(".hero.heading"), + description: t(".hero.description"), + ) + ) +%> + +<%= + render( + StepsContentComponent.new( + heading: t(".steps.heading"), + steps: [ + {title: t(".steps.one.title"), description: t(".steps.one.description")}, + {title: t(".steps.two.title"), description: t(".steps.two.description")}, + {title: t(".steps.three.title"), description: t(".steps.three.description")} + ] + ) + ) +%> + +<%= + render( + FaqComponent.new( + heading: t(".faq.heading"), + questions: t(".faq.questions") + ) + ) +%> diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb new file mode 100644 index 0000000..d96c4a9 --- /dev/null +++ b/app/views/pages/home.html.erb @@ -0,0 +1,23 @@ +<%= + render( + HeroComponent.new( + heading: t(".hero.heading"), + description: t(".hero.description"), + ) + ) do |hero| +%> + <% hero.with_actions do %> + <%= form_with url: new_bulk_deletion_request_path, method: :get, class: "mt-sm" do |form| %> +

<%= t(".hero.region") %>

+ + + + <%= render(ButtonComponent.new(text: t(".hero.button"), type: "submit", name: nil)) %> + <% end %> + <% end %> +<% end %> diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 67ef493..a8de834 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,8 +1,10 @@ #!/bin/bash -e -# If running the rails server then create or migrate existing database +# If running the Rails server then create or migrate existing database. Also +# sync companies from sources. if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then ./bin/rails db:prepare + ./bin/rake sync_companies fi exec "${@}" diff --git a/config/application.rb b/config/application.rb index 6bc6057..6c549de 100644 --- a/config/application.rb +++ b/config/application.rb @@ -23,5 +23,16 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + + config.active_job.queue_adapter = :litejob + + # Redact `BulkDeletionRequestController` parameters from logs. + config.filter_parameters += %i[ + email_subject + email_body + smtp_provider + smtp_username + smtp_password + ] end end diff --git a/config/dockerfile.yml b/config/dockerfile.yml new file mode 100644 index 0000000..223e505 --- /dev/null +++ b/config/dockerfile.yml @@ -0,0 +1,6 @@ +# generated by dockerfile-rails + +--- +options: + label: + fly_launch_runtime: rails diff --git a/config/environments/development.rb b/config/environments/development.rb index 2e7fb48..6d25247 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -36,10 +36,8 @@ # Store uploaded files on the local file system (see config/storage.yml for options). config.active_storage.service = :local - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false - config.action_mailer.perform_caching = false + config.action_mailer.delivery_method = :smtp # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log diff --git a/config/environments/production.rb b/config/environments/production.rb index 96dd0d1..2b59008 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -72,6 +72,7 @@ # config.active_job.queue_name_prefix = "pidr_production" config.action_mailer.perform_caching = false + config.action_mailer.delivery_method = :smtp # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c349ae..521b717 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -28,4 +28,149 @@ # enabled: "ON" en: - hello: "Hello world" + components: + header: + nav: + about: "About" + github: "GitHub →" + + requester_form: + smtp_provider_label: "SMTP provider" + smtp_username_label: "SMTP username" + smtp_password_label: "SMTP password" + email_subject_label: "Email subject" + email_subject_value: "Personal information deletion request" + email_body_label: "Email body" + + # The double newlines are intentional because of the way underlying helpers parse + # the email body value. + email_body_value: > + Dear {COMPANY NAME}, + + + I am writing to formally request the deletion of my personally identifiable information as per my rights under the %{regulation_name} (%{regulation_code}, %{region}). I am a resident and, as such, I am entitled to request the deletion of my personal data that you may have collected, processed, or sold. + + + Please find below the details necessary to identify and locate my information in your database: + + + • Full Name: [YOUR FULL NAME] + + • Address: [YOUR FULL ADDRESS] + + • Email Address(es): [YOUR EMAIL ADDRESS(ES)] + + • Phone Number(s): [YOUR PHONE NUMBER(S)] + + • Any other relevant information you may have associated with my profile + + + Under the %{regulation_code}, the above information can only be used for verification purposes and you may not collect it. + + + I kindly request that you promptly delete all of my personal information from your records and confirm in writing once this has been completed. Please also provide details on the steps taken to ensure that my data has been permanently removed from your systems and any third parties with whom you may have shared it. + + + I expect full compliance with this request within the time frame stipulated by %{region} law. Failure to do so may result in further action being taken to protect my privacy rights. + + + Thank you for your prompt attention to this matter. I look forward to receiving confirmation of the deletion of my personal information. + + + Sincerely, + + [YOUR SIGNATURE NAME] + submit: "Send to all" + help: "Help, I'm confused" + popover: + smtp_heading: "What's SMTP?" + smtp_text: "SMTP stands for Simple Mail Transfer Protocol. It's a standard for sending emails over the internet. Naisho uses SMTP to send your emails directly from your email provider so that you don't have to sign up using your email account." + creds_heading: "How do I get my SMTP credentials?" + creds_text: "That depends on your email provider, but in general, it's your email/password combination. It's best practice to generate an app password that's separate from your regular password for one-time use. Here are help articles for each provider we support:" + tokens_heading: "What are these {curly braces} and [brackets] in the email body?" + tokens_text: "These are placeholders. The curly braces will automatically be replaced by our system when sending emails out, and the brackets are where you should fill in your verification information." + after_heading: "What happens after I send all these emails?" + after_text: "You may or may not get a response from the data brokers. Unfortunately, it's up to them to comply with your request. If you don't hear back, you can follow up with them directly or file a complaint with your region's relevant government body." + + footer: + text: "Naisho is a free and open-source tool that helps you exercise your privacy rights. It doesn't track, collect, or store any data from you." + + models: + deletion_request: + email_subject_missing: "is missing" + email_body: + missing: "is missing" + full_name_missing: "still has a full name placeholder" + full_address_missing: "still has a full address placeholder" + email_addresses_missing: "still has an email address placeholder" + phone_numbers_missing: "still has a phone number placeholder" + signature_name_missing: "still has a signature name placeholder" + company_missing: "is missing or invalid" + smtp_config_missing: "is missing or invalid" + + company: + source: "Sourced from %{source}" + + pages: + home: + hero: + heading: "Exercise your privacy rights" + description: "Send personal data deletion request emails to hundreds of data brokers at once. No signups, no tracking." + region: "I'm a resident of" + region_placeholder: "Choose..." + button: "Get started" + + about: + title: "About" + + hero: + heading: "About" + description: "Naisho is a free and open-source tool that helps you exercise your privacy rights. It doesn't track, collect, or store any data from you." + + steps: + heading: "How it works" + one: + title: "Draft your email" + description: "Use a provided template to draft an email requesting the deletion of your personal information." + two: + title: "Email all data brokers" + description: "Send your email to all data brokers at once through your own email provider using SMTP." + three: + title: "Wait for responses" + description: "All responses will be sent directly to your email inbox. Any further communications are managed entirely by you." + + faq: + heading: "FAQ" + questions: + - question: "What are data brokers?" + answer: "Data brokers are companies that collect, analyze, and sell personal information about individuals to other companies, often without their knowledge or consent." + - question: "How does this service source data brokers?" + answer: "Naisho uses the data broker list from the California Privacy Protection Agency (CPPA) as well as DataBrokersWatch.org." + - question: "How can I trust this service?" + answer: "Naisho is completely open-source. The site you are using right now is operated by the same code you can find on our GitHub repository." + - question: "Can I run this service myself? Can I self-host this?" + answer: "Yes and yes! You can find the source code and instructions on running the app locally or self-hosting on GitHub." + - question: "The email template has placeholders for personal information. What if I don't want to give that out?" + answer: "The email template includes language to explicitly prohibit the collection of included personal information, but you are free to edit the email as you see fit." + - question: "What if I don't get a response from a data broker?" + answer: "Unfortunately, it's up to the data broker to comply with your request. If you don't hear back, you can follow up with them directly or file a complaint with your region's relevant government body." + - question: "I want to help. How can I contribute?" + answer: "Naisho is open-source and contributions are welcome. You can find the source code and join the discussion on GitHub." + + bulk_deletion_requests: + new: + title: "New bulk deletion request" + hero: + heading: "Ask %{company_count} data brokers to delete your data" + description: "Draft an email below and send it to all data brokers at once through your own email provider. Nothing is stored on our servers." + + create: + notice: "Deletion requests being sent through your SMTP provider. Check your email outbox for confirmation." + smtp_authentication_alert: "SMTP authentication failed. Please check your SMTP username and password." + + companies: + index: + title: "Companies" + hero: + heading: "Companies" + description: "This is the full list of companies in our database. We source from the California Privacy Protection Agency (CPPA) as well as DataBrokersWatch.org. Last updated: %{last_updated_at} UTC." diff --git a/config/routes.rb b/config/routes.rb index 9f768e5..fd25814 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,10 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + root "pages#home" + get "about" => "pages#about" - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. - get "up" => "rails/health#show", :as => :rails_health_check + resources :bulk_deletion_requests, only: [:new, :create] + resources :companies, only: [:index] - # Defines the root path route ("/") - # root "posts#index" + # Healthcheck + get "up" => "rails/health#show", :as => :rails_health_check end diff --git a/db/migrate/20240606193101_create_companies.rb b/db/migrate/20240606193101_create_companies.rb new file mode 100644 index 0000000..ce20c36 --- /dev/null +++ b/db/migrate/20240606193101_create_companies.rb @@ -0,0 +1,11 @@ +class CreateCompanies < ActiveRecord::Migration[7.1] + def change + create_table :companies do |t| + t.string :name, null: false + t.string :email, null: false + t.string :website, null: false, index: {unique: true} + t.string :category, null: false, index: true + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..5214a11 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,25 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.1].define(version: 2024_06_06_193101) do + create_table "companies", force: :cascade do |t| + t.string "name", null: false + t.string "email", null: false + t.string "website", null: false + t.string "category", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["category"], name: "index_companies_on_category" + t.index ["website"], name: "index_companies_on_website", unique: true + end + +end diff --git a/lib/tasks/auto_annotate_models.rake b/lib/tasks/auto_annotate_models.rake new file mode 100644 index 0000000..60381ab --- /dev/null +++ b/lib/tasks/auto_annotate_models.rake @@ -0,0 +1,59 @@ +# NOTE: only doing this in development as some production environments (Heroku) +# NOTE: are sensitive to local FS writes, and besides -- it's just not proper +# NOTE: to have a dev-mode tool do its thing in production. +if Rails.env.development? + require "annotate" + task :set_annotation_options do + # You can override any of these by setting an environment variable of the + # same name. + Annotate.set_defaults( + "active_admin" => "false", + "additional_file_patterns" => [], + "routes" => "false", + "models" => "true", + "position_in_routes" => "before", + "position_in_class" => "before", + "position_in_test" => "before", + "position_in_fixture" => "before", + "position_in_factory" => "before", + "position_in_serializer" => "before", + "show_foreign_keys" => "true", + "show_complete_foreign_keys" => "false", + "show_indexes" => "true", + "simple_indexes" => "false", + "model_dir" => "app/models", + "root_dir" => "", + "include_version" => "false", + "require" => "", + "exclude_tests" => "false", + "exclude_fixtures" => "false", + "exclude_factories" => "false", + "exclude_serializers" => "false", + "exclude_scaffolds" => "true", + "exclude_controllers" => "true", + "exclude_helpers" => "true", + "exclude_sti_subclasses" => "false", + "ignore_model_sub_dir" => "false", + "ignore_columns" => nil, + "ignore_routes" => nil, + "ignore_unknown_models" => "false", + "hide_limit_column_types" => "integer,bigint,boolean", + "hide_default_column_types" => "json,jsonb,hstore", + "skip_on_db_migrate" => "false", + "format_bare" => "true", + "format_rdoc" => "false", + "format_yard" => "false", + "format_markdown" => "false", + "sort" => "false", + "force" => "false", + "frozen" => "false", + "classified_sort" => "true", + "trace" => "false", + "wrapper_open" => nil, + "wrapper_close" => nil, + "with_comment" => "true" + ) + end + + Annotate.load_tasks +end diff --git a/lib/tasks/sync_companies.rake b/lib/tasks/sync_companies.rake new file mode 100644 index 0000000..07d77e9 --- /dev/null +++ b/lib/tasks/sync_companies.rake @@ -0,0 +1,6 @@ +# Runs the relevant methods to update the companies table with the latest data from +# the California Privacy Protection Agency (CPPA) and DataBrokersWatch.org. +task sync_companies: :environment do + Company.update_california_data_brokers + Company.update_data_brokers_watch_companies +end diff --git a/public/favicon.ico b/public/favicon.ico index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d4e021811aa430365306b78e84b4c31b94015cbf 100644 GIT binary patch literal 177709 zcmeEP2S5}@8(oTk1;yS_u=f^?T|vbjTP(4~uCd2n5|zUilGu9}JE90E_ICQ)OKh<> z)Yzkv15RLvQpkyL-Dc-}mO5K07N(7E(&d!9jxWEcLXOq&bo#Wy_{N z_e?2CVYue(tUve0eG}74Qt8t2c>zf(=_E<6uKM%BH6`hSnudeQ?-g-A78nim1bP6!022W>zyc8AFpTvZKZc3x zO#x4!DC7*k&u^2sCl9W>1Dya%Kw1~@$+O?!+8;nh@taCk>MUPN1>Xap9LhENRsiKK zt6X1?uob7`h4%fDd^HF5g>-#J2Bd5yqWpF$!vS*dBRC;vB zy-XM7`O?bs8*nW$r@EhM!n9SM&yHj2lu}$%>8^yOx|iIK0caoM`SpGxt%l>gnsSZ4 z;#6|;{$jv5K+n}j84BUrWWWg!;Zm3NTf=4KHKr}?gEl7aA=g`Xh2P_X>r(+&K(CAb zJ{z2W3rqrrJ{pjO2Y1`89aA>>av=qR! zp?%QC!sAgts~oS}@bP!`y`LN#*jA9OBx$Wsp6H7Z5tM~SlB~3nWE-Rhk9kdTyqC}L zS(Y;>>ZH;D%OBxT=9!8M&o?UWKH)>T4qjLuMAz-z`zhB*S8Ifu52y!p2D$=`fuevN z5T15>w4aY9{C<7jV7^!g>;%>U3xK)68eoU)H-S$(74gu{B6G?;@Y4Xyi@Shc0P~KG zZm1*)JM|MfWy7`BKp-$1;JJunH#jhK%5U&l-cJW8Z+;+L|L|jS{_%Qd9M1%N0NTFz zO&vb-DzD3Vi22G7$iizIcH=h#+vc< z_$;S(18j?gyA4Vk!(SSX>(f@R6Z%N%JQ9Ah!GS;WK)@FL{dl%7umI($l{$Q1JwN0q z55V|^4!q915t(BcDin~PWjc!M>gQDXq@8>p%U|{L>f1lLj{BHqEU!kREovc`fsx%$ z-39?HCu*@ zcG*s=0x+MmEXfcau3W}_3?X_)2t$;!!v9pSaC!NgZw%`9=*1lUyL3986e_t$!BRTF zRtl1=BrTw+Y9VRH06GiFL+2rl)9nj~2ab7-zwsWeBY$*|cDa|T?2 z@OGF3PRuUG4EV_HXan2*#FoNs4K~aBqAI_lE5HVW_PboBTp%02Ki0gP7*I0DipxlL;rui-iGRe^QVY=E{=hS!-lB6G^`@M+hV0oIYC zERTsDtMd(bArEYSZ2wpvt*U<4A!1US$OeG)eE63Hz;WBLYQCL1O zpGI)J2IK*lXTIk-%iXv-rR+v{Y$q~)mXi@2uK|^Tn?O09`|ONxTdXcHEFe8_66nu! zL&s}CU4UgPWsNt-umGm57GQl5!SNbU2Vnfn1M%h<7Qnp6HmT_68U`-kQyxC!XB$@X zhyBKflf%vcpZxdYxuKKqafE*XXwUOCzJ_4wQS~v))_-u=6 zwm=1-5WsxGxuQnEB;Yha&VB_bzoe7r9E|ZD*)_k994I!L~t8Ay!(K zP%Eu_n3YziQ!s9yPA7nCier84gZ8XicJ}cw#$yS&tq2}(`#ATl zzgMn@KG*I@C_UAj`UVR5GvONRn@+%ZU^&3C=}mw)FdgWRdsr_yW9)@G~JPD34erShyx&ds9QEtjI8u4>XFpY8_u6^WGb&NDif!~|}w)NPqq8|SN zvw&QDo*c!dZ(>Ym)%X+JsqCw=y-OQ-26zI6*0TL0%P7*vNc)O5w;uVNcBkA6pJNXl z0k%!a1>5D{0z&@y&_UH^W;=Bypm6>@${mZ?#Kkhpf16$pp|5^i!cPvHZD1EA9qGr` zQ8rnA^5i9le;9rR@teYVEbWzPvHO4F>5&Ox&HzGzi-72pp2~#ZBk31LrI*YLZHDC> z%NO!~3MdAI+dx!dMCUig6Fwi{C))Wl<+u*P&#lB2ov_sRtb$F;X~@3zbAaVcO{T}% zXpT9n!~5K^kP~4eO)}#+03ctpkq2z3BlBT&T{s{5IoN{;=N-_^TbA1g^;JSR=5fSB zaBSgkfaTJ6~9s1(g-T-;v*pt2w7Xu!mAEW+G*1=+JfIe=P(JWiH0QP`% z;zaa9O0ko|Mn?pbHjZq^GM z1UIoM2V$d%sy+B4;%E9Vz_^)Jbm{|+uWdpdj%QdO@>$xD;3PJ(#O4{wDaTn5{y)HF zz>(L2f}#$Q_W%w6%lW53Ss>9JbpY5_*#yvr+*tPIs~5E>locofJOqxQJfkj&?zrAs zZfmg3M?3hL=by77$_lsv)Y%t0+feVscB=K_%mZP-^aH4?sY6tbc^CKr_fzNjfEe42 z>Y0T44RMpp1^~+uZ@EtwWpwg0o#0dci9BchmlRHxr2>4~!e;Pl&1+FP`I!#zDgPgA zD}K)Aq6#lAzsa&xh0lK6M%a!OuSMnLXIj9g%`J>hnne{}Tz->fVLinD=PJ}qmY=hK z`5Ef`64-$IlO+GL%#da22%om#skCdN;zWKX5ByueS%j07*Am;Y9Fgbg*`KET-FfYE zYtw|@`gZs>JVU!4&`mqA)J)ZfOYh@D4 zQFg(ZevXY}GVIH+4=8axe^gF2U&($n+YiS8&Ru@C?uaVf=>A5oDE}&b8)zKjC5L0Y z!QaMcjyuwZkZ;-cSR+5*n_+q*%!GC-?%9U939#Q+5{UME74MZ%8$Q$T3?TZVw5^14 zjFa-?Ju=q$lL2x+1%1aDwE*4<^Nn692@=Qazo68BChWN_M6yl zF39-c$H?)18Ek`}1ww&g;KD+-uNZ|F)y*8&;vAq{Zm_*@9bnnlg>iAsU`(AKp4rT` zT6*2NHs}$+a;!7P+eAIcu;c2K^yC~g!Y%=@&&u?kiZahOw(?Uhtf>%b;JsG(T@WVa zXMPw3*fZ>P921O2ztm7q!L?jg>-1xB^$;f0nX<3I?->-`44;k3xsXwxhRj2q0j2}v zScc#7%e*PJ7OkK~m>=!a9%1!Wg}VsH`tmv!c4JHcB|dNcdk?fFmPgd#A}|DSRF!+- zx^T=%tXX6_eUw)mzvbP4EmG_14S6JiQR$_!9M? zJ{qKV4fq^)u|>YI;&|6u+O>Y1HwAtt*DT9_0@$y84e)u6JAcP924ORlKLP4{zTdl5 zKQ_U9ZHqYT0&9V%0Og=Or-1-q2`~wm2+Re1fc=1Cf8?BbwKpK_g>^5>i-dH12aItL zh?ihJ%`(pe*bdwTn8!JGMx7K;w+FycU_R`m8T`yj{$cw7=ZWT+z8kiO>mbq_hyyo$%?)P>x<~~O?Kfsg-Gt$ zOG9$uD^qwE3n9?A!15#~NeV%zLG*3mr-PqPrwfK33_pav6MVey9_*sig+}6s$=4(E zKY0$}iE|}fIX)#`A%`p%<)fS$mydEoiBRgJ<5DY~USHvBWxc_J-~+s*Z+;C30yy4q3}F9u2JY<#)Wma+>(}YWC>h>bzH5O# ziYYqwvBf(ZkaHFKfsmJD5d#6vcd&oUaX+Qc8y){S?mrK3y+VD2mqz3d#Ix^!ehkeN zcwnCPQ}xjyE62w-h@jw}F!Y#{*UZT&MXPumIQqaJ+@j{R?oOQF)$R_=AJ* zVH5VMF@_|ifaA9WZ4+se6*3M5t{BM8v6hW$HEE#qk*e{!oxir568G1%TdV>DwL>&_64v~rd3i{jW5_y=HITsyG_ z$Oq`h-ry&+Q_>1)pAzwMjF{uQl!s$`*1G`!Wn%gJ3*O1K zA_uIW;_5^=hzs>R%e%#LJZ#Gi->@F>Bv5{fj@-&TDmW+tdDwm?2ls*MfaHgMR9qdy zk-_#0+hL4@ZTK`mqSm{Hx`;CIqkMHB&qILn`JtRhOZk{zV(XZ;vK^&ESXo^yNaENArQr?gA4n!Xnd235N z*jH?2Q$I&pgq(ij9TKJey%Ep9q!zP*LtOv#Z0--SZv zUmz#zgJ(c3+UeqzF_%=?iwF9aKsa{*>i_$))$voV#$2MPpMY$lj)J}{3l(`MVQraZ zY}VOqM4v^TduCgQd@@fp0^+4!iCUZ3{Yu$@)W9Zy>2(qiYXD*zBA#P=lkMCU0K>Wj z8_PvIGgZg^ch>03tdX+JTt86-h;doPXJGqmTPW?F! z(vA=AkD1hXj{28{&-Z6_u!R9EOXAv}i;0Iodk2YnD1c~9zqRrwbrGu>O7gK+(?@zFca#xzT`KtN0qVaW zx~Gqo{`$3zEc-d$p|Ag9SdSz&VXFFXkF5V{T(jf#m^eI3`)8h4#@okmKbEMzqY#tu z6XITF&s)fx4`9A$`g<dXidoFDzng7Sv~^kc&y{7S?Q-7ev+su) z#=4Ys2spsA?DM_^!oVZv5b;3xJixr$?kyAAn3%sZP(UyyxrmT{1c zNjx3|SwtEkEtpTWvW#3q!f`;s zOKj3!d4^%g$P523fZU!3$^eo-@>XC1?VF>>5Sh>TIUWaaT#D%vDr4YEjMMVB_;6xQ zS)a$+BYb5ZU;Y-xo^&4mi)ghHHWlHjn}`bVPhN0qXt=7>xbLtf@ECKOr2$Wj}&4AU?K*{|7LB z;vnL#Ce|%c1{racL+sT}y%C;(e&3*R2%r5@#!LSflu^u^B2UCM&ZwSeov*5I3n5M= zFHn{n;HVGy;o9DC4k%Y_9G^p(Bf`Pj6KkAT1bhJ69OGl3q$loS`7iE^O*}Duh8&1* z2I}Wpaw6_&fKo5K!!g%9b^r(Zcda3t-~;Ix)m~wIS8)#4MC$7rp%1v`I!D&EGl6>m z^(HvC)d+19F}9E6M0Ffz+j$w~EI5$s1=fvc07X9bKX*azUI5oWr{O#8lru^oZ-HC( z;nQGUqv8*m*^9z&< z6=(-E04gER7RIxAk!P5`HG$^fiE|F@zn%pYn_^fzW*ALyza8$A0?_Ux_R=A-57N9Oq0FEoO?tTO?UDSx=J#wT?Wd-W+FOnZ|s?H5bDW#y8+I zC43R?W_;tuHx6rh5`!L&FEBXT?fKj+U7(VTuV=X12 zYZmNJV8{JK_&mY=8)G_#ml(Q66^9SnOWuF$?Lu%tKB57pHOmtpw((3nt)tT4{F|8p zGXrJ@%nX<=1NwagqV-pcjR)Ywh7It8xj_M(mKzqpQAc?AC=EXw!`ryCgAO0L4Z#iu zxZPdH2ZC@jzbB*RF?1uEa0Pqn8Wv33)Zp)gAFsXBWLb8u-+Y`i77LL_oa- z7YHnfI`bMgxZpjKZII+7^C-dqpYA#Y03RNC7czu!g9tc&4Tyfs9QTQ+2TTLP_lanR z6SFfjU}nI~fSCa^17-$d#Q^6l_+2q&Oo)Ad^*8{J6^P$Bc7(qFm+Fk}2|}F%#Czmi zqwEY60vE-A5;*3!K=WffBqzp7?HAtqls|IicL>)$vv;*A^C`x8Fs2DT_47&`L*$yB z0HJ#eT-V@y8Nl&FuIcy(;MnPNfMbO>0gkf<0JCs^U%&<7X2;&U97h*pPTpK20VF5K zadq*HU!=1Rja1kH*C245&=(*N94|E-NB!J6=Z-iAdm5Mw)CE%0mP8sLPE&Ur_udF_ zoN&rwQDzi`4!;2B0M0k4IS9i!dEz+x1>i8S6`;LvZ6j^x8o)LF#>S<|l`pty3Z!G4 z%6x~Z@x(pgSIcukVt-qd8<2fCzIt&9Z7-%WsXeM-2T(+C9Wp{%lnFKxvfz~(r-e;S|sa_qcscfHMEtsv)8 z8vzFZ&O0bNFi%Z~zSJ`t_7vuK5cOl$NOxQlX)o42aV}H77VhJBU(8{m?BQH=aiBA> z3*cO!630^G=yuV5|u1VqA>CAx8g=JEFJK7(?IAptY#qkk9;fS`|2X-sI_d=Ny!?BFu z_b*vrKrYVvZCA;}ywwkKhucg-%Y^s@N5ZD`X|(~@l(LU&=lWp}@9?~x(DJOuJLzJ7 z0sS2DI>fJ}*A*OpkNs9ezGvDdmSbF7uzpOWnSR~(Ovv&^mhB1Bu`|}ViTBAjV{S8{ zoSa6S?@fugSy!+PLw@c+h6c2A$}08_OXT@cuKSVspy&Y4&J&gWctaro>1u%op2$SlHao8m~BnPzW1XZai)yiPbj_}(?-_6G~&E0 z$NLUx<$*fbhIx+tP*ZTCKMWoqZ(+!JOvTX*!WVVGCzUOz4e5RQLp%LD^Xv`dt5a{`0V%4W?3E=7jd!NNdTLT zi|nY^^=rT?2rjt3jBPT?y&1APu&ktBadiww>}QI;5yJM7<6vAP7veX*e`KnT`AEo* z?|?(zfVvKgm)ewKbHX7YqH>nT9XIZ3-ds4SqJJd9^Vd(@%|%f7Po;Pc`u^& z2u~SHSb=__E%RJ*ajfr|o#Z?Hk(?ag zRYco|?dt!@x-gG%?2QW~V&z!&BX6Zg7+N_@w$sZoOd>fs?(ONfOUV7ZtP9(=y;=X{ zFBdya$N|cDwn51Y?T0*xazAzC8}hGctH&z9Bz}l} z41n#qeX>6HQ8pKjZTXBaSRW38uZ*1x=U$qz&96yJk5c|PLZ6c|KUbh1zr7JFM}B}- z0LM&dKfmKWJbvRP**T@&g$`UZ!9F(i(IP)&W}b+R!}H{)BJeN3@Kz!$EB{!A7aJMk z>lttK7a>1wpM5UQ(@`I9jDN&<4>3h1h6m6dmGSt~2rmc6R7_#mV!pX>FC582ni zPMDt%F5A+|nutvpdlhrbT(e<{ZsBry?n1xch%)bc0XndaC{eHYaH5RX^TTgD;5s&?>}2^ccoE8Lt{0$Q@#WZO=NvrpS!=|>b`Uwb3AnPo zxE1S;l4`#>Lmk;il)2$Lwn>0eZtjEp*;sB;=J<3h%McIaWqWY}Kz<0$H5Xw!Nop3K zF~%+|+61`?+$Q>o{UP5gS?7cB6hjmTNLcEVEhX=U_Rs zfieP#Y|NmBMaVw_cTHV|}#*Hst_6!N+ENvF4e1&G)ECt7eE(X;+h@tw3qY zOIeA;a7tW=n|<-#@Rj)++O|K^EC=mM!aQkwt{aO?H^#+0j`iV|h`%GCw7V$JImp|1 zjX%;6_pO_qa59PUK&A!K3vsfZ;#j#--cXj!KmqaxyGqpeplCycKff8qy}R`9+E6Cq z0k|2AHAtDL1Lb317uVy(F>MHjhqPiEl?6EFuC&cqU#-MFx#5fN=<@!=cA`#${u|~B zSzREDy3A%ja5vBf{OI@DgpB0M2mQ8~+W9*D8Yev`2qVx>q)SnR{~K@2pVm_okIi$q6zK2TlP>`;|Nq)Q58jtAMEhZJ{U7 z5@6j-eg^{Mfw=(t2R8t|W2uBe{|T@bXaE~#A5^q|z>~>3%E+`=@&|mDk*srf0FME+ zeul?k_?5#d;D=7U2<_;Kf4k(PM*a2KfI?6eOv*qMfL&y1|9+I z%Ng1Q`5;dm$6~+x4#0I^bAYZ0vjEB^Yq2JqZ3q*#m(Qga`#Yj6k@eWH44h#+68SJ2 zbS(|A-0lxBkFhSAgyXUBnK!!tHGzD%mVPb2yRMYo@a3`1&xw#MerLJqvjE^&uv|_- zZ+)2_4IljIpF^F;{dCRF%z&8zGXrJ@%nXK_8ArgeH!; zVWx|GJp{+NNn8)YU+fDhjy3p;JtM_2{>ZOHh^-&F`y(%e;b!b1DUL(nhawDafgAk7{*h(C0xv3;Js^oQz6OTEpC?8`ZOCF02C$ZyY_%W`C zW899&c&y(KQ>PKfj0zE}j9E`t6Z*i=)Ltb&0uOmc?B<_Jv7mvr>hqM!*1;Y0s zorV*$Gc#ajz|4S|0W$+;2FwhY889wz=k8JJSS3t^&$6#8&e=wK3#escS+x`Gq8fX4iZ#O0rDXQbKKT%P4TD3p;T@sR z4B&-5LH*z$Kg#N)_Alx+}j9ANuZoesB51tF`U6VXvWWPGI7l}*Y&E?fOXM$ z@SlNckdU?!A9^cx!hS1sEDhOd-)en$tOcsG-$m|;q;#VHr=Yabx(GYrR*@I6mEwo6g|CYpm?i@wsvZKA!#aYvSk zid|fQy;Nr2<+zxsm-n9|4R$ZeA&iai+l?Hv;MldIE60oKVDHYvAaka(R z3nV4?1NoAjD2w#trkp=g>VYTVqb1+h_mAbCwb9{>-;82=AL-9=&C{y*gHYao!!nXK z@FhFy$h9lb{YOCE-{JUBI@Wt}{kB$Q{GC39{bKZce04_LoHtR@e-h%gVccK3rMentXt(CH8!MZ@yyI**@Z^$S40CIr`&LJyY+(Q{$AR+jtePg`O z260VN#mBMP-W=mud)QFIFZ+BO)?IAdEc*XmDn8g3XvOc_$9G?hZGNP8TKIcZ@m)i@ zRAk!tn7~+gVy3wGE&%jKdx&ijW!>Hf@H4euqxhGEc?#uwUc{x|lalK+?7xO~k1t%3K`V{XK!N z)=_ux%>5td0}B6-!PEDw6XVnE-7WH(Iv)0aTTq6t)lqlw&vipwyQT2YF$vBS#H;%a zZA_%;N>x1kR)$M_#}ltYCvafY9q&eQ%@EhoEBtevQ@{AOf9$)>zL$C)n(LK|0bi}- zc#O6<_=^40caLvgIPQlq16A=n2LJ32 ze6^16x31?{T!5$t)%^bw-@L5A{2IbstK$DD;w=~7@g%3j#9XAb1mnWspofZ=FqHj+ z;=BC+b&jZim#gA=jyURk&HQsN3UvV2_OZNI?Ef8j=^Nkt`(yqSeZ47&M;ZHki8xwQ zhOgC8cYtG(f2jC>4xZWr@#;9nfH(#K%6e4xzj}YIV-F-3`YZjfML1UM{}zsG^0)YO zA};-WgEH^P`TtRD10=`y)8d00jxhinfA9ko{{P0>zhYdY9-mI;pX;sI?$^iVC#?#= zcZq?o(y@#OvVo6F;hf;z4U|cl;=5dCdB$)Nk9zG9zk8gYY5k?G_xYSbDQmyMM?HY= z5(=9{daS}+xh3bzb3g3gnR$SD^YA4sEn)^;@h!&XC45uAU=O3PWUgty$kv0 zK2=|X^TW8qYK}YTzilJ9m-!eA8{~L&g1GINWB0;4F9@Ig0JcAsxIMtXbwZ?jf+!M) zKsn|~exWPvQ2nmqWAufpGhMbP(0&E@j;V+n@s(3wk>M+f@2<{Yh_*Q6}^n zOu;{8@DqF7Jy6MT1!+)TmhX#90*dJ5O&DFO(`a@@yHwym;!3Wo(Cz=!OP{fD4W`{C|W3Nj8k1}zNGB;)ZQtAT1zrO9^ z0^KeG3iq4?X|)>Z!g47w9b^#wEtGkMaLoOS6q%S7b0Kqj%Doitip2L?yu{Lza_b(7 ze9pXH0NAI}llm@){@j-?Nxm1%HNL;$o6(S?1E8!+q+D-c7bCI1sSW3im*bt8Fd=>OkY>fUTT(8XE<9n@hbh!$EeY3p_Sy&fO29&jNl#k=oofmM98uuhO zCu|yWql`^uM7L*1kIt|y3-$-dLsGfp$6+I|6O@0sA?FrAZ6iUjk$OTG*vTrS1@?(F zX-D}s>GynKnPLrn*wd3=*}xzzKem9Z&4HVgSq19yJGjq5{;6kT^;e=wY?e1G(9WU!kdL;(IaXglU6)ahgV3*x z$Q!gZ@Na5P^v$7%&w5dAa(qhNR;CWrX+3l(s_etLuruyYqP>0@uK>ytH04;9a~JmD9=8cvs6K>j zU#%iumC>WPBwr@2jSz}BXKR8`26Zg!p8OGJIZ@-ng;3I z7(RLDoUNL79^U}^U~N-6?m4p&`7=2>why*~|253J`uB9&La&oR7+|RLU0j<9-nmD) zBldu_U>dM|T8sIlnC@Z5`w1m4;ypDR@XhtwjR4lK#&~~)IJe-ws+eQ6;vS#u+nQ24 zC#H75{(;yx1!-{-`vO6~;?SMr)atndby_^eZ#z&PPsF|7ga6#%-;(>xgBM}L`27=K zq?7s@?pu#GG5Ajb{<$A)Cp?;4b$QTz;Ui|@CO1N@N7fmr%C|#GR^^?99P3{O@Xe! zP~7teeA+qZMp>3CJga?<74jW&Poz_+E!b}!=h8CppWOGYi!8m^=BJLFBY@ugRt4Kg zi-D_vdK_EL6OUPcbFPE)PV7st{P-7O`aJZxQ}&&DO)xNeT^B=WF^ZE=4oC(@+`(ueKwr8qtbpEk}hFt!n+vTN4s z%qPqvOvAf)em^h+;jvt*2=3D}{n^gtI-aj>-CksACEDe3KaBgScyASJDN=zu&g0|< zcdRdZ;@lJd0{E;u0syW<*nsmD@aF;=pd`^4)6z*^-+8V+DoLkhl!!65C0VyAq{Vk}Q;CYAyc|#$y-# z55YXP)gNnlEI*+6cr0$09|+}yl}7(?sAQ$n1xZePI9N)j(}m~*3KH=s$NW`;8}U~J ztwjKs^Ab@8sg8qr9HtK~lo5pKV-cRXApaa9tHE1C6loc|e5}QBuyTxOf<;t%KS(*I z*`~7)%B!0R?DM4cG3wKYN(5H-H-DoLU}nI~fSCa^17-%y444@( zGhk-G%z&8zGXrJ@%nXK6~QK`EZ+G{gLLkMblxAIX&ue5_?_V0ct^TDKd$})`bQ4evWP>_D5t#RTa3 zVnjeYtPyxMwtv>=(*7CjWp-2Shw!b^uA`6e%kw&a=Y71N&xz-a z@97d{+>{sg+W3n}gd|V|d)}8qIejF$Wi>eGx0|_-V;8pl<5OhOm+-@01AdY(;^3YI zQKrA@_aoFJK4tmR!@?dh+!uI0pnQ`uQm%2&X{5 zE@z_D$4@#zy<-~omv%q)X{rI9jP02y@(#*`%cv7tB}ctsvuz>&waDxMzXgBIeMRhh z)EeA`M8*%UuS|~VZYVSK@QFf4e%mxzlxv21#pN7*wC>=<*nX`_ojD{f;hWmCNaK-_ z*#OFcQ1)3(P3DA>2Rv}EIVGJx@xjG^s6QJdRNP4!F7={*k?QU*jYvxQ5+iQpg8@?UWZcOmZ18Z*m<=HP&nNeB zLg+Ow65Vd&I}VwXOByAloa6!Jz&)cp`AO3fGVX*78}{2Q5}jV74OBcK<4$g2qYNkw z@cRuB>w=9^ljNok%Kc18e|~2qfBFv=V1(d42p*H#(R0lMKU5oY%}2EY@)zwt@AiMoMf#3sMK6l%UoA!%5s~UY-kJoY*+>e9!3dHqQG+tlvs%%R(98ZD<21159RIm~#}6@qwX?rn*i$snav2@yqMV4eNkM zrZP9g@m#~Shkc4Y^JA*3`F`UJkWWL7O=TP%b-%x%Y$y*_8P~*oH8u2yf!f7qJYRcQXa{zTkjF5lim?F8q~s>DcE8U~DP2;^^`*tl`9H(- z=Q-+y(n+Z+%B2xTi#iFDbtr4H|o@}5%L<^UP+}d z>mH>4zlJg;hk44R;w&!lvOjIeHOl`aYXk6I1|#x3ii?isXQO7oSU(W&5j#iyRKolk zNxy$b!o+3{l?e5M|M{2(n;ri-gnO z95VQxV#qnuAt{(aS+vbi7My=k?` zhbu|oyMrO;I8T!C7TDb-Ls_skrM5Yp5{?1n|2h#kM>-^2jFE?o^p7y-i)$vaCtKuF zC+}Y{wo=ka`aehttsqB;p)BS-4ih#d$gd&iOoxPx)p+Pe|bXr;Jm0|o?|*B1siCKMtDB~ zvQ>CZftAU|WD3R}oxYz)XbK_dLh1I650p$KV@cQ5!3Mp98@tPg-P zDoN_2q~aj4;svh}=l+wZ?XTzznKXtriSxb6?`A*#7(m&7AOhFmKmKBfc}Pfq*9bCU zeA>L%Q_Q)H>WRqS{~`ZTJ(tkGMb-vDz3^i~$Ne>hk8z(ahWQNTL6X+}3jeS{{|K`A zN$Zv0&3@b&Kw3xK6VL?S|D|Te#sQH3U&mc*a|mPrWq&He{KfO6>h~L_irD|dPkIzV z#;@?ZR)*Zf$2t0a5%vW}dGNqg_Wz)cNc(w)=?@t<$A|u3dl;x+cNua^zDd+w`NF%H2d$fL%_f^h9*f<*R}1d1{=!uQ3=vni?{Ss4Hwnwg@! zrY;lOcdd-&ee!IovP0e@hBCum&HMkxBEgVvBFy!W-&h2dXpb1{3(EG;EzyMd0wOZD z&WAjjN}n$(iXo@5eQF*i?lCJ$`49LoGqIp2q`RYiGb z-V@LmVFuTb&Jn%|NIsL(w{an_vAqHK9f*Mj>Y7hg3}CEhsA0b0Ir&uGX7Yyt0g^rP zyV37|ncopMo_xp~b&aMycqE_3pEBL6(5F!(`n)v1C-iB$A+Hog9yj`Jc>I=Z`_GZp z1JLPaV4#%#Qx?9?Gx7jFjD8b7xt05dDJahkoiJJs_(_Kh@BbRlk@gYS_2}#TqxjyJ zO>*bNceCV#^nYg52EOt>fw8u}%+s@^+Se~3?2TiVu=6*O*ue6Hh||==1Rvg!@bL!k zdDb*_`C}^Ix((8IsLwta)!9!v;p;19k7=06@qXy(2p%Hrokd>A59j-nqh8UGIkIt0 zS@%b#IIkHUy`p;$^sI)mFv6b6N}l){zLN?4jjb8~I8Nv%^@=W^$>~1i?Y>Gn8}c%gsO%PdKFYEgrd~}ZspULrAlmqple}wnP!foISXcI(L z*Hcf}#Y?t}l4JVChs@m11m!@mlIDiK_zmy8qaU0mKH^KPFeop+*WixyHTE5ErLKDh z{mLhnOr{zS`nmbQgV8Y}#U8*T=MqeQZ(*c+N->8MMH`PgQD5q9s%etga&epx^}!@? z6Q-oOF&}nt7uS0ywtUfvk8OA8aL<_Bs%zBmPg7dE9Ua{g>mKk|2WcE}?E~_RibK|$ zH*wxMvE+&>9=2s6caT~>W5+@-=oeL3=HDax=PVGkQ|PdDz98J|2b zJoeF$-kT7I(Q#~JvQd7FX_>!)9?Qf)UbEu2M8SE4b@0acHn?^i>E(-S^TfB){G@&e z=LcMKMP4kwdEX46H`if&E$b4#h(tFlVP?S0fSCa^ z17-%y444@(Ghk-G%z&8zGXrJ@%nXt*BO#EL7+C`I8$$c%JU#4M9A&`gpF9_|c{C^DqXc{TM@tcdp#i6*Uzb0-qJQsn;zq`XXJP%h_ zrwiim2IqRAaUG7TgoV?g(*>Ktc{u$#U5F{jW5|!1PsnG;Z-jHh^f5g5Kp3(!OmATi z;*b6YaVT!mpNmHg?Mo!O{&yj&p}jJZ^-iDkhW2fk9}Le8^N(TvGR%KUwuPm;i+CkD zdgWZcjFss6AN1K4mMJr3SUxD_hT<#bhH{@_`J)t6l8YR%n?uepCDXNm(Pafw_*8?!upf)A2EarA0Ft}aqxT7 z#&zve&zJvtx#yp6TDCcN{=tIWFTFf^eWxq@ zQYt&nCe=BY9h&*>_Gwo4&{+6vOxHSLJ1xu+h!^E_d^CC4@SM_VlU zGwA$JPYP=+(>rYUYkF;=&)u7qo=s@{-hcj{ck{%iblSAD=EGuVi8EjJ6T>Z1S zV~gcMSF1H#9r|k0uerAC-k&)&Bh2PriH9poX$PmAWY=w6al8pIYUqyWFd)h4U z@0`_XO@(=ty^r@BbZnqyHmix6eMJrr>XXxH*~q-7Ml?Lz;9jxXxS~9fkhi=xr zJnqd}=zjgmZfV!KG)?|F_g#Z5tFo`m-m}j6Ddj!erGMz!M*G%kZ|YU4&lItAy;iAm zztatiJFG7GqEL-BDJ=$Cx)#zjo0eyFDvN>RI%apvZj&p;pnCOtw$6BQLAg`wd}}{F zwew-!Qa!vMWjXOP@wdKI(kfB){6f5ZPm(!)1 zGjm?+b+OBAmsW4qtP3mCWoe=8$5K!8zILF5#p+%uE8pEbt?tpzmbZ(ae(&2g|L)N4 z{XNI6&713-{gfq_5A~6Lm@#+h@KUSWKFF8zmqwO%YAkTQ+N@u#%~s2-rZ?`C$A8iY zZLx+m)}-=C;kP)X+W_rHc zCo(l3arW2DB`b8WsyB9)R#!2V`^7UU`&yT`xaieaY9(Etb$;}kcMS)>{q|L*5DT}n z^ZG5%p0V{0=}TD!1{TjIHMaQ8W4E2HL)!n`>x>S)+TwD>2itFtun!vaV*lX#y5jSi z?7CAlTa^X1SF|eEyKWoJ+24<(8o$n?k=MoL>uYt(-u-{q+OLupSxm9~wb}J_j&7AE zA8{x>>4;s8X?G6U777WqFL|lu(y(4<-<%0Xn1AFeJ#1IX?kW9qZ?ox~aX{t=sirml zZq+l_p533O8*{B%$$h&r^+~@yIr*o%Te#!yb9d5AUs6nSQ7Sp%(t?|r zwVPH1w{BOda=Hltfz#~U*#%lH%h)aZkzY=)^XTL1H|$Z^@}GSN-!GQs{%PAmcAZ_0 zR1b1~(5%HNsh0DKp1(L2nfI!+L(kr0e_J|!@7oH&!*!RdA9p#o;CT-1Y_CeaZCY6@ z^yufc&|~PO^%pJ9PCPy5W%mo2%lE9}(ML1eYpdmM@7yU~OG~YOofd4}qi%3}n_b?! zvUT|7>D6bykF_q+;wqA|vPWj`_V(?kJ}4j5d)lh@9bSz&yV+szsoGD6UR$lJ`eFB4 zzkO-`EOMgmz-1R_-)Pu*W_`zY1GX3Tx?<%hy>$%9@lqO)BU|2S&9am_Ht@j!x8`L7 zH`gedBE{A&BliDl?{n*0&9j=0H+-&G_La7$eOa(m#{ES*rL32A%Z1&G#_jdoe%7XG z9gl(I2Rc8y^77(^Syk)39o^Y+$hGU=rEZY=v}Ly%o9x=W`ES;nh9`HgY2%f7Rjvnh zx|NxGs^Rn+-F|=Hu8&8RW>YMFEi<`Og|6GXPMUnr!Ncp$D2t%Ae_WJ`KHr~u*rc=0 zc~<_Rsky`ckL^8LRM8x?>(eiIQ|2K#{d2Ee_-xPRq6cjH^ly;!@U@;lX~xVdQ~bqW zHp7bB{cS&@*_%z1#!Xr=qpsEaAMFQ~yI;^^UeA6tm$!2M)2)r$Ca)5nSF}I$s&OQ5 zV~3vgn+6WpBekBmy-d3RkE@mMz8XF{ckLFZEPivSZu>UX^beknwtr=?&bRAD1CQUF zEUe3QZ|8iVNTpL%2Or$gV8!ikH4T?lTw;;M;-bay6@6>Bwki|?L3Us>?h^QHf5cxtor4u{q$ z2W1|R(dVj1pY&}GtXjLLq|MTSeK$?c)v~U~V7o$^uFi)w`BqkJ&~C_-<_|5OG<%!z z_RoWQcia2@jfH;wcV}=~_v4HQvsP5;xO#Nh+ftqKYg!&mv;FTbdD}EOP`SlLi#`rN z+84L!b$E`OZ}TgwuGrUX@!sRQXQ)L;nu?PiWiOX4$jN=t{f?noJr|tX+setM>Ha>_ zY){)(Thfhr+jhZ_{sjgU8jy29KK~o`p~Fg!yuNOxtJ8oqsV7yscO_*|@neO*>7LTL z@}Up9MjrRw+w3{?i>t@_{U6*r1iiZd&VSYMGC>)=>{sRLTxNfcS>Mi^=%2RT)lwU) zrODbT)%21T$(Ttaj0 zdSU67-p$UUP2Wa4bKI(P`iXOeuD5nRnHzjDZ{O1CEJu6&`@ z=mn|6s54XM_ptl3bam^QBU^7PUaRxQoI~vWQ`~BN`h6Mqu!BB6f7m~$5#;!wNT;P4 zrH*Y6v@18P!?E2XhAq`DzjvoV-xW`$PR&>Fzf{>hvK0BL+Jd5&zORwf=i>5fo{vgs zHy6y*dTa{Y%>&MiZdoWG#ACQ7m$rhZW1(~9wA+_hHfq!J;rJFgQkC5B;NhB?W4dgb za#7NbI-Fy^Zo~+?9dnCY@6NR?+)A}$Mj1APCtRC?^XW$cYfgUb5b6#0E$j_C#8JLWz(r%#n;AEp(4H*fl~+*ww7 zl^8J4s=kZnpLt7jXp8mlaaOzj+T$e?#_7JPUG!$!9cj)#ZuVhM&d>^-vJWpgvFeuG z)og#saVgzxo4uo~&KybM^r88GCn|qg!a7M@iIAU6w2Z` zq)Zyiotnj-q3QqWGcETYjaF3suXWG#OPWtA+N8p~)vt2RyPrc-wneUxXI5iJ6>B!c z-pxgN>eememI{0C+^*g{wQIT~S+#2pY$!71NRLs2>faoCJ>QI4LrXPCckHlTPS>qt z`_{T_v%Y3YpJ&55jUAPz*^tcs8OPqt;P&f?=M}mh=~=Gh{rzu;UJP*WxTuNu=EC3E zG|DutfOlWZ-BvG`T2$Dfnc23=W$V(D+PwPJV_g43I>(T+_F+FS{q|;$(B8Ys9o$^x z(z%YAT$>C`xj)OSe%{y8w4dOgsqCRcN9*=p_TaY7Fppgmj-;J6y61?ri|4tmE>L!K zkEiY~|24Pr-1@Zf>ILn0__lL(vg%)}Tjtd-F23&DB43c>k&=D2?iP+U1JiYrx&@9| z^vb?w@nZSBT=rjXxW}@w*T1fp?5>S}61XdU>!*Wqx5_uUUIqWmt*v)h+{#?`NclGw z6W(SiUE1a8%e%eG9=ER9c9Zq`nwKU%TDmOMVVlj`k!43(WOIF0(SK{XJ9S4LoN9Y2 zxb577Z)&_P@;>|C@?DIbY53x-;a-1ud};k z^)AP;U$qt71}(6>)9uHQuA#fDy`8yo>5~FDkc3 zQu(X89J8A^{GzEotMiS2zbV_PNPW-yRq|ch@LPrczYTn1TfJ7LiO1?{OL$F{uD1XF zeA{$2o6-a%K+XQxNHrX$C7IhVWgsYcV<1EF?-{>t zsk>OX)8#l^{9P4?G2>oTI(@wL#r!94EOvS9_@n*Xf32TP zu+nat)8IQNo9!7dxi&hpz%t7pp$=RA46wReEAMsNNhgMW+rNKrY1OxT+r9j)*#g(f z={MB;r(}y~bzbG}RC{vo4i*iy107yA51OjoS@__@Z9 ztveqZrrYsgpRLEv8vXZ=*VgR4HT5E!FuNDkA1rN})~oV&4w+81%TV>(;e9RtbG2$} zw=(0l?A@z8*saa7^-sT6Hkx!zKS;Oob#khCbL7D))l%N>(d&KV8y1bde#tidiFR1= zmD{ppvASvdqvu(Rz&tM|49@je{d=vRP5$qNZAopRj4Q7#a8G@#mYuuJwMi#aZ7w}= z!kRI^KYFIgRd{F0Y?V&6dpWD)okGVN4(wKFpzBS`@sBk-9Y#7_-LZStl?nr1SuOPn zwYXKn?zQtbsXDg~tEHVeS-1PtkM^#bdRLq6wweCS^D7HI9c`|zHImTd5>|eaSv)AOg&`9yK3tny>Mu-q;N%xf9^HVcItX_f1kf{v>%dvP{Ee<&Wukn z;@Z>K!7k%xFZFp@KhKu>_ebf*%)9)*J@y%|4bWOQ{I})nxAz_|+%{(1j%TC%+x2w% zYev!jdmfCi$QW`s{SCJfSufSsmihjS^@K?ae!bj%Tp#Ty%fYp*Jg<+oIH~>qbU(YP zR>QZCbv&QV77A58wb$j$H%EOt?sL1aT@(86%@KvyS6rKB$o=}6OJyqMpSx%`r;WdN zP5EG4Nxx++E2mv$TkhtA*P26rOSc`z)T-vNvh(m#*S6HFxVYVfN4{FClI31LD!Mh> z{rgp{-&q9zy35`DY12v_f-Hje^r}%Sb6(rbLC*Uh&*@&T`f~r^O#PPD{ipmqufjV{ zI@xD>d-+lh*MOeAp87qu%QmlPjZ2;{>;{xN@<)#9d48+?dbC|=s*nTk-`xLZ;4aOQ zT6g(jdz)pN4U?bhwv^A$WYHGN{Wr%uSX_Z~fL_-;$pO|=)b`thB^=!+L#U76gne+Rd_ z*7+>_Q@zZyuG%5HUK0yq(f&1-|>K9l1xO8sYFymg1S)uL>yhaQ`@+b!fh0wuL`fm$Y)T zdbI*+lXvcZUx$*`Po?dL?*^}%bEw#$k#EyEzbH2C=D3%xEn2;=_MhEPj%mK%?Y+mN zckaN{4{VNpv&o_7pkM26sQoJU_`LhmdKG$DWk>5AwWM948*^>;-2UFO#{Y`DJ$8Mz zao30S>u-&j7;yHilk>)L8~VI{{@gvyWzTz_*TP&?iui*1cPZoLlu@kh5Z zCpUJV*rZzp67St z&nLGw)F~CbI>o>TW4?LVtZ|wN!`@GRZddh(8QK}CDtu^}en_@%rH(xI+%;iULEl4p zgK`%S>sBeXZi2()+3)-1YJRhWX6~BV+VV$tS)X}dL-*}~LiGxiSnb(z!exuljLY4v ztBtcBIXK1keY%iqM}u!~x4s>s`To{^uM|TnJ-Dtt6>w=r$)deaw6z|l3GL{;EA^rR z0}j@^v;X$!JRdQ?sb^+!>Ixe?;kfDSa4*DqvLX0Rk!Xp z$f9Pezee5reQ=%&6>QU|J#+oq@ny?A3zYb~*yKFYjWIh@q8ppfr-D^G|F*$p%h)&u zSTA|8tmW*Kb*3MAcK^-anubN@{xR`Pr>sSL&B|<7qQ#A}*sO2i2j?o;N;r0{eYNmG z?|CnCopc+WN|SYs?}szPI+bme_EAxvA09vX-|by4()HA5rX7?jZPwLy`R~_~u8W?$ zx%*SU3E5WH?Bp*t_mT9QdF^8s4FE+V2wS-so6s{>J zr~6a$%<7Hv#+Ap4%+wBayHPN6sKxZ^`|V~v8uR*1d+UK|T&Gp~tWTx$U?DFRy*q{k*$l#T5V6`KQm6 zCoYXIX4rPu_VwP2z75|^sX7@F4nLKr%9wYr^IQEibyJUX72N(k)WxdkLE8g&kG$8M za;`YpW@P^LJF|Nu(4u`)T#zny>a{0J^I<;iJuT*LbuZ*|zx6K5@3V(AacT0dan;F3 zbhqD~Jf$m9yhDo9za2ST_{U2@OY2MBempyUU9sPZjLd3%)TF1D5zhMlp&4Be#;-M{t0_O6S*bd^S`PLb9{Js&a`G({ZqcQ zn6+0Mc(#F~MTc5%>T1JKN1RkISZYy}f#|Xa5zyRM$D2@7$uv@*Sx=m)h^? zd-zeqLnCvf57@kW<;3)t$8AjgGSh>?vvYk|tZn4kf771tt`9x@uD#`?)n4EB&D$ya zfD%34`UPJ8Db0UxpV>@F|FE!hd(hjr^KT9=a^SN>rTr zqmTW6cI{Ia&v(1@_P_J@>wI>2tK~?=cJmH4sN{S2LRdqe_K&6gjsLFQ_x$`>uTGR{ zVWU}Wm)gJ0n1U9k$G^J1S^LZQe1Q{oU%D5#!>hWrcI$<(D{0%0JNC-%xKI0^JPULQ zvAk8R@2S1dTfFE~{FGx*{rg*|eh4{I`a#Ll2QR${{65Eya{g)V{?e}ev|9P6TKa8U ze=y~R`QBx7x;p;XBu%Bm8p-)%-U^Nw|=jZ4|fKi7`Xpswj7R@uQj=C zb7{*jIq5d{@$s$SKRWg5R{Hg&EF5}R+b{56)c0c7K5H6`(KYK^VoU0ZH4aofXyf;H z&|3GiGaEQe8Mj2*WAnP}=z#^V1ne1AE${JOy^sCf@W-R?T3a4i`Nv7^H`DCxa{l{m zqs)IjsP&Ik3tdmYnR_fwg*rd&c{{_n-c@>RdbHry_~LnwdkpnlUm#7o=Z{j4_HLYi zbfKfEG~4!{`aR#U7X4CO$lHEGwbS!$_F87trVL8$Hmqz*%lDi2-f!S=IC$?r7oHxm z3$}M_SmWmutvZi>U9)*Jo5$yJ{=U4{yb@Y#*Q#mz@9cNs=e1+zNcZyft98k9d*imn zLbo*E`%-$4-tDim0~_8x`*+!n1JZsNQa5+X#>;+@?m8dqRkU3QnuLcor)`n(=F%DK zZ3aB{9aeSY&10jwZ+bHClzYC}AO2s42T!~tfC*k!1SF3Pjog$Xc)u8^VjX(%c6`U5{$zHdaE>q##Aj)ZNITLP*0mM( zfBdpX3B<(@P?*8?++qSD1s`EKKG6OJ2)y$Rc!S8eM4jX7($tG7?JMN};!1KBuAE+K z>(i^q04mkXR${z5sbj0r=6z>dv)>w5P8`+kgTAEQ0(3{PsGluchi@UwHHtePdAu`N zq-V@QJ883 z%J#!DM$@b%%kA@QwCQ(DZ1?3GqO%m&h(qb8JG+`u1i_=f>&j3?7Uz-UA$;B!a{S=H zoT#jlR0J#Z46Os``b6d8v#Zrx8Q;gjTZu0r5S?qK0Z%ihMO z{BEy-g9V$_0Uu0KJ~PqaX;V9o0c-eNlJH9ouxA9de1yqdMrNmBtS}JiyY;0?;=?+F zoYdA&oFfFiLd}8sD6nA|wQ8Jq>fnn~*DOv#b(sD3lgs0Z(;m>|d#0v7@=LVby%aPY zzbVGu^HNOar-~C*=Ph(zG!0mW{>qznH@L_PDwYN>NNj6ye97;W`5C^|%*CP-lZSTh5waLH5bDGG7(VAV^D ztq{7{6Sg}<11FSTiq{hVXq2QeuiULE^1W`iANaFz=k!`TCg}udOaqX4xV|7dESfzF zh%Q*TUM>TV3#vwPd#q7NQ|5GSTMUZl1T9@DKHmqBxB-fT7tDumO}f`!@vB!vSp>^{ zGuWI=Q(4XOBR1Kr5nR2hnc;dk+fV^?%t`5|lD!W9I)QbO5!cqlG4~+^hJvdbb%R$@ zXP(-y4tI6XyA_oF1TdGEOn-2|`8dysC)jOL*Wq;JUP}CwNL;VX zB6Be@@6+v&O{SYYNs>>up}OJdEc-H%xM==8-FMao0e}{#6or6y$Th#U3eZx8LcD3O zb4+jOk5h^PCqEVi=Dg{;#34Mw0_1#3Xje>f$&>2lFum;V!0>1|! zmFZ%_m^ZnEHSR9XoyIIkxl)c*NLG^(m^-3U{^IJNBtXvrF;xavwqvd;?{L=N;Q#_t zilD=57UiA0-PDcQq^tYmRIAc=EkR}*?i6Zh=XscPzYT7*f))vyQFa2tYu+BfQjf1g z7$Zij3DYqT9z@<}Z?tpZc(Ai9%%kZB06K5Gmitn$Yy6b&8de8{Fp`&am{^0x@BKHA zARr{&|4z}bl*c}RU!MON(|U*3cQc%|su=<|ekeC^K>N~I;pQF1($Fv5O5GtqISP!- zq9CNo2~-gvXZ1KO9+Knx#n2o83&I@0-hKcEGDa9GKCtX`pCHkJ-^T#B+`zrfCchau zDv{_s)ZUG`g-fk~2cONFlAiVJ8h!$nFv|0J0QiCmP|405{*V7@gvc$#O((tK=Hk*z zm9y|9LB-Jn(?81t9MpkI7nivm#f;@;I_5t@CRa^Lt0VCRl?MXl!gtDdN zNoL@r4UF?423$=LeRrn8J1NKm&-={n>~FP?0)?mzJf6K*s;(j_vgq>A=@MA~q@@Mc z7f{i{II(AdJlT2Q*V{jeJ#o*^gQ6ZksriwvTwtO6v*W8W{-Yp3>llfZx#C9G548T# z1PVg1WLlI6VSwC`9{l+4@L7#}6MZ;QF5zoeWwl?r1*oemt$e;#9_`9LoG}F5c7tin z8;^PIP1XM%ydV2l9Wd_u<-Lc#D^T+kjnY$rzdTKnh;|qJbn$CnWQ!4PD2WUFY*d7# zKo=9SukwWJMH)=nRgmE@nSh2?roC2EcI1|=o7qbu-1k2UZprIuAB$S)s67A;?(15? zB>EK;c9S%>1La=gEzWnx_eGw41~`3l{Wx0wOyeFCZgNxoC@`%=E~;d@wwwfTzYCg@ z0oCu?e3vNF^lRutSpdaNta3KF161wE>5C5no42`~z_n?bov0Vu+(E!aH@WX0fa9a# zWCWB&Q`EE*&m9T^1Sgh22gv)VU8P0<@qIC_VGo?q`LQ zh5;lZAh9iNT#+YW!fX0wl#U1|2&f$+}Bik|b)fqY(k>p?3^|7(+F#;japq0~3qX=f(9}VPDg4viv_$h>PiX%O6v}S+Je+Z#b2RQ~o{~=(%Y$Pt3d+ha z#1&PH4}1&% z#f)TxG=te$Vn1+`yriYV8UKR7R|)#3Lo2!vt|;<#;o7Cxfke*xy3%_8=2+pF%+7vW zNMqcC7q7l=EZsn47P-l2Y^i--C7&0}@#p2F`V_9Z(SsVilnqnFk%@ zNj;Q&bsH@(Z_Eu3RthO)rh?mgNcB-G^!xkW_sMD{ zd~<0_CZ9OfX04kV#hqI>Sr-`bE|V1#jP|>$(T=neRGk!M7Zj?1)@vfWg5A_lxJ6YG8WST8BPCB4c&D z3NI4^iA@cs`*jhB%L=5Mq*2sec`WxggYI2d{xmdSU=f5XN`t0=Wk`b|66)`}XRhA` z0-fWEs3UY#)Rg`K&ug6sI*s|20fVfyrT{t3!eR%qKKG0X<4M3*Tx2P;vJ>t7>Xip# zpv9~6s<=-+siGK4c3*%c(;x==!@3EQDpWrs0S3p--TclAWQkPQi^ZmFrCu~`VN+Bz zyy+vDGro4*B!p=b0Q*PiADzti1$*F0xPPCt%INZbsDeIxahHkyMV%?+*;d;Ad*90I ztkW5k3Fh~hzBENQu}Lc%^^4v^u4yf!fYjtfP_NHpNsro*q7ousHv{B(OGLId?6XAU zl!Iug7{s74}DCMN``A&wvrI-dh?25oS+k>`#@F0SvF9zvz=l2#J;^wvL zL3XExvXU;?5kq|zundT~ds1JV1V>cr)uGI*hZ4hWR#xcc1?hhL6abj|WTFX301@NC zrAf8kWu8}Y|Cdpnc3G>YSN$1e@}`%LGC%+1BHaLPru?CH!SHH8lah_n{FCI!kNp9< zxn4H5h<~^5baomJ3!j4$Wy_ktmuFY4hBUTN0AV_68`0oZCIIF}zl=N}`XHmLOv-fc@k(N?HS+fPln#RDE*?e3*Cc zp?mXu;Ty0=pK_KJIO(@ENDKUzKlQfd1@9(N8))G6Ui^70&(rLUouD;(;g+Uwx3vFi zPqnwk%=kZEkrK=?NpJ`coPj6)Cbzor`KCJfrjbKangf+j*u)wS=?A5EaV&Zne<*B$ zwL%wPQD~L5Bi{x~Z5-FNNz()w59J3#CLD?kU4jnF;<)CXj+F@mQ}}qP@7sHv&l7+; zp4oAa2avTC*t>V?8)H+ftfE~M;F4qp!=QR(Gs#xui~7JM;5lBt^q+`~`Qqw&>9gA& zVI+V%P3%!a2!$T%nD3Yz9y_qNVBbCNrn`90iM&&czN?DAytiO)aEkl+6|wfEIUlrT z@ZPssfPLO$k0Vp9x`{%9g0V?KD3zJ%l7%>dG~;#bp@2wKyc>n^$Gbm_+9FccW%ug0 zUZ%Dr*p33{VtvUvN%@is;g;vSYW{2{)I(P#l)oNqYJ)Y3s|0OCbdAO|Q&b2(4=cQj zjTh(hpHTbor24T0TjJK^-;ycqg8qPzCMVsX@~~1?br}u6znYl=$uG}>SoGOU5r1R) zD}#rqGL+f%EYIB@Mh;u(%)ikbbXS>ySy9ykBWNILoVs)eXIuX)lLxMzdgy^^j$hkT z!Zqj&U5C}UnDpPjM1gm$WmA z%_6$18NTbV*8}=HTPPZ-CO*fK|2Ik5LibzYg?2!1mW}(dl|}VGsO;b?;)~+OP&aod zN#A8FWvMW5ujZOZw(X$R`qd_kTiz5PUt0OmZyi9ng5 z^E;uX6Q02G4+X)pwHa?NwXxgG3AuG^`&Q(IB~`0uizVR{y@naGBEVtmr2)-{V?&~L zihMmVr&iXmv7;xdfa@!^?dH!6Abf!h*#&JNJhjSLto25?mW((JCyDrgkWr;yoxh&h zmD}Nj$lnV5W`4PhV$pOmuqGM-mtOHN{^LW)T@r*AKM?$i9EB0yqpBaSU$<+WQEHD* zQKQy~z0xtdRZnr=l!0vG&6Ndd0J!Fl-3>72E$ES1bw{)7rye8D{iwIyw+WYoNECnB zc3t=`fosXgq?=(EsdMM$6@84;tFn;{)~HI?S@^uJ)pdZ z>j*TcuUtGo9#=(w?OQrwsEk8&&#f>9*w)t6%MT* zoOoB6vF%5j8WtN$!mL)5e7*M}{b+7y(~>7i`~p)vNLPV7#}Om`^gJdr`Nh1)(6@`X zRjFm-N-l4S6*{kS$g4qvEs%vcoVvj!HmN2H){*d5;49b#tfEE(x}dC-+blL72u&# zx82q`nFckN8KcTqU6g>xrYt(70Qu>u!(GD+7eJ__7gsNy_XZy>`TV^2B6{G`u6#e?`p}(y%DAX9x7XVK9D=lPH9pRJkn9TEja*B8W7x@PXv_7s@iq6`?cz_+2m+ zfXY5jnSa7D*=i(J4cbD+ZO-k zKTB`p)B}~Ct2;65aSq=!?C`I)47zBQ)Nv}Yt?DU1@sP_k7_79Fu}YXx8yp*Sd0%!- zS7|q-_U2Y+aLMqFBfm>5Tez5<>Q5+W_+gzZnt&R#CwNSAL*{q0%s;bOqh z0e>i6%k0TxTFP>MlI64cOYK=kEVla|YFg^2kL>p;dK)4pm3THqH2#j^-@xp(sTW%6e|eU&a^m_f?Lg`ccML-z~O-4w6?oL zw{GX1?gm7Lb51yD0)q9QlC^@jIft>*G-*F!*sMc&YwE-@U{~v}$4AjZ<6;H3%v1^1 zFg@@GhnNm`wOoWio*nTXbdQKnUhXKJx#pS zv=cw3{)SEQdCv2i$!~qG`FWP~m|jrSW2_JG7$q0+z-K4lw=w@5FbK8@{BZOsr&29lYMomAUd3tZ?J%HelwswQ-?`mc>UOe7 z&RPaPq6em6&)g^Q)CK$n;*d;W{;!D7>Rb96k^v#%`>#(q4Pm)A0$j8Q&sy_%1!#cq#kiN?lyNz~KG?4JkYr65-#YEOQBOs$4ohoX49En66@Lm!= z6S5f<%g8LX1Ue1V+o@W?zmDNtarN{+VE@9RUe`${eSdxuC60Oo+6BI0_f0_2bH=Z2 z53q@UxQokrSrSYP8J4U}pTi$;3LO3Q&9C~Z#|)+f%Ib&FoRB)gBM=4tN#huQC8^XQ zac`flrnD6!6sivZx75C5Q0uFB*!HfY9x;t>tiY<{J%X=mD#H63B=Aa+Wzw2W5C<< z4^z%2hN+vpv;0V5maM_lM6wGKiQUv(bcnJ|{bdwo6EB8!BCP8Zll|>u;;B z(hL-Y!w#{FY64WDO}Mui(JX$q-oFfG+7Ml2(y6+L@ec%L?G=->lVQcz;$|Wkj^6`3 z(3$4lJQii1dvw+uS=OjW_-)91GRDO%p8L+b^}(CPQ2eygU;RtJRaf{u6q4=L;a&XB2Y(N+GlO zG5d1IY3reqRRcJIuX)SIwRf*9wqHHQex0=-R-AskTZ7Ambg(J4L4ULBtT&(Sawc6p z{5g0b`k&A$02xu8*~PT%H>f7P~f+h7>LrBgUcSr2F0V8St3cP4q%t%r+7vJUix zBl~}TqB%+NUvo%e-N1GzZ&TAaZemh zAgQb0zsxVe2@6EO3{CQl$B}U1c=GV=mrQ|&LpznwzNIH0Z*Y<5SgA8NzT_w#oKJ>> zS?Y)^?zKphe}}4blpDxAZiZG8yZM`8k;{9oiH4wQA}Fgudn@fJ?aLQad4uq8h6AM^ zoV6_D$}XQHkoK%u?%(cx@7ca1gZby+w5)(RWejG5H=$w@QP#Z_D( z{#^#pC_SYp_<&7~w8T@y3Vekgb_bYoVw%{&%*46{!K54Nz2N28gvgax$D9Vw zbOAP#zos6i7A0Z zvCoLU{p)-9N=b^PN&e;Zo#A8n%(ER>D@il)Uv1&FUvFG)C;)+A&sXO;J_CVw6J|Jc z?JYX?4YwF~3qNMLv}P7bHfR_j?)7Bf)X~JxEuL5<4xT+X_)VxhF(SfWG``1l3%D;>{H&265p6FJk}pdGt9CpdhrfvxYjlk7;6F#}$OsT1Riy zD$PX)>}ij{`byOZs7{iU8wo;+1A)YNh$F#XG8zsuxMT0J2gs+5x(fY82Y5H-s0#18Qy)Nfb6Z#655T)-qx# zHuyp$7i6BVadD%F>kfC+@xri(V~yf}Dc{IN6j&qBBfl@|5+|ktPLMX>W_tTa^K`aR zR^gX$?8)8tG1qX*u?5`bAYsDG&HML|DHma~ld1M-A30nW)9Hg+pUv|4NY*N)uzKC6 ze!BjWF*&WmNKf zZS2kiImkTA$$221C${KRj!yYY+@1nXB4ix~5CvgSS?{NQ`6w=%e(?aj1Z&>UU(y_Auk;fl zo`+c_7w7+k)aT5KjZA8hzBYf2qwFfXGI|LtF3h{m)4fz&H#WAP_-P!>PGn(n)W542 zvfFyQqquUJ-vE6?-f6Sw7d1~n(E8<)78Vtd6!c%ZtW(*5PlT+z+gp_5ojFWEH$Vir z6>&Z580=mheT2!y-#1D2i~}&qt zb<@(tc?$_jb`0fPH_Xm^yw!6C3Sr4%r)u#yU+U?^y)u?n>GIE6X!_+Cd%WJ*$^46# z#Mdxk9${P8CrtV%6@nP3S`0{VEUf1?^24r=&e`mgU znX5?$3WGSZ)h^hKMUq5e?EsDmMxRxYPtMCWNtf8RUaVL$c+H7|<<5)(mp(>A9(K*e zOxX*yznHd(bQZW_WIT2#u))h*Jx7Z9}!|oI0s1tCy%VZcnpD-MjqL2q4Fv zGY8L)*gWs5ImfYQU7(9p#eit$(-G&*r8SSQ zAE!7mgLf%DxyKeZ8P=yGxO3Gx&TV948{2y~_u!tF>J;hpLz}G0U(HcUeUS@Eo#s(C zqdCRe&t}+YfBR?31%%wF&Aykbx^_p9wB1$kAkYiu2x5{Oo4Do{ zK$Wmy@%?dA(WB9BRDVjp&-4Ca&gEw2T2}DTa;D^-Cs!zrW7@ulXV{9-XHcf^f}W+w zQgV#>-_NcLWFA(21M)1?fSQkkW&EIbuCFF4D$?FMAOJuWBQW z$BFzAaV*-(Xj%MwH&FhK5CuJ#FR?g814v(>2K2#2qI%)IsPaex>JAG>@t4DQUw^lu zpQcYouz4u#8^RXVpT5&0tpQ4K&*NO?z#@E05ab}n8-u-Gs z$dl4mwHur(vFmU^7O%{go#`O#?5=<-Qxx9`bCymfdt-YxEj@$_;T*0viwj@(QEZr! kwXq8M|Du)sWKOSvyZv)U|8hIzZ%A!`rm8NcTG=k*f1Lo{uK)l5 literal 0 HcmV?d00001 diff --git a/public/robots.txt b/public/robots.txt index c19f78a..39ed19c 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1 +1,27 @@ -# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# Block OpenAI. +User-agent: GPTBot +Disallow: / + +# Block OpenAI. +User-agent: ChatGPT-User +Disallow: / + +# Block Google Bard/Gemini. +User-agent: Google-Extended +Disallow: / + +# Block Common Crawl. +User-agent: CCBot +Disallow: / + +# Block Anthropic. +User-agent: anthropic-ai +Disallow: / + +# Block Anthropic. +User-agent: Claude-Web +Disallow: / + +# Block Facebook. +User-agent: FacebookBot +Disallow: / diff --git a/test/fixtures/companies.yml b/test/fixtures/companies.yml new file mode 100644 index 0000000..3d96407 --- /dev/null +++ b/test/fixtures/companies.yml @@ -0,0 +1,22 @@ +# == Schema Information +# +# Table name: companies +# +# id :integer not null, primary key +# category :string not null +# email :string not null +# name :string not null +# website :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_companies_on_category (category) +# index_companies_on_website (website) UNIQUE +# +california_data_broker: + category: "california_data_broker" + email: "test-ca-broker@localhost" + name: "Test CA Broker" + website: "https://test-ca-broker.localhost" diff --git a/test/integration/deletion_request_flows_test.rb b/test/integration/deletion_request_flows_test.rb new file mode 100644 index 0000000..9cf1a42 --- /dev/null +++ b/test/integration/deletion_request_flows_test.rb @@ -0,0 +1,45 @@ +class DeletionRequestFlowsTest < ActionDispatch::IntegrationTest + test "able to deliver emails for a valid bulk deletion request" do + # Creating an extra California data broker company separate from fixtures. + Company.create! \ + category: "california_data_broker", + email: "test-ca-broker-2@localhost", + name: "Test CA Broker 2", + website: "https://test-ca-broker-2.localhost" + + post \ + bulk_deletion_requests_path, + params: { + email_subject: "Test deletion request subject", + email_body: "Test deletion request body", + smtp_provider: "gmail", + smtp_username: "test_username", + smtp_password: "test_password" + } + + # The first email should be delivered immediately to ensure SMTP configuration + # is correct. + assert_emails 1 + assert_enqueued_emails 1 + assert_redirected_to root_path + assert_equal "Deletion requests being sent through your SMTP provider. Check your email outbox for confirmation.", flash[:notice] + + # The rest of the emails should be able to be delivered later. + perform_enqueued_jobs + assert_emails 2 + end + + test "sets alert flash for invalid requests" do + post \ + bulk_deletion_requests_path, + params: { + email_subject: "Test deletion request subject", + email_body: "Test deletion request body", + smtp_provider: "invalid_provider", # INVALID + smtp_username: "test_username", + smtp_password: "test_password" + } + + assert_equal ["Smtp config is missing or invalid"], flash[:alert] + end +end diff --git a/test/integration/smoke_test.rb b/test/integration/smoke_test.rb new file mode 100644 index 0000000..4e554ce --- /dev/null +++ b/test/integration/smoke_test.rb @@ -0,0 +1,31 @@ +class SmokeTest < ActionDispatch::IntegrationTest + test "home page" do + get "/" + + assert_response :success + end + + test "about page" do + get "/about" + + assert_response :success + end + + test "companies page" do + get "/companies" + + assert_response :success + end + + test "new bulk deletion request page" do + get "/bulk_deletion_requests/new?regulation=ccpa" + + assert_response :success + end + + test "new bulk deletion request page redirects without regulation" do + get "/bulk_deletion_requests/new" + + assert_redirected_to "/" + end +end diff --git a/test/models/company_test.rb b/test/models/company_test.rb new file mode 100644 index 0000000..b2c8494 --- /dev/null +++ b/test/models/company_test.rb @@ -0,0 +1,78 @@ +# == Schema Information +# +# Table name: companies +# +# id :integer not null, primary key +# category :string not null +# email :string not null +# name :string not null +# website :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_companies_on_category (category) +# index_companies_on_website (website) UNIQUE +# +class CompanyTest < ActiveSupport::TestCase + test "valid object" do + company = Company.new \ + category: "california_data_broker", + email: "test-company@localhost", + name: "Test Company", + website: "https://localhost" + + assert company.valid? + end + + test "invalid without a valid category" do + company = Company.new \ + category: "invalid_category", + email: "test-company@localhost", + name: "Test Company", + website: "https://localhost" + + assert company.invalid? + end + + test "invalid without a valid email" do + company = Company.new \ + category: "california_data_broker", + email: "invalid-email", + name: "Test Company", + website: "https://localhost" + + assert company.invalid? + end + + test "invalid without a name" do + company = Company.new \ + category: "california_data_broker", + email: "test-company@localhost", + website: "https://localhost" + + assert company.invalid? + end + + test "invalid without a website" do + company = Company.new \ + category: "california_data_broker", + email: "test-company@localhost", + name: "Test Company" + + assert company.invalid? + end + + test "#domainify_website! strips www. from website" do + company = Company.new \ + category: "california_data_broker", + email: "test-company@localhost", + name: "Test Company", + website: "https://www.localhost" + + company.domainify_website! + + assert_equal "localhost", company.website + end +end diff --git a/test/models/deletion_request_test.rb b/test/models/deletion_request_test.rb new file mode 100644 index 0000000..f984730 --- /dev/null +++ b/test/models/deletion_request_test.rb @@ -0,0 +1,164 @@ +class DeletionRequestTest < ActiveSupport::TestCase + test "valid object" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "Test deletion request body" + + assert deletion_request.valid? + end + + test "invalid without a valid company" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: Company.new, + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "Test deletion request body" + + assert deletion_request.invalid? + end + + test "invalid without a valid SMTP config" do + smtp_config = SmtpConfig.new \ + provider: "invalid_provider", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "Test deletion request body" + + assert deletion_request.invalid? + end + + test "invalid without an email subject" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_body: "Test deletion request body" + + assert deletion_request.invalid? + end + + test "invalid without an email body" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject" + + assert deletion_request.invalid? + end + + test "invalid with full name token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR FULL NAME]" + + assert deletion_request.invalid? + end + + test "invalid with full address token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR FULL ADDRESS]" + + assert deletion_request.invalid? + end + + test "invalid with email addresses token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR EMAIL ADDRESS(ES)]" + + assert deletion_request.invalid? + end + + test "invalid with phone numbers token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR PHONE NUMBER(S)]" + + assert deletion_request.invalid? + end + + test "invalid with signature name token still remaining" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "[YOUR SIGNATURE NAME]" + + assert deletion_request.invalid? + end + + test "#interpolated_email_body returns expected output" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + deletion_request = DeletionRequest.new \ + company: companies(:california_data_broker), + smtp_config: smtp_config, + email_subject: "Test deletion request subject", + email_body: "Hello {COMPANY NAME}" + + assert_equal "Hello Test CA Broker", deletion_request.interpolated_email_body + end +end diff --git a/test/models/smtp_config_test.rb b/test/models/smtp_config_test.rb new file mode 100644 index 0000000..f24c64a --- /dev/null +++ b/test/models/smtp_config_test.rb @@ -0,0 +1,53 @@ +class SmtpConfigTest < ActiveSupport::TestCase + test "valid object" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + assert smtp_config.valid? + end + + test "invalid without a valid provider" do + smtp_config = SmtpConfig.new \ + provider: "invalid_provider", + username: "test_username", + password: "test_password" + + assert smtp_config.invalid? + end + + test "invalid without username" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + password: "test_password" + + assert smtp_config.invalid? + end + + test "invalid without password" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username" + + assert smtp_config.invalid? + end + + test "#address" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + assert_equal "smtp.gmail.com", smtp_config.address + end + + test "#port" do + smtp_config = SmtpConfig.new \ + provider: "gmail", + username: "test_username", + password: "test_password" + + assert_equal 587, smtp_config.port + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..f2a4bda 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,8 @@ +require "simplecov" +SimpleCov.start "rails" do + add_filter "app/channels" +end + ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" @@ -7,6 +12,10 @@ class TestCase # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) + # Get parallel tests to play nice with SimpleCov. + parallelize_setup { |worker| SimpleCov.command_name("\#{SimpleCov.command_name}-\#{worker}") } + parallelize_teardown { |worker| SimpleCov.result } + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all