From ae6318544549661edd30d4fb3cb9c6d8767434ac Mon Sep 17 00:00:00 2001 From: nora <163450896+24c02@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:45:56 -0500 Subject: [PATCH] CDN V4 (#20) --- .dockerignore | 47 + .env.example | 67 +- .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 38 + .gitignore | 50 +- .rubocop.yml | 8 + .ruby-version | 1 + Dockerfile | 88 +- Gemfile | 72 ++ Gemfile.lock | 497 +++++++++ Procfile.dev | 3 + README.md | 188 ++-- Rakefile | 6 + app/components/admin/search/index.rb | 144 +++ app/components/admin/users/show.rb | 197 ++++ app/components/api_keys/_row.rb | 55 + app/components/api_keys/index.rb | 101 ++ app/components/base.rb | 17 + app/components/docs/content.rb | 32 + app/components/docs/page.rb | 17 + app/components/docs/sidebar.rb | 36 + app/components/header_bar.rb | 41 + app/components/inspector.rb | 28 + app/components/static_pages/base.rb | 51 + app/components/static_pages/home.rb | 128 +++ app/components/static_pages/logged_out.rb | 76 ++ app/components/uploads/_row.rb | 118 +++ app/components/uploads/index.rb | 107 ++ app/controllers/admin/api_keys_controller.rb | 12 + .../admin/application_controller.rb | 14 + app/controllers/admin/search_controller.rb | 24 + app/controllers/admin/uploads_controller.rb | 19 + app/controllers/admin/users_controller.rb | 40 + app/controllers/api/v4/api_keys_controller.rb | 22 + .../api/v4/application_controller.rb | 53 + app/controllers/api/v4/uploads_controller.rb | 108 ++ app/controllers/api/v4/users_controller.rb | 21 + app/controllers/api_keys_controller.rb | 36 + app/controllers/application_controller.rb | 55 + app/controllers/concerns/.keep | 0 app/controllers/docs_controller.rb | 23 + .../external_uploads_controller.rb | 62 ++ app/controllers/sessions_controller.rb | 25 + app/controllers/static_pages_controller.rb | 12 + app/controllers/uploads_controller.rb | 77 ++ app/frontend/controllers/upload_dropzone.js | 112 ++ app/frontend/entrypoints/application.js | 5 + app/frontend/entrypoints/application.scss | 22 + app/frontend/styles/admin_tool.scss | 9 + app/frontend/styles/dark_mode.scss | 0 app/frontend/styles/file_dropzone.scss | 99 ++ app/frontend/styles/hca.scss | 0 app/helpers/application_helper.rb | 6 + app/helpers/quota_helper.rb | 29 + app/helpers/static_pages_helper.rb | 2 + app/jobs/application_job.rb | 7 + app/jobs/refresh_cdn_stats_job.rb | 9 + app/mailers/application_mailer.rb | 4 + app/models/api_key.rb | 45 + app/models/application_record.rb | 12 + app/models/concerns/.keep | 0 app/models/concerns/public_identifiable.rb | 44 + app/models/doc_page.rb | 79 ++ app/models/quota.rb | 11 + app/models/upload.rb | 93 ++ app/models/user.rb | 66 ++ app/policies/api_key_policy.rb | 16 + app/policies/application_policy.rb | 31 + app/policies/upload_policy.rb | 18 + app/services/cdn_stats_service.rb | 65 ++ app/services/flavor_text_service.rb | 241 +++++ app/services/hca_service.rb | 23 + app/services/quota_service.rb | 111 ++ app/views/admin/search/index.html.erb | 1 + app/views/admin/users/show.html.erb | 1 + app/views/api_keys/index.html.erb | 4 + app/views/base.rb | 12 + app/views/docs/pages/api.md | 157 +++ app/views/docs/pages/getting-started.md | 44 + app/views/docs/pages/privacy.md | 18 + app/views/docs/pages/quotas.md | 66 ++ app/views/docs/pages/terms.md | 15 + app/views/docs/pages/using-cdn-urls.md | 60 ++ app/views/docs/show.html.erb | 4 + .../errors/internal_server_error.html.erb | 62 ++ app/views/layouts/application.html.erb | 53 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + app/views/static_pages/home.html.erb | 5 + app/views/uploads/index.html.erb | 1 + bin/brakeman | 7 + bin/dev | 2 + bin/docker-entrypoint | 14 + bin/jobs | 6 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 34 + bin/thrust | 5 + bin/vite | 16 + bun.lockb | Bin 98096 -> 0 bytes config.ru | 6 + config/application.rb | 35 + config/boot.rb | 4 + config/brakeman.ignore | 28 + config/cable.yml | 17 + config/cache.yml | 16 + config/credentials.yml.enc | 1 + config/database.yml | 51 + config/environment.rb | 5 + config/environments/development.rb | 72 ++ config/environments/production.rb | 90 ++ config/environments/test.rb | 53 + config/initializers/assets.rb | 7 + config/initializers/blind_index.rb | 3 + .../initializers/content_security_policy.rb | 43 + .../initializers/filter_parameter_logging.rb | 8 + config/initializers/hack_club_auth.rb | 4 + config/initializers/hashid.rb | 4 + config/initializers/high_voltage.rb | 5 + config/initializers/inflections.rb | 22 + config/initializers/lockbox.rb | 3 + config/initializers/omniauth.rb | 9 + config/initializers/phlex.rb | 16 + config/initializers/sentry.rb | 7 + config/locales/en.yml | 31 + config/puma.rb | 41 + config/queue.yml | 18 + config/recurring.yml | 18 + config/routes.rb | 53 + config/storage.yml | 36 + config/vite.json | 16 + db/cable_schema.rb | 11 + db/cache_schema.rb | 12 + db/migrate/20260127174404_create_users.rb | 14 + db/migrate/20260127_add_slack_id_to_users.rb | 6 + ...te_active_storage_tables.active_storage.rb | 57 ++ db/migrate/20260129051531_create_uploads.rb | 20 + db/migrate/20260129201832_create_api_keys.rb | 16 + ...0260130161152_add_quota_policy_to_users.rb | 5 + db/queue_schema.rb | 129 +++ db/schema.rb | 92 ++ db/seeds.rb | 47 + index.js | 57 -- lib/tasks/.keep | 0 log/.keep | 0 package.json | 30 +- public/400.html | 114 +++ public/404.html | 114 +++ public/406-unsupported-browser.html | 114 +++ public/422.html | 114 +++ public/500.html | 114 +++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 src/api.js | 17 - src/api/deploy.js | 27 - src/api/index.js | 86 -- src/api/upload.js | 121 --- src/api/utils.js | 43 - src/config/logger.js | 19 - src/storage.js | 605 ----------- src/upload.js | 35 - src/utils.js | 8 - storage/.keep | 0 test/application_system_test_case.rb | 5 + test/controllers/.keep | 0 .../api/v4/uploads_controller_test.rb | 98 ++ .../api/v4/users_controller_test.rb | 47 + test/controllers/api_keys_controller_test.rb | 56 + .../static_pages_controller_test.rb | 7 + test/fixtures/api_keys.yml | 17 + test/fixtures/files/.keep | 0 test/fixtures/files/test.png | Bin 0 -> 5182 bytes test/fixtures/users.yml | 15 + test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/models/api_key_test.rb | 72 ++ test/models/user_test.rb | 7 + test/system/.keep | 0 test/test_helper.rb | 15 + tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vite.config.ts | 31 + yarn.lock | 958 ++++++++++++++++++ 193 files changed, 7573 insertions(+), 1169 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Procfile.dev create mode 100644 Rakefile create mode 100644 app/components/admin/search/index.rb create mode 100644 app/components/admin/users/show.rb create mode 100644 app/components/api_keys/_row.rb create mode 100644 app/components/api_keys/index.rb create mode 100644 app/components/base.rb create mode 100644 app/components/docs/content.rb create mode 100644 app/components/docs/page.rb create mode 100644 app/components/docs/sidebar.rb create mode 100644 app/components/header_bar.rb create mode 100644 app/components/inspector.rb create mode 100644 app/components/static_pages/base.rb create mode 100644 app/components/static_pages/home.rb create mode 100644 app/components/static_pages/logged_out.rb create mode 100644 app/components/uploads/_row.rb create mode 100644 app/components/uploads/index.rb create mode 100644 app/controllers/admin/api_keys_controller.rb create mode 100644 app/controllers/admin/application_controller.rb create mode 100644 app/controllers/admin/search_controller.rb create mode 100644 app/controllers/admin/uploads_controller.rb create mode 100644 app/controllers/admin/users_controller.rb create mode 100644 app/controllers/api/v4/api_keys_controller.rb create mode 100644 app/controllers/api/v4/application_controller.rb create mode 100644 app/controllers/api/v4/uploads_controller.rb create mode 100644 app/controllers/api/v4/users_controller.rb create mode 100644 app/controllers/api_keys_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/docs_controller.rb create mode 100644 app/controllers/external_uploads_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/controllers/static_pages_controller.rb create mode 100644 app/controllers/uploads_controller.rb create mode 100644 app/frontend/controllers/upload_dropzone.js create mode 100644 app/frontend/entrypoints/application.js create mode 100644 app/frontend/entrypoints/application.scss create mode 100644 app/frontend/styles/admin_tool.scss create mode 100644 app/frontend/styles/dark_mode.scss create mode 100644 app/frontend/styles/file_dropzone.scss create mode 100644 app/frontend/styles/hca.scss create mode 100644 app/helpers/application_helper.rb create mode 100644 app/helpers/quota_helper.rb create mode 100644 app/helpers/static_pages_helper.rb create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/refresh_cdn_stats_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/api_key.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/concerns/public_identifiable.rb create mode 100644 app/models/doc_page.rb create mode 100644 app/models/quota.rb create mode 100644 app/models/upload.rb create mode 100644 app/models/user.rb create mode 100644 app/policies/api_key_policy.rb create mode 100644 app/policies/application_policy.rb create mode 100644 app/policies/upload_policy.rb create mode 100644 app/services/cdn_stats_service.rb create mode 100644 app/services/flavor_text_service.rb create mode 100644 app/services/hca_service.rb create mode 100644 app/services/quota_service.rb create mode 100644 app/views/admin/search/index.html.erb create mode 100644 app/views/admin/users/show.html.erb create mode 100644 app/views/api_keys/index.html.erb create mode 100644 app/views/base.rb create mode 100644 app/views/docs/pages/api.md create mode 100644 app/views/docs/pages/getting-started.md create mode 100644 app/views/docs/pages/privacy.md create mode 100644 app/views/docs/pages/quotas.md create mode 100644 app/views/docs/pages/terms.md create mode 100644 app/views/docs/pages/using-cdn-urls.md create mode 100644 app/views/docs/show.html.erb create mode 100644 app/views/errors/internal_server_error.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100644 app/views/static_pages/home.html.erb create mode 100644 app/views/uploads/index.html.erb create mode 100755 bin/brakeman create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/jobs create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100755 bin/vite delete mode 100755 bun.lockb create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/brakeman.ignore create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/blind_index.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/hack_club_auth.rb create mode 100644 config/initializers/hashid.rb create mode 100644 config/initializers/high_voltage.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/lockbox.rb create mode 100644 config/initializers/omniauth.rb create mode 100644 config/initializers/phlex.rb create mode 100644 config/initializers/sentry.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 config/vite.json create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/migrate/20260127174404_create_users.rb create mode 100644 db/migrate/20260127_add_slack_id_to_users.rb create mode 100644 db/migrate/20260129051530_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20260129051531_create_uploads.rb create mode 100644 db/migrate/20260129201832_create_api_keys.rb create mode 100644 db/migrate/20260130161152_add_quota_policy_to_users.rb create mode 100644 db/queue_schema.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb delete mode 100644 index.js create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep delete mode 100644 src/api.js delete mode 100644 src/api/deploy.js delete mode 100644 src/api/index.js delete mode 100644 src/api/upload.js delete mode 100644 src/api/utils.js delete mode 100644 src/config/logger.js delete mode 100644 src/storage.js delete mode 100644 src/upload.js delete mode 100644 src/utils.js create mode 100644 storage/.keep create mode 100644 test/application_system_test_case.rb create mode 100644 test/controllers/.keep create mode 100644 test/controllers/api/v4/uploads_controller_test.rb create mode 100644 test/controllers/api/v4/users_controller_test.rb create mode 100644 test/controllers/api_keys_controller_test.rb create mode 100644 test/controllers/static_pages_controller_test.rb create mode 100644 test/fixtures/api_keys.yml create mode 100644 test/fixtures/files/.keep create mode 100644 test/fixtures/files/test.png create mode 100644 test/fixtures/users.yml create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 test/models/api_key_test.rb create mode 100644 test/models/user_test.rb create mode 100644 test/system/.keep create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vite.config.ts create mode 100644 yarn.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7540593 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.env.example b/.env.example index 08b5fc6..f85a825 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,58 @@ -# S3 Config CF in this example -AWS_ACCESS_KEY_ID=1234567890abcdef -AWS_SECRET_ACCESS_KEY=abcdef1234567890 -AWS_BUCKET_NAME=my-cdn-bucket -AWS_REGION=auto -AWS_ENDPOINT=https://.r2.cloudflarestorage.com -AWS_CDN_URL=https://cdn.beans.com +# ============================================================================= +# Cloudflare R2 Storage (S3-compatible) +# ============================================================================= +R2_ACCESS_KEY_ID=your_access_key_id +R2_SECRET_ACCESS_KEY=your_secret_access_key +R2_BUCKET_NAME=your-bucket-name +R2_ENDPOINT=https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com -# API -API_TOKEN=beans # Set a secure random string -PORT=3000 \ No newline at end of file +# Public hostname for CDN URLs (used in generated links) +CDN_HOST=cdn.hackclub.com + +# ============================================================================= +# Hack Club OAuth +# ============================================================================= +# Get credentials from Hack Club Auth (https://auth.hackclub.com) +HACKCLUB_CLIENT_ID=your_client_id +HACKCLUB_CLIENT_SECRET=your_client_secret +# Optional: Override auth URL (defaults to staging in dev, production in prod) +# HACKCLUB_AUTH_URL=https://auth.hackclub.com + +# ============================================================================= +# Encryption Keys +# ============================================================================= +# Generate with: ruby -e "require 'securerandom'; puts SecureRandom.hex(32)" +LOCKBOX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000 +BLIND_INDEX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000 + +# Active Record Encryption (generate with: bin/rails db:encryption:init) +ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=your_primary_key +ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=your_deterministic_key +ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=your_key_derivation_salt + +# ============================================================================= +# Database (production only - dev uses cdn_development/cdn_test) +# ============================================================================= +# DATABASE_HOST=localhost +# DATABASE_USER=cdn +# DATABASE_PASSWORD=your_password +# DATABASE_NAME=cdn_production + +# Solid Cache/Queue/Cable databases (optional, defaults provided) +# CACHE_DATABASE_HOST=localhost +# QUEUE_DATABASE_HOST=localhost +# CABLE_DATABASE_HOST=localhost + +# ============================================================================= +# Optional +# ============================================================================= +# Sentry error tracking +# SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id + +# HashID salt (defaults to SECRET_KEY_BASE if not set) +# HASHID_SALT=your_hashid_salt + +# Rails configuration +# PORT=3000 +# RAILS_MAX_THREADS=5 +# SECRET_KEY_BASE=your_secret_key_base diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0527e6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..394fc4d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github diff --git a/.gitignore b/.gitignore index adb25e2..abd99e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,44 @@ -/node_modules/ -/splitfornpm/ -/.idea/ -/.env -/package-lock.json -/.history \ No newline at end of file +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key + +# Vite Ruby +/public/vite* +node_modules +# Vite uses dotenv and suggests to ignore local-only env files. See +# https://vitejs.dev/guide/env-and-mode.html#env-files +*.local + +.idea + diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..e3cc07a --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.4.4 diff --git a/Dockerfile b/Dockerfile index 737842c..f14b6e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,81 @@ -# Use the official Bun image as base -FROM oven/bun:1 +# syntax=docker/dockerfile:1 +# check=error=true -# install curl for coolify healthcheck -RUN apt-get update && apt-get install -y curl wget +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t cdn . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name cdn cdn -# Set working directory -WORKDIR /app +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html -# Copy package.json and bun.lockb (if exists) -COPY package*.json bun.lockb* ./ +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.4.4 +ARG NODE_VERSION=22 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base -# Install dependencies -RUN bun install +# Rails app lives here +WORKDIR /rails -# Copy the rest of the application +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems and Node.js/Yarn +ARG NODE_VERSION +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config curl && \ + curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \ + apt-get install --no-install-recommends -y nodejs && \ + corepack enable && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Install Node.js dependencies +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile + +# Copy application code COPY . . -# Expose the port your Express server runs on -EXPOSE 3000 +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ -# Start the server -CMD ["bun", "run", "start"] +# Build Vite assets and precompile Rails assets +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails db log storage tmp +USER 1000:1000 + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c158cd0 --- /dev/null +++ b/Gemfile @@ -0,0 +1,72 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.0.4" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use postgres as the database for Active Record +gem "pg", "~> 1.3" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +# gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end +gem "jb" +gem "pry-rails", group: :development +gem "awesome_print" +gem "dotenv-rails", groups: [ :development, :test ] +gem "hashid-rails" +gem "vite_rails" +gem "phlex-rails" +gem "omniauth" +gem "omniauth-hack_club" +gem "faraday" +gem "pundit" +gem "primer_view_components" +gem "pg_search" +gem "kaminari" +gem "high_voltage" +gem "redcarpet" +gem "lockbox" +gem "blind_index" +gem "sentry-ruby" +gem "sentry-rails" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..00a34e0 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,497 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (8.0.4) + actionpack (= 8.0.4) + activesupport (= 8.0.4) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.4) + actionpack (= 8.0.4) + activejob (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + mail (>= 2.8.0) + actionmailer (8.0.4) + actionpack (= 8.0.4) + actionview (= 8.0.4) + activejob (= 8.0.4) + activesupport (= 8.0.4) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.4) + actionview (= 8.0.4) + activesupport (= 8.0.4) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.0.4) + actionpack (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.4) + activesupport (= 8.0.4) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.0.4) + activesupport (= 8.0.4) + globalid (>= 0.3.6) + activemodel (8.0.4) + activesupport (= 8.0.4) + activerecord (8.0.4) + activemodel (= 8.0.4) + activesupport (= 8.0.4) + timeout (>= 0.4.0) + activestorage (8.0.4) + actionpack (= 8.0.4) + activejob (= 8.0.4) + activerecord (= 8.0.4) + activesupport (= 8.0.4) + marcel (~> 1.0) + activesupport (8.0.4) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + argon2-kdf (0.3.1) + fiddle + ast (2.4.3) + awesome_print (1.9.2) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.0.1) + bindex (0.8.1) + blind_index (2.7.0) + activesupport (>= 7.1) + argon2-kdf (>= 0.2) + bootsnap (1.21.1) + msgpack (~> 1.2) + brakeman (8.0.1) + racc + builder (3.3.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + coderay (1.1.3) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) + crass (1.0.6) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.2.0) + dotenv-rails (3.2.0) + dotenv (= 3.2.0) + railties (>= 6.1) + drb (2.2.3) + dry-cli (1.4.1) + erb (6.0.1) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + fiddle (1.1.8) + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + hashid-rails (1.4.1) + activerecord (>= 4.0) + hashids (~> 1.0) + hashids (1.0.6) + hashie (5.1.0) + logger + high_voltage (5.0.0) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + io-console (0.8.2) + irb (1.16.0) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jb (0.8.2) + json (2.18.0) + jwt (3.1.2) + base64 + kaminari (1.2.2) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) + actionview + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + lockbox (2.1.0) + logger (1.7.0) + loofah (2.25.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + method_source (1.1.0) + mini_mime (1.1.5) + minitest (6.0.1) + prism (~> 1.5) + msgpack (1.8.0) + multi_xml (0.8.1) + bigdecimal (>= 3.1, < 5) + mutex_m (0.3.0) + net-http (0.9.1) + uri (>= 0.11.1) + net-imap (0.6.2) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.0-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.0-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.0-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.0-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.0-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-musl) + racc (~> 1.4) + oauth2 (2.0.18) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (~> 1.1, >= 1.1.9) + octicons (19.21.2) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-hack_club (1.0.1) + omniauth-oauth2 (~> 1.8) + omniauth-oauth2 (1.9.0) + oauth2 (>= 2.0.2, < 3) + omniauth (~> 2.0) + parallel (1.27.0) + parser (3.3.10.1) + ast (~> 2.4.1) + racc + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) + pg_search (2.3.7) + activerecord (>= 6.1) + activesupport (>= 6.1) + phlex (2.4.0) + refract (~> 1.0) + zeitwerk (~> 2.7) + phlex-rails (2.4.0) + phlex (~> 2.4.0) + railties (>= 7.1, < 9) + zeitwerk (~> 2.7) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + primer_view_components (0.49.0) + actionview (>= 7.2.0) + activesupport (>= 7.2.0) + octicons (>= 18.0.0) + view_component (>= 3.1, < 5.0) + prism (1.8.0) + propshaft (1.3.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + pry (0.16.0) + coderay (~> 1.1) + method_source (~> 1.0) + reline (>= 0.6.0) + pry-rails (0.3.11) + pry (>= 0.13.0) + psych (5.3.1) + date + stringio + public_suffix (7.0.2) + puma (7.2.0) + nio4r (~> 2.0) + pundit (2.5.2) + activesupport (>= 3.0.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.4) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + rack-proxy (0.7.7) + rack + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.0.4) + actioncable (= 8.0.4) + actionmailbox (= 8.0.4) + actionmailer (= 8.0.4) + actionpack (= 8.0.4) + actiontext (= 8.0.4) + actionview (= 8.0.4) + activejob (= 8.0.4) + activemodel (= 8.0.4) + activerecord (= 8.0.4) + activestorage (= 8.0.4) + activesupport (= 8.0.4) + bundler (>= 1.15.0) + railties (= 8.0.4) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.0.4) + actionpack (= 8.0.4) + activesupport (= 8.0.4) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.1) + rdoc (7.1.0) + erb + psych (>= 4.0.0) + tsort + redcarpet (3.6.1) + refract (1.1.0) + prism + zeitwerk + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + rexml (3.4.4) + rubocop (1.84.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.3) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + rubyzip (3.2.2) + securerandom (0.4.1) + selenium-webdriver (4.40.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + sentry-rails (6.3.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.3.0) + sentry-ruby (6.3.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.3.1) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) + stringio (3.2.0) + thor (1.5.0) + thruster (0.1.17) + thruster (0.1.17-aarch64-linux) + thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-darwin) + thruster (0.1.17-x86_64-linux) + timeout (0.6.0) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + version_gem (1.1.9) + view_component (4.2.0) + actionview (>= 7.1.0) + activesupport (>= 7.1.0) + concurrent-ruby (~> 1) + vite_rails (3.0.20) + railties (>= 5.1, < 9) + vite_ruby (~> 3.0, >= 3.2.2) + vite_ruby (3.9.2) + dry-cli (>= 0.7, < 2) + logger (~> 1.6) + mutex_m + rack-proxy (~> 0.6, >= 0.6.1) + zeitwerk (~> 2.2) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.4) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + awesome_print + blind_index + bootsnap + brakeman + capybara + debug + dotenv-rails + faraday + hashid-rails + high_voltage + jb + kaminari + lockbox + omniauth + omniauth-hack_club + pg (~> 1.3) + pg_search + phlex-rails + primer_view_components + propshaft + pry-rails + puma (>= 5.0) + pundit + rails (~> 8.0.4) + redcarpet + rubocop-rails-omakase + selenium-webdriver + sentry-rails + sentry-ruby + solid_cable + solid_cache + solid_queue + thruster + tzinfo-data + vite_rails + web-console + +BUNDLED WITH + 2.7.2 diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..1acf605 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,3 @@ + +vite: bin/vite dev +web: bin/rails s diff --git a/README.md b/README.md index 2c1b1f0..166f6d8 100644 --- a/README.md +++ b/README.md @@ -14,122 +14,106 @@ +--- -## 📡 API Usage +A Rails 8 application for hosting and managing CDN uploads, with OAuth authentication via Hack Club. -- All API endpoints require authentication via `Authorization: Bearer api-token` header -- Use the API_TOKEN from your environment configuration -- Failure to include a valid token will result in 401 Unauthorized responses +## Prerequisites -### V3 API (Latest) -Version 3 +- Ruby 3.4.4 (see `.ruby-version`) +- PostgreSQL +- Node.js + Yarn (for Vite frontend) +- A Cloudflare R2 bucket (or S3-compatible storage) -**Endpoint:** `POST https://cdn.hackclub.com/api/v3/new` +## Setup -**Headers:** -``` -Authorization: Bearer api-token -Content-Type: application/json -``` +1. **Clone and install dependencies:** + ```bash + git clone https://github.com/hackclub/cdn.git + cd cdn + bundle install + yarn install + ``` -**Request Example:** +2. **Configure environment variables:** + ```bash + cp .env.example .env + ``` + Edit `.env` with your credentials (see below for details). + +3. **Setup the database:** + ```bash + bin/rails db:create db:migrate + ``` + +4. **Generate encryption keys** (for API key encryption): + ```bash + # Generate a 32-byte hex key for Lockbox + ruby -e "require 'securerandom'; puts SecureRandom.hex(32)" + + # Generate a 32-byte hex key for BlindIndex + ruby -e "require 'securerandom'; puts SecureRandom.hex(32)" + + # Generate Active Record encryption keys + bin/rails db:encryption:init + ``` + +5. **Start the development servers:** + ```bash + # In one terminal, run the Vite dev server: + bin/vite dev + + # In another terminal, run the Rails server: + bin/rails server + ``` + +## Environment Variables + +See `.env.example` for the full list. Key variables: + +| Variable | Description | +|----------|-------------| +| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key | +| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key | +| `R2_BUCKET_NAME` | R2 bucket name | +| `R2_ENDPOINT` | R2 endpoint URL | +| `CDN_HOST` | Public hostname for CDN URLs | +| `HACKCLUB_CLIENT_ID` | OAuth client ID from Hack Club Auth | +| `HACKCLUB_CLIENT_SECRET` | OAuth client secret | +| `LOCKBOX_MASTER_KEY` | 64-char hex key for encrypting API keys | +| `BLIND_INDEX_MASTER_KEY` | 64-char hex key for searchable encryption | + +## API + +The API uses bearer token authentication. Create an API key from the web dashboard after logging in. + +**Upload a file:** ```bash -curl --location 'https://cdn.hackclub.com/api/v3/new' \ ---header 'Authorization: Bearer beans' \ ---header 'Content-Type: application/json' \ ---data '[ - "https://assets.hackclub.com/flag-standalone.svg", - "https://assets.hackclub.com/flag-orpheus-left.png" -]' +curl -X POST https://cdn.hackclub.com/api/v4/upload \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -F "file=@image.png" ``` -**Response:** -```json -{ - "files": [ - { - "deployedUrl": "https://hc-cdn.hel1.your-objectstorage.com/s/v3/64a9472006c4472d7ac75f2d4d9455025d9838d6_flag-standalone.svg", - "file": "0_64a9472006c4472d7ac75f2d4d9455025d9838d6_flag-standalone.svg", - "sha": "64a9472006c4472d7ac75f2d4d9455025d9838d6", - "size": 4365 - }, - { - "deployedUrl": "https://hc-cdn.hel1.your-objectstorage.com/s/v3/d926bfd9811ebfe9172187793a171a5cbcc61992_flag-orpheus-left.png", - "file": "1_d926bfd9811ebfe9172187793a171a5cbcc61992_flag-orpheus-left.png", - "sha": "d926bfd9811ebfe9172187793a171a5cbcc61992", - "size": 8126 - } - ], - "cdnBase": "https://hc-cdn.hel1.your-objectstorage.com" -} +**Upload from URL:** +```bash +curl -X POST https://cdn.hackclub.com/api/v4/upload_from_url \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/image.png"}' ``` -
-V2 API +See `/docs` in the running app for full API documentation. -Version 2 +## Architecture -**Endpoint:** `POST https://cdn.hackclub.com/api/v2/new` - -**Headers:** -``` -Authorization: Bearer api-token -Content-Type: application/json -``` - -**Request Example:** -```json -[ - "https://assets.hackclub.com/flag-standalone.svg", - "https://assets.hackclub.com/flag-orpheus-left.png" -] -``` - -**Response:** -```json -{ - "flag-standalone.svg": "https://cdn.example.dev/s/v2/flag-standalone.svg", - "flag-orpheus-left.png": "https://cdn.example.dev/s/v2/flag-orpheus-left.png" -} -``` -
- -
-V1 API - -Version 1 - -**Endpoint:** `POST https://cdn.hackclub.com/api/v1/new` - -**Headers:** -``` -Authorization: Bearer api-token -Content-Type: application/json -``` - -**Request Example:** -```json -[ - "https://assets.hackclub.com/flag-standalone.svg", - "https://assets.hackclub.com/flag-orpheus-left.png" -] -``` - -**Response:** -```json -[ - "https://cdn.example.dev/s/v1/0_flag-standalone.svg", - "https://cdn.example.dev/s/v1/1_flag-orpheus-left.png" -] -``` -
- -# Technical Details - -- **Storage Structure:** `/s/v3/{HASH}_{filename}` -- **File Naming:** `/s/{slackUserId}/{unix}_{sanitizedFilename}` +- **Rails 8** with **Vite** for frontend assets +- **Phlex** + **Primer ViewComponents** for UI +- **Active Storage** with Cloudflare R2 backend +- **Solid Queue/Cache/Cable** for background jobs and caching (production) +- **Pundit** for authorization +- **Lockbox + BlindIndex** for API key encryption

Made with 💜 for Hack Club

-
\ No newline at end of file + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/app/components/admin/search/index.rb b/app/components/admin/search/index.rb new file mode 100644 index 0000000..4aa72de --- /dev/null +++ b/app/components/admin/search/index.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +class Components::Admin::Search::Index < Components::Base + include Phlex::Rails::Helpers::FormWith + include Phlex::Rails::Helpers::LinkTo + + def initialize(query: nil, users: [], uploads: [], type: "all") + @query = query + @users = users + @uploads = uploads + @type = type + end + + def view_template + div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do + header_section + tabs_section + search_form + results_section if @query.present? + end + end + + private + + def header_section + header(style: "margin-bottom: 24px;") do + h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "Admin Search" } + p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do + "Search users and uploads by ID, email, filename, URL, etc." + end + end + end + + def tabs_section + render Primer::Alpha::UnderlineNav.new(label: "Search type") do |nav| + nav.with_tab(selected: @type == "all", href: admin_search_path(type: "all", q: @query)) { "All" } + nav.with_tab(selected: @type == "users", href: admin_search_path(type: "users", q: @query)) { "Users" } + nav.with_tab(selected: @type == "uploads", href: admin_search_path(type: "uploads", q: @query)) { "Uploads" } + end + end + + def search_form + div(style: "margin-bottom: 24px; margin-top: 16px;") do + form_with url: admin_search_path, method: :get, style: "display: flex; gap: 8px;" do + input(type: "hidden", name: "type", value: @type) + input( + type: "search", + name: "q", + placeholder: search_placeholder, + value: @query, + class: "form-control", + style: "flex: 1; max-width: 600px;", + autofocus: true + ) + button(type: "submit", class: "btn btn-primary") do + render Primer::Beta::Octicon.new(icon: :search, mr: 1) + plain "Search" + end + end + end + end + + def search_placeholder + case @type + when "users" then "Search by ID, email, name, slack_id..." + when "uploads" then "Search by ID, filename, URL, uploader..." + else "Search by ID, email, filename, URL..." + end + end + + def results_section + if @users.empty? && @uploads.empty? + empty_state + else + users_section if @users.any? + uploads_section if @uploads.any? + end + end + + def users_section + div(style: "margin-bottom: 32px;") do + h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") do + plain "Users " + render(Primer::Beta::Label.new(scheme: :secondary)) { plain @users.size.to_s } + end + render Primer::Beta::BorderBox.new do |box| + @users.each do |user| + box.with_row do + user_row(user) + end + end + end + end + end + + def user_row(user) + div(style: "display: flex; justify-content: space-between; align-items: center;") do + div do + div(style: "font-weight: 500;") { user.name || "Unnamed" } + div(style: "font-size: 12px; color: var(--fgColor-muted);") do + plain user.email + plain " · " + code(style: "font-size: 11px;") { user.public_id } + end + end + div(style: "display: flex; align-items: center; gap: 16px;") do + div(style: "text-align: right; font-size: 12px; color: var(--fgColor-muted);") do + div { "#{user.total_files} files" } + div { user.total_storage_formatted } + end + if user.is_admin? + render(Primer::Beta::Label.new(scheme: :accent)) { plain "ADMIN" } + end + link_to admin_user_path(user), class: "btn btn-sm", title: "View user" do + render Primer::Beta::Octicon.new(icon: :eye, size: :small) + end + end + end + end + + def uploads_section + div do + h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") do + plain "Uploads " + render(Primer::Beta::Label.new(scheme: :secondary)) { plain @uploads.size.to_s } + end + render Primer::Beta::BorderBox.new do |box| + @uploads.each do |upload| + box.with_row do + render Components::Uploads::Row.new(upload: upload, compact: true, admin: true) + end + end + end + end + end + + def empty_state + render Primer::Beta::Blankslate.new(border: true) do |component| + component.with_visual_icon(icon: :search) + component.with_heading(tag: :h2) { "No results found" } + component.with_description { "Try a different search query" } + end + end +end diff --git a/app/components/admin/users/show.rb b/app/components/admin/users/show.rb new file mode 100644 index 0000000..89f783e --- /dev/null +++ b/app/components/admin/users/show.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +class Components::Admin::Users::Show < Components::Base + include Phlex::Rails::Helpers::LinkTo + include Phlex::Rails::Helpers::ButtonTo + + def initialize(user:) + @user = user + end + + def view_template + div(style: "max-width: 800px; margin: 0 auto; padding: 24px;") do + header_section + stats_section + quota_section + api_keys_section + uploads_section + end + end + + private + + def header_section + header(style: "margin-bottom: 24px;") do + div(style: "display: flex; justify-content: space-between; align-items: flex-start;") do + div do + div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do + h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { @user.name || "Unnamed User" } + if @user.is_admin? + render(Primer::Beta::Label.new(scheme: :accent)) { plain "ADMIN" } + end + end + p(style: "color: var(--fgColor-muted, #656d76); margin: 0; font-size: 14px;") do + plain @user.email + plain " · " + code(style: "font-size: 12px;") { @user.public_id } + end + if @user.slack_id.present? + p(style: "color: var(--fgColor-muted); margin: 4px 0 0; font-size: 12px;") do + plain "Slack: " + code { @user.slack_id } + end + end + end + link_to admin_search_path, class: "btn" do + render Primer::Beta::Octicon.new(icon: :"arrow-left", mr: 1) + plain "Back to Search" + end + end + end + end + + def stats_section + div(style: "display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px;") do + stat_card("Total Files", @user.total_files.to_s) + stat_card("Total Storage", @user.total_storage_formatted) + stat_card("Member Since", @user.created_at.strftime("%b %d, %Y")) + end + end + + def stat_card(label, value) + render Primer::Beta::BorderBox.new do |box| + box.with_body(padding: :normal) do + div(style: "font-size: 12px; color: var(--fgColor-muted);") { label } + div(style: "font-size: 24px; font-weight: 600; margin-top: 4px;") { value } + end + end + end + + def quota_section + quota_service = QuotaService.new(@user) + usage = quota_service.current_usage + policy = quota_service.current_policy + + div(style: "margin-bottom: 24px;") do + h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") { "Quota Management" } + render Primer::Beta::BorderBox.new do |box| + box.with_body(padding: :normal) do + # Current policy + div(style: "margin-bottom: 16px;") do + div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do + span(style: "font-weight: 500;") { "Current Policy:" } + render(Primer::Beta::Label.new(scheme: quota_policy_scheme)) { policy.slug.to_s.humanize } + if @user.quota_policy.present? + render(Primer::Beta::Label.new(scheme: :accent)) { "Override" } + end + end + div(style: "font-size: 12px; color: var(--fgColor-muted);") do + plain "Per-file limit: #{helpers.number_to_human_size(policy.max_file_size)} · " + plain "Total storage: #{helpers.number_to_human_size(policy.max_total_storage)}" + end + end + + # Usage stats + div(style: "margin-bottom: 16px;") do + div(style: "font-weight: 500; margin-bottom: 4px;") { "Storage Usage" } + div(style: "font-size: 14px; margin-bottom: 4px;") do + plain "#{helpers.number_to_human_size(usage[:storage_used])} / #{helpers.number_to_human_size(usage[:storage_limit])} " + span(style: "color: var(--fgColor-muted);") { "(#{usage[:percentage_used]}%)" } + end + # Progress bar + div(style: "background: var(--bgColor-muted); border-radius: 3px; height: 8px; overflow: hidden;") do + div(style: "background: #{progress_bar_color(usage[:percentage_used])}; height: 100%; width: #{[ usage[:percentage_used], 100 ].min}%;") + end + end + + # Admin controls + form(action: helpers.set_quota_admin_user_path(@user), method: :post, style: "display: flex; gap: 8px; align-items: center;") do + input(type: "hidden", name: "_method", value: "patch") + input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token) + + render(Primer::Alpha::Select.new(name: "quota_policy", size: :small)) do |select| + select.with_option(label: "Auto-detect (via HCA)", value: "", selected: @user.quota_policy.nil?) + select.with_option(label: "Verified", value: "verified", selected: @user.quota_policy == "verified") + select.with_option(label: "Functionally Unlimited", value: "functionally_unlimited", selected: @user.quota_policy == "functionally_unlimited") + end + + button(type: "submit", class: "btn btn-sm btn-primary") { "Set Policy" } + end + end + end + end + end + + def quota_policy_scheme + case @user.quota_policy&.to_sym + when :functionally_unlimited + :success + when :verified + :accent + else + :default + end + end + + def progress_bar_color(percentage) + if percentage >= 100 + "var(--bgColor-danger-emphasis)" + elsif percentage >= 80 + "var(--bgColor-attention-emphasis)" + else + "var(--bgColor-success-emphasis)" + end + end + + def api_keys_section + api_keys = @user.api_keys.recent + return if api_keys.empty? + + div(style: "margin-bottom: 24px;") do + h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") { "API Keys" } + render Primer::Beta::BorderBox.new do |box| + api_keys.each do |api_key| + box.with_row do + api_key_row(api_key) + end + end + end + end + end + + def api_key_row(api_key) + div(style: "display: flex; justify-content: space-between; align-items: center;") do + div do + div(style: "font-weight: 500;") { api_key.name } + code(style: "font-size: 12px; color: var(--fgColor-muted);") { api_key.masked_token } + end + div(style: "display: flex; align-items: center; gap: 12px;") do + if api_key.revoked? + render(Primer::Beta::Label.new(scheme: :danger)) { plain "REVOKED" } + else + render(Primer::Beta::Label.new(scheme: :success)) { plain "ACTIVE" } + button_to helpers.admin_api_key_path(api_key), method: :delete, class: "btn btn-sm btn-danger", data: { confirm: "Revoke this API key?" } do + plain "Revoke" + end + end + span(style: "font-size: 12px; color: var(--fgColor-muted);") { api_key.created_at.strftime("%b %d, %Y") } + end + end + end + + def uploads_section + uploads = @user.uploads.includes(:blob).order(created_at: :desc).limit(20) + return if uploads.empty? + + div do + h2(style: "font-size: 1.25rem; font-weight: 600; margin-bottom: 12px;") { "Recent Uploads" } + render Primer::Beta::BorderBox.new do |box| + uploads.each do |upload| + box.with_row do + render Components::Uploads::Row.new(upload: upload, compact: true, admin: true) + end + end + end + end + end +end diff --git a/app/components/api_keys/_row.rb b/app/components/api_keys/_row.rb new file mode 100644 index 0000000..7d9601e --- /dev/null +++ b/app/components/api_keys/_row.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Components::APIKeys::Row < Components::Base + include Phlex::Rails::Helpers::FormWith + + def initialize(api_key:) + @api_key = api_key + end + + def view_template + div(style: "display: flex; justify-content: space-between; align-items: flex-start; gap: 16px;") do + div(style: "flex: 1; min-width: 0;") do + div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do + render Primer::Beta::Octicon.new(icon: :key, size: :small, color: :muted) + span(style: "font-size: 14px; font-weight: 500;") { api_key.name } + end + code(style: "font-size: 12px; color: var(--fgColor-muted, #656d76);") { api_key.masked_token } + div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin-top: 4px;") do + plain "Created #{time_ago_in_words(api_key.created_at)} ago" + end + end + + render_revoke_dialog + end + end + + private + + attr_reader :api_key + + def render_revoke_dialog + render Primer::Alpha::Dialog.new(title: "Revoke API key?", size: :medium) do |dialog| + dialog.with_show_button(scheme: :danger, size: :small) do + render Primer::Beta::Octicon.new(icon: :trash) + end + dialog.with_header(variant: :large) do + h1(style: "margin: 0;") { "Revoke \"#{api_key.name}\"?" } + end + dialog.with_body do + p(style: "margin: 0;") do + plain "This action cannot be undone. Any applications using this API key will immediately lose access." + end + end + dialog.with_footer do + div(style: "display: flex; justify-content: flex-end; gap: 8px;") do + form_with url: api_key_path(api_key), method: :delete, style: "display: inline;" do + button(type: "submit", class: "btn btn-danger") do + plain "Revoke key" + end + end + end + end + end + end +end diff --git a/app/components/api_keys/index.rb b/app/components/api_keys/index.rb new file mode 100644 index 0000000..b4a3447 --- /dev/null +++ b/app/components/api_keys/index.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +class Components::APIKeys::Index < Components::Base + include Phlex::Rails::Helpers::FormWith + + def initialize(api_keys:, new_token: nil) + @api_keys = api_keys + @new_token = new_token + end + + def view_template + div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do + header_section + new_token_alert if new_token + create_form + api_keys_list + end + end + + private + + attr_reader :api_keys, :new_token + + def header_section + header(style: "margin-bottom: 24px;") do + h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "API Keys" } + p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do + plain "Manage your API keys for programmatic access. " + a(href: "/docs/api", style: "color: var(--fgColor-accent, #0969da);") { "View API documentation" } + end + end + end + + def new_token_alert + render Primer::Beta::Flash.new(scheme: :success, mb: 4) do |component| + component.with_icon(icon: :check) + div do + p(style: "margin: 0 0 8px; font-weight: 600;") { "API key created successfully!" } + p(style: "margin: 0 0 8px;") { "Copy your API key now. You won't be able to see it again!" } + code(style: "display: block; padding: 12px; background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default); border-radius: 6px; font-size: 14px; word-break: break-all;") do + plain new_token + end + end + end + end + + def create_form + render Primer::Beta::BorderBox.new(mb: 4) do |box| + box.with_header do + h2(style: "font-size: 14px; font-weight: 600; margin: 0;") { "Create new API key" } + end + box.with_body do + form_with url: api_keys_path, method: :post do + div(style: "margin-bottom: 12px; max-width: 400px;") do + label(for: "api_key_name", style: "display: block; font-size: 14px; font-weight: 600; margin-bottom: 8px;") do + plain "Key name" + end + input( + type: "text", + name: "api_key[name]", + id: "api_key_name", + placeholder: "e.g., My App", + required: true, + class: "form-control" + ) + end + button(type: "submit", class: "btn btn-primary") do + render Primer::Beta::Octicon.new(icon: :key, mr: 1) + plain "Create key" + end + end + end + end + end + + def api_keys_list + div do + h2(style: "font-size: 1.25rem; font-weight: 600; margin: 0 0 16px;") { "Your API keys" } + + if api_keys.any? + render Primer::Beta::BorderBox.new do |box| + api_keys.each do |api_key| + box.with_row do + render Components::APIKeys::Row.new(api_key: api_key) + end + end + end + else + empty_state + end + end + end + + def empty_state + render Primer::Beta::Blankslate.new(border: true) do |component| + component.with_visual_icon(icon: :key) + component.with_heading(tag: :h3) { "No API keys yet" } + component.with_description { "Create your first API key to get started with the API" } + end + end +end diff --git a/app/components/base.rb b/app/components/base.rb new file mode 100644 index 0000000..884140d --- /dev/null +++ b/app/components/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Components::Base < Phlex::HTML + register_value_helper :admin_tool + register_value_helper :current_user + # Include any helpers you want to be available across all components + include Phlex::Rails::Helpers::Routes + include Phlex::Rails::Helpers::ButtonTo + include Phlex::Rails::Helpers::TimeAgoInWords + + if Rails.env.development? + def before_template + comment { "Before #{self.class.name}" } + super + end + end +end diff --git a/app/components/docs/content.rb b/app/components/docs/content.rb new file mode 100644 index 0000000..28b20d0 --- /dev/null +++ b/app/components/docs/content.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Components::Docs::Content < Components::Base + def initialize(doc:) + @doc = doc + end + + def view_template + style do + raw(<<~CSS.html_safe) + .markdown-body h1 { font-size: 2em; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--borderColor-default); } + .markdown-body h2 { font-size: 1.5em; margin-top: 24px; margin-bottom: 16px; } + .markdown-body h3 { font-size: 1.25em; margin-top: 24px; margin-bottom: 16px; } + .markdown-body p { margin-bottom: 16px; line-height: 1.6; } + .markdown-body ul, .markdown-body ol { padding-left: 2em; margin-bottom: 16px; } + .markdown-body li { margin-bottom: 4px; } + .markdown-body code { background: var(--bgColor-muted); padding: 2px 6px; border-radius: 4px; font-size: 85%; } + .markdown-body pre { background: var(--bgColor-muted); padding: 16px; border-radius: 6px; overflow-x: auto; margin-bottom: 16px; } + .markdown-body pre code { background: none; padding: 0; } + .markdown-body a { color: var(--fgColor-accent); } + .markdown-body blockquote { padding: 0 1em; color: var(--fgColor-muted); border-left: 4px solid var(--borderColor-default); margin-bottom: 16px; } + .markdown-body table { border-collapse: collapse; margin-bottom: 16px; width: 100%; } + .markdown-body th, .markdown-body td { border: 1px solid var(--borderColor-default); padding: 8px 12px; } + .markdown-body th { background: var(--bgColor-muted); } + CSS + end + + article(class: "markdown-body") do + raw @doc.content.html_safe + end + end +end diff --git a/app/components/docs/page.rb b/app/components/docs/page.rb new file mode 100644 index 0000000..daf257d --- /dev/null +++ b/app/components/docs/page.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Components::Docs::Page < Components::Base + def initialize(doc:, docs:) + @doc = doc + @docs = docs + end + + def view_template + div(class: "d-flex", style: "min-height: calc(100vh - 64px);") do + render Components::Docs::Sidebar.new(docs: @docs, current_doc: @doc) + main(class: "flex-auto p-4 p-md-5", style: "max-width: 900px;") do + render Components::Docs::Content.new(doc: @doc) + end + end + end +end diff --git a/app/components/docs/sidebar.rb b/app/components/docs/sidebar.rb new file mode 100644 index 0000000..99a09bc --- /dev/null +++ b/app/components/docs/sidebar.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Components::Docs::Sidebar < Components::Base + def initialize(docs:, current_doc:) + @docs = docs + @current_doc = current_doc + end + + def view_template + aside( + class: "color-bg-subtle border-right", + style: "width: 280px; min-width: 280px; padding: 24px 16px;" + ) do + div(class: "mb-3") do + a(href: root_path, class: "color-fg-muted text-small d-flex flex-items-center") do + render Primer::Beta::Octicon.new(icon: "arrow-left", size: :small, mr: 1) + plain "Back to CDN" + end + end + + h2(class: "h5 mb-3") { "Documentation" } + + render Primer::Beta::NavList.new(aria: { label: "Documentation" }) do |nav| + @docs.each do |doc| + nav.with_item( + label: doc.title, + href: doc_path(doc.id), + selected: doc.id == @current_doc.id + ) do |item| + item.with_leading_visual_icon(icon: doc.icon) + end + end + end + end + end +end diff --git a/app/components/header_bar.rb b/app/components/header_bar.rb new file mode 100644 index 0000000..bb25891 --- /dev/null +++ b/app/components/header_bar.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Components::HeaderBar < Components::Base + register_value_helper :signed_in? + register_value_helper :impersonating? + + def view_template + header(class: "app-header", style: "display: flex; align-items: center; justify-content: space-between;") do + div(style: "display: flex; align-items: center; gap: 1rem;") do + a(href: root_path, class: "app-header-brand", style: "text-decoration: none; color: inherit;") do + plain "Hack Club CDN" + sup(class: "app-header-env-badge") { "(dev)" } if Rails.env.development? + end + nav(style: "display: flex; align-items: center; gap: 1rem; margin-left: 1rem;") do + if signed_in? + a(href: uploads_path, style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "Uploads" } + a(href: api_keys_path, style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "API Keys" } + end + a(href: doc_path("getting-started"), style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "Docs" } + admin_tool(element: "span") do + a(href: admin_search_path, style: "color: var(--fgColor-default); text-decoration: none; font-size: 14px;") { "Search" } + end + end + end + + return unless signed_in? + div(style: "display: flex; align-items: center; gap: 0.5rem;") do + render(Primer::Alpha::ActionMenu.new(anchor_align: :end)) do |menu| + menu.with_show_button(scheme: :invisible) do |btn| + btn.with_leading_visual_icon(icon: impersonating? ? :eye : :person) + plain current_user.name + end + + menu.with_item(label: "Log out", href: logout_path, form_arguments: { method: :delete }) do |item| + item.with_leading_visual_icon(icon: :"sign-out") + end + end + end + end + end +end diff --git a/app/components/inspector.rb b/app/components/inspector.rb new file mode 100644 index 0000000..4ac2488 --- /dev/null +++ b/app/components/inspector.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Components::Inspector < Components::Base + def initialize(object:) + @object = object + end + + def view_template + admin_tool do + details class: "inspector" do + summary { record_id } + pre class: "inspector-content" do + unless @object.nil? + raw safe(ap @object) + else + plain "nil" + end + end + end + end + end + + private + + def record_id + "#{@object.class.name} #{@object&.try(:public_id) || @object&.id}" + end +end diff --git a/app/components/static_pages/base.rb b/app/components/static_pages/base.rb new file mode 100644 index 0000000..2982f68 --- /dev/null +++ b/app/components/static_pages/base.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Components::StaticPages::Base < Components::Base + def stat_card(title, value, icon) + div(style: "padding: 14px; background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;") do + div(style: "display: flex; justify-content: space-between; align-items: flex-start;") do + div do + p(style: "font-size: 11px; color: var(--fgColor-muted, #656d76); margin: 0 0 4px; text-transform: uppercase; letter-spacing: 0.3px;") { title } + span(style: "font-size: 28px; font-weight: 600; line-height: 1;") { value.to_s } + end + span(style: "color: var(--fgColor-muted, #656d76);") do + render Primer::Beta::Octicon.new(icon: icon, size: :small) + end + end + end + end + + def link_panel(title, links) + div(style: "background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px; overflow: hidden;") do + div(style: "padding: 12px 16px; border-bottom: 1px solid var(--borderColor-default, #d0d7de); background: var(--bgColor-muted, #f6f8fa);") do + h3(style: "font-size: 14px; font-weight: 600; margin: 0;") { title } + end + div(style: "padding: 8px 0;") do + links.each do |link| + a( + href: link[:href], + target: link[:href].start_with?("http") ? "_blank" : nil, + rel: link[:href].start_with?("http") ? "noopener" : nil, + style: "display: flex; align-items: center; gap: 12px; padding: 10px 16px; text-decoration: none; color: inherit;" + ) do + span(style: "color: var(--fgColor-muted, #656d76);") do + render Primer::Beta::Octicon.new(icon: link[:icon], size: :small) + end + span(style: "font-size: 14px;") { link[:label] } + end + end + end + end + end + + def resources_panel + links = [ + { label: "Documentation", href: doc_path("getting-started"), icon: :book }, + { label: "GitHub Repo", href: "https://github.com/hackclub/cdn", icon: :"mark-github" }, + { label: "Use via Slack", href: "https://hackclub.enterprise.slack.com/archives/C016DEDUL87", icon: :"comment-discussion" }, + { label: "Help with development?", href: "https://hackclub.enterprise.slack.com/archives/C0ACGUA6XTJ", icon: :heart }, + { label: "Report an Issue", href: "https://github.com/hackclub/cdn/issues", icon: :"issue-opened" } + ] + link_panel("Resources", links) + end +end diff --git a/app/components/static_pages/home.rb b/app/components/static_pages/home.rb new file mode 100644 index 0000000..005de57 --- /dev/null +++ b/app/components/static_pages/home.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +class Components::StaticPages::Home < Components::StaticPages::Base + def initialize(stats:, user:, flavor_text:) + @stats = stats + @user = user + @flavor_text = flavor_text + end + + def view_template + div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do + header_section + kpi_section + main_section + end + end + + private + + attr_reader :stats, :user, :flavor_text + + def header_section + header(style: "display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 16px; padding-bottom: 24px; margin-bottom: 24px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);") do + div do + p(style: "color: var(--fgColor-muted, #656d76); margin: 0 0 4px; font-size: 14px;") do + plain "Welcome back, " + strong { user&.name || "friend" } + end + h1(style: "font-size: 2rem; font-weight: 300; margin: 0;") { "Hack Club CDN" } + div(style: "margin-top: 8px;") do + render(Primer::Beta::Label.new(scheme: :secondary)) { flavor_text } + end + end + + div(style: "display: flex; gap: 8px; flex-wrap: wrap;") do + a(href: helpers.docs_path("getting-started"), class: "btn") do + render Primer::Beta::Octicon.new(icon: :book, mr: 1) + plain "Docs" + end + a(href: helpers.uploads_path, class: "btn btn-primary") do + render Primer::Beta::Octicon.new(icon: :upload, mr: 1) + plain "Upload" + end + end + end + end + + def kpi_section + div(style: "margin-bottom: 32px;") do + # Your stats section + h2(style: "font-size: 14px; font-weight: 600; color: var(--fgColor-muted, #656d76); text-transform: uppercase; letter-spacing: 0.5px; margin: 0 0 12px;") { "Your Stats" } + div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px;") do + stat_card("Total files", stats[:total_files], :archive) + stat_card("Storage used", stats[:storage_formatted], :database) + stat_card("Uploaded today", stats[:files_today], :upload) + stat_card("This week", stats[:files_this_week], :zap) + quota_stat_card + end + + # Recent uploads + if stats[:recent_uploads].any? + h2(style: "font-size: 14px; font-weight: 600; color: var(--fgColor-muted, #656d76); text-transform: uppercase; letter-spacing: 0.5px; margin: 24px 0 12px;") { "Recent Uploads" } + recent_uploads_list + end + end + end + + def recent_uploads_list + render Primer::Beta::BorderBox.new do |box| + stats[:recent_uploads].each do |upload| + box.with_row do + render Components::Uploads::Row.new(upload: upload, compact: true) + end + end + end + end + + def quota_stat_card + quota_data = stats[:quota] + available = quota_data[:available] + limit = quota_data[:storage_limit] + percentage = quota_data[:percentage_used] + + # Color based on usage + color = if percentage >= 100 + "var(--fgColor-danger)" + elsif percentage >= 80 + "var(--fgColor-attention)" + else + "var(--fgColor-success)" + end + + progress_color = if percentage >= 100 + "var(--bgColor-danger-emphasis)" + elsif percentage >= 80 + "var(--bgColor-attention-emphasis)" + else + "var(--bgColor-success-emphasis)" + end + + div(style: "padding: 14px; background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;") do + div(style: "display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px;") do + div do + p(style: "font-size: 11px; color: var(--fgColor-muted, #656d76); margin: 0 0 4px; text-transform: uppercase; letter-spacing: 0.3px;") { "Available storage" } + span(style: "font-size: 28px; font-weight: 600; line-height: 1; color: #{color};") do + helpers.number_to_human_size(available) + end + p(style: "font-size: 11px; color: var(--fgColor-muted); margin: 4px 0 0;") do + plain "of #{helpers.number_to_human_size(limit)}" + end + end + span(style: "color: var(--fgColor-muted, #656d76);") do + render Primer::Beta::Octicon.new(icon: :"shield-check", size: :small) + end + end + # Progress bar + div(style: "background: var(--bgColor-muted); border-radius: 3px; height: 6px; overflow: hidden;") do + div(style: "background: #{progress_color}; height: 100%; width: #{[ percentage, 100 ].min}%;") + end + end + end + + def main_section + div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px;") do + resources_panel + end + end +end diff --git a/app/components/static_pages/logged_out.rb b/app/components/static_pages/logged_out.rb new file mode 100644 index 0000000..f87491c --- /dev/null +++ b/app/components/static_pages/logged_out.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Components::StaticPages::LoggedOut < Components::StaticPages::Base + def initialize(stats:, flavor_text:) + @stats = stats + @flavor_text = flavor_text + end + + def view_template + div(style: "max-width: 1200px; margin: 0 auto; padding: 48px 24px 24px;") do + header_section + stats_section + main_section + end + end + + private + + attr_reader :stats, :flavor_text + + def header_section + header(style: "display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 16px; padding-bottom: 24px; margin-bottom: 24px; border-bottom: 1px solid var(--borderColor-default, #d0d7de);") do + div do + h1(style: "font-size: 2rem; font-weight: 300; margin: 0 0 8px;") do + plain "Hack Club CDN" + sup(style: "font-size: 0.5em; margin-left: 4px;") { "v4" } + end + p(style: "color: var(--fgColor-muted, #656d76); margin: 0 0 8px; max-width: 600px;") do + plain "File hosting for Hack Clubbers." + end + render(Primer::Beta::Label.new(scheme: :secondary)) { flavor_text } + end + + div(style: "display: flex; gap: 8px; flex-wrap: wrap;") do + button_to "Sign in with Hack Club", "/auth/hack_club", method: :post, class: "btn btn-primary", data: { turbo: false } + end + end + end + + def stats_section + div(style: "margin-bottom: 32px;") do + h2(style: "font-size: 14px; font-weight: 600; color: var(--fgColor-muted, #656d76); text-transform: uppercase; letter-spacing: 0.5px; margin: 0 0 12px;") { "State of the Platform:" } + div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px;") do + stat_card("Total files", stats[:total_files], :archive) + stat_card("Storage used", stats[:storage_formatted], :database) + stat_card("Users", stats[:total_users], :people) + stat_card("Files this week", stats[:files_this_week], :zap) + end + + h2(style: "font-size: 14px; font-weight: 600; color: var(--fgColor-muted, #656d76); text-transform: uppercase; letter-spacing: 0.5px; margin: 24px 0 12px;") { "New in V4:" } + div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px;") do + feature_card(:lock, "Invincible", "Backups of the underlying storage exist.") + feature_card(:link, "No broken links, this time?", "it lives on a domain! that we own!") + feature_card(:"shield-check", "Hopefully reliable", 'Backed by the award-winning "cc @nora" service guarantee.') + end + end + end + + def feature_card(icon, title, description) + div(style: "padding: 14px; background: var(--bgColor-default, #fff); border: 1px solid var(--borderColor-default, #d0d7de); border-radius: 6px;") do + div(style: "display: flex; align-items: center; gap: 10px; margin-bottom: 6px;") do + span(style: "color: var(--fgColor-muted, #656d76);") do + render Primer::Beta::Octicon.new(icon: icon, size: :small) + end + h3(style: "font-size: 14px; font-weight: 600; margin: 0;") { title } + end + p(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin: 0;") { description } + end + end + + def main_section + div(style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 24px;") do + resources_panel + end + end +end diff --git a/app/components/uploads/_row.rb b/app/components/uploads/_row.rb new file mode 100644 index 0000000..5ae4093 --- /dev/null +++ b/app/components/uploads/_row.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +class Components::Uploads::Row < Components::Base + include Phlex::Rails::Helpers::FormWith + include Phlex::Rails::Helpers::LinkTo + + def initialize(upload:, compact: false, admin: false) + @upload = upload + @compact = compact + @admin = admin + end + + def view_template + div(style: "display: flex; justify-content: space-between; align-items: #{compact ? 'center' : 'flex-start'}; gap: 16px;") do + if compact + compact_content + else + full_content + end + end + end + + private + + attr_reader :upload, :compact, :admin + + def compact_content + div(style: "flex: 1; min-width: 0;") do + div(style: "font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;") do + render Primer::Beta::Octicon.new(icon: file_icon_for(upload.content_type), size: :small, mr: 1) + plain upload.filename.to_s + end + div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76); margin-top: 4px;") do + plain "#{upload.human_file_size} • #{time_ago_in_words(upload.created_at)} ago" + end + end + + div(style: "display: flex; gap: 8px; align-items: center;") do + render(Primer::Beta::ClipboardCopyButton.new(value: upload.cdn_url, size: :small, "aria-label": "Copy link")) { "Copy link" } + + a(href: upload.cdn_url, target: "_blank", rel: "noopener", class: "btn btn-sm") do + plain "View" + end + + render_delete_dialog + end + end + + def full_content + div(style: "flex: 1; min-width: 0;") do + div(style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;") do + render Primer::Beta::Octicon.new(icon: file_icon_for(upload.content_type), size: :small) + div(style: "font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;") do + plain upload.filename.to_s + end + render(Primer::Beta::Label.new(scheme: :secondary)) { plain upload.provenance.titleize } + end + div(style: "font-size: 12px; color: var(--fgColor-muted, #656d76);") do + plain "#{upload.human_file_size} • #{upload.content_type} • #{time_ago_in_words(upload.created_at)} ago" + end + end + + div(style: "display: flex; gap: 8px; align-items: center;") do + render(Primer::Beta::ClipboardCopyButton.new(value: upload.cdn_url, size: :small, "aria-label": "Copy link")) { "Copy link" } + + a(href: upload.cdn_url, target: "_blank", rel: "noopener", class: "btn btn-sm") do + render Primer::Beta::Octicon.new(icon: :link, mr: 1) + plain "View" + end + + render_delete_dialog + end + end + + def render_delete_dialog + render Primer::Alpha::Dialog.new(title: "Delete file?", size: :medium) do |dialog| + dialog.with_show_button(scheme: :danger, size: :small) do + render Primer::Beta::Octicon.new(icon: :trash) + end + dialog.with_header(variant: :large) do + h1(style: "margin: 0;") { "Delete #{upload.filename}?" } + end + dialog.with_body do + p(style: "margin: 0;") do + plain "This action cannot be undone. The file will be permanently removed from the CDN." + end + end + dialog.with_footer do + div(style: "display: flex; justify-content: flex-end; gap: 8px;") do + form_with url: (admin ? admin_upload_path(upload) : upload_path(upload)), method: :delete, style: "display: inline;" do + button(type: "submit", class: "btn btn-danger") do + plain "Delete" + end + end + end + end + end + end + + def file_icon_for(content_type) + case content_type + when /image/ + :image + when /video/ + :video + when /audio/ + :unmute + when /pdf/ + :file + when /zip|rar|tar|gz/ + :"file-zip" + when /text|json|xml/ + :code + else + :file + end + end +end diff --git a/app/components/uploads/index.rb b/app/components/uploads/index.rb new file mode 100644 index 0000000..51f14cc --- /dev/null +++ b/app/components/uploads/index.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +class Components::Uploads::Index < Components::Base + include Phlex::Rails::Helpers::FormWith + include Phlex::Rails::Helpers::LinkTo + + register_output_helper :paginate + + def initialize(uploads:, query: nil) + @uploads = uploads + @query = query + end + + def view_template + dropzone_form + div(style: "max-width: 1200px; margin: 0 auto; padding: 24px;") do + header_section + search_section + uploads_list + pagination_section if uploads.respond_to?(:total_pages) && uploads.total_pages > 1 + end + end + + private + + attr_reader :uploads, :query + + def header_section + header(style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;") do + div do + h1(style: "font-size: 2rem; font-weight: 600; margin: 0;") { "Your Uploads" } + p(style: "color: var(--fgColor-muted, #656d76); margin: 8px 0 0; font-size: 14px;") do + count = uploads.respond_to?(:total_count) ? uploads.total_count : uploads.size + plain "#{count} file#{count == 1 ? '' : 's'}" + end + end + + label(for: "dropzone-file-input", class: "btn btn-primary", style: "cursor: pointer;") do + render Primer::Beta::Octicon.new(icon: :upload, mr: 1) + plain "Upload File" + end + end + end + + def search_section + div(style: "margin-bottom: 24px;") do + form_with url: uploads_path, method: :get do + div(style: "display: flex; gap: 12px;") do + input( + type: "search", + name: "query", + placeholder: "Search files...", + value: query, + class: "form-control", + style: "flex: 1; max-width: 400px;" + ) + button(type: "submit", class: "btn") do + render Primer::Beta::Octicon.new(icon: :search, mr: 1) + plain "Search" + end + end + end + end + end + + def uploads_list + if uploads.any? + render Primer::Beta::BorderBox.new do |box| + uploads.each do |upload| + box.with_row do + render Components::Uploads::Row.new(upload: upload, compact: false) + end + end + end + else + empty_state + end + end + + def empty_state + render Primer::Beta::Blankslate.new(border: true) do |component| + component.with_visual_icon(icon: query.present? ? :search : :upload, size: :medium) + component.with_heading(tag: :h2) do + query.present? ? "No files found" : "Drop files here" + end + component.with_description do + if query.present? + "Try a different search query" + else + "Drag and drop files anywhere on this page, or use the Upload button" + end + end + end + end + + def pagination_section + div(style: "margin-top: 24px; text-align: center;") do + paginate uploads + end + end + + def dropzone_form + form_with url: uploads_path, method: :post, multipart: true, data: { dropzone_form: true } do + input(type: "file", name: "file", id: "dropzone-file-input", data: { dropzone_input: true }, style: "display: none;") + end + end +end diff --git a/app/controllers/admin/api_keys_controller.rb b/app/controllers/admin/api_keys_controller.rb new file mode 100644 index 0000000..d8e1db3 --- /dev/null +++ b/app/controllers/admin/api_keys_controller.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Admin + class APIKeysController < ApplicationController + def destroy + api_key = APIKey.find(params[:id]) + user = api_key.user + api_key.revoke! + redirect_to admin_user_path(user), notice: "API key '#{api_key.name}' revoked." + end + end +end diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb new file mode 100644 index 0000000..9358d9e --- /dev/null +++ b/app/controllers/admin/application_controller.rb @@ -0,0 +1,14 @@ +module Admin + class ApplicationController < ::ApplicationController + before_action :require_admin! + + private + + def require_admin! + redirect_to( + root_path, + alert: "You need to be an admin to access this page." + ) unless current_user&.is_admin? + end + end +end diff --git a/app/controllers/admin/search_controller.rb b/app/controllers/admin/search_controller.rb new file mode 100644 index 0000000..fe575a7 --- /dev/null +++ b/app/controllers/admin/search_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Admin + class SearchController < ApplicationController + def index + @query = params[:q].to_s.strip + @type = params[:type] || "all" + return if @query.blank? + + @users = search_users(@query) if @type.in?(%w[all users]) + @uploads = search_uploads(@query) if @type.in?(%w[all uploads]) + end + + private + + def search_users(query) + User.search(query).limit(20) + end + + def search_uploads(query) + Upload.search(query).includes(:blob, :user).order(created_at: :desc).limit(50) + end + end +end diff --git a/app/controllers/admin/uploads_controller.rb b/app/controllers/admin/uploads_controller.rb new file mode 100644 index 0000000..fd3fe87 --- /dev/null +++ b/app/controllers/admin/uploads_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Admin + class UploadsController < ApplicationController + before_action :set_upload + + def destroy + filename = @upload.filename + @upload.destroy! + redirect_to admin_search_path, notice: "Upload #{filename} deleted." + end + + private + + def set_upload + @upload = Upload.find(params[:id]) + end + end +end diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 0000000..4975a87 --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Admin + class UsersController < ApplicationController + before_action :set_user + + def show + end + + def destroy + @user.destroy! + redirect_to admin_search_path, notice: "User #{@user.name || @user.email} deleted." + end + + def set_quota + quota_policy = params[:quota_policy] + + # Empty string means auto-detect (clear override) + if quota_policy.blank? + @user.update!(quota_policy: nil) + redirect_to admin_user_path(@user), notice: "Quota policy cleared. Will auto-detect via HCA." + return + end + + unless %w[verified functionally_unlimited].include?(quota_policy) + redirect_to admin_user_path(@user), alert: "Invalid quota policy." + return + end + + @user.update!(quota_policy: quota_policy) + redirect_to admin_user_path(@user), notice: "Quota policy set to #{quota_policy.humanize}." + end + + private + + def set_user + @user = User.find_by_public_id!(params[:id]) + end + end +end diff --git a/app/controllers/api/v4/api_keys_controller.rb b/app/controllers/api/v4/api_keys_controller.rb new file mode 100644 index 0000000..58f298c --- /dev/null +++ b/app/controllers/api/v4/api_keys_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module API + module V4 + class APIKeysController < ApplicationController + def revoke + api_key = current_token + owner_email = current_user.email + key_name = api_key.name + + api_key.revoke! + + render json: { + success: true, + owner_email: owner_email, + key_name: key_name, + status: "complete" + }, status: :ok + end + end + end +end diff --git a/app/controllers/api/v4/application_controller.rb b/app/controllers/api/v4/application_controller.rb new file mode 100644 index 0000000..0bc02ce --- /dev/null +++ b/app/controllers/api/v4/application_controller.rb @@ -0,0 +1,53 @@ +module API + module V4 + class ApplicationController < ActionController::API + include ActionController::HttpAuthentication::Token::ControllerMethods + + attr_reader :current_user, :current_token + + before_action :authenticate! + before_action :set_sentry_context + + rescue_from ActiveRecord::RecordNotFound, with: :not_found + rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity + rescue_from StandardError, with: :handle_error + + private + + def authenticate! + @current_token = authenticate_with_http_token do |token, _options| + APIKey.find_by_token(token) + end + + unless @current_token&.active? + return render json: { error: "invalid_auth" }, status: :unauthorized + end + + @current_user = @current_token.user + end + + def set_sentry_context + Sentry.set_user(id: current_user&.id) if current_user + Sentry.set_tags(api_key_id: current_token&.hashid) if current_token + end + + def not_found + render json: { error: "Not found" }, status: :not_found + end + + def unprocessable_entity(exception) + render json: { + error: "Validation failed", + details: exception.record.errors.full_messages + }, status: :unprocessable_entity + end + + def handle_error(exception) + raise exception if Rails.env.local? + + event_id = Sentry.capture_exception(exception) + render json: { error: exception.message, error_id: event_id }, status: :internal_server_error + end + end + end +end diff --git a/app/controllers/api/v4/uploads_controller.rb b/app/controllers/api/v4/uploads_controller.rb new file mode 100644 index 0000000..13f716d --- /dev/null +++ b/app/controllers/api/v4/uploads_controller.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module API + module V4 + class UploadsController < ApplicationController + before_action :check_quota, only: [ :create, :create_from_url ] + + # POST /api/v4/upload + def create + file = params[:file] + + unless file.present? + render json: { error: "Missing file parameter" }, status: :bad_request + return + end + + blob = ActiveStorage::Blob.create_and_upload!( + io: file.tempfile, + filename: file.original_filename, + content_type: file.content_type + ) + + upload = current_user.uploads.create!(blob: blob, provenance: :api) + + render json: upload_json(upload), status: :created + rescue => e + render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity + end + + # POST /api/v4/upload_from_url + def create_from_url + url = params[:url] + + unless url.present? + render json: { error: "Missing url parameter" }, status: :bad_request + return + end + + download_auth = request.headers["X-Download-Authorization"] + upload = Upload.create_from_url(url, user: current_user, provenance: :api, original_url: url, authorization: download_auth) + + # Check quota after download (URL upload size unknown beforehand) + quota_service = QuotaService.new(current_user) + unless quota_service.can_upload?(0) # Already uploaded, check if now over quota + if current_user.total_storage_bytes > quota_service.current_policy.max_total_storage + upload.destroy! + usage = quota_service.current_usage + render json: quota_error_json(usage), status: :payment_required + return + end + end + + render json: upload_json(upload), status: :created + rescue => e + render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity + end + + private + + def check_quota + # For direct uploads, check file size before processing + if params[:file].present? + file_size = params[:file].size + quota_service = QuotaService.new(current_user) + policy = quota_service.current_policy + + # Check per-file size limit + if file_size > policy.max_file_size + usage = quota_service.current_usage + render json: quota_error_json(usage, "File size exceeds your limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)} per file"), status: :payment_required + return + end + + # Check if upload would exceed total storage quota + unless quota_service.can_upload?(file_size) + usage = quota_service.current_usage + render json: quota_error_json(usage), status: :payment_required + nil + end + end + # For URL uploads, quota is checked after download in create_from_url + end + + def quota_error_json(usage, custom_message = nil) + { + error: custom_message || "Storage quota exceeded", + quota: { + storage_used: usage[:storage_used], + storage_limit: usage[:storage_limit], + quota_tier: usage[:policy], + percentage_used: usage[:percentage_used] + } + } + end + + def upload_json(upload) + { + id: upload.id, + filename: upload.filename.to_s, + size: upload.byte_size, + content_type: upload.content_type, + url: upload.cdn_url, + created_at: upload.created_at.iso8601 + } + end + end + end +end diff --git a/app/controllers/api/v4/users_controller.rb b/app/controllers/api/v4/users_controller.rb new file mode 100644 index 0000000..342a86a --- /dev/null +++ b/app/controllers/api/v4/users_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module API + module V4 + class UsersController < ApplicationController + def show + quota_service = QuotaService.new(current_user) + usage = quota_service.current_usage + + render json: { + id: current_user.public_id, + email: current_user.email, + name: current_user.name, + storage_used: usage[:storage_used], + storage_limit: usage[:storage_limit], + quota_tier: usage[:policy] + } + end + end + end +end diff --git a/app/controllers/api_keys_controller.rb b/app/controllers/api_keys_controller.rb new file mode 100644 index 0000000..a3e3a6c --- /dev/null +++ b/app/controllers/api_keys_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class APIKeysController < ApplicationController + before_action :set_api_key, only: [ :destroy ] + + def index + @api_keys = current_user.api_keys.active.recent + end + + def create + @api_key = current_user.api_keys.create!(api_key_params) + + flash[:api_key_token] = @api_key.token + redirect_to api_keys_path, notice: "API key created. Copy it now - you won't see it again!" + rescue ActiveRecord::RecordInvalid => e + redirect_to api_keys_path, alert: "Failed to create API key: #{e.message}" + end + + def destroy + authorize @api_key, :destroy? + @api_key.revoke! + redirect_to api_keys_path, notice: "API key revoked successfully." + rescue Pundit::NotAuthorizedError + redirect_to api_keys_path, alert: "You are not authorized to revoke this API key." + end + + private + + def set_api_key + @api_key = APIKey.find(params[:id]) + end + + def api_key_params + params.require(:api_key).permit(:name) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..c4c3879 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,55 @@ +class ApplicationController < ActionController::Base + before_action :require_authentication! + before_action :set_sentry_context + + helper_method :current_user, :signed_in?, :impersonating? + + rescue_from StandardError, with: :handle_error + + private + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + end + + def signed_in? = current_user.present? + + def require_authentication! + redirect_to root_path, alert: "Please sign in to continue." unless signed_in? + end + + def impersonating? = false + + include Pundit::Authorization + + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + + private + + def set_sentry_context + Sentry.set_user(id: current_user&.id, email: current_user&.email) if signed_in? + end + + def user_not_authorized + flash[:alert] = "You are not authorized to perform this action." + redirect_back fallback_location: root_path + end + + def handle_error(exception) + raise exception if Rails.env.local? + + event_id = Sentry.capture_exception(exception) + + respond_to do |format| + format.html do + if request.path == root_path + render "errors/internal_server_error", status: :internal_server_error, locals: { error_id: event_id, error_message: exception.message } + else + flash[:alert] = "Something went wrong: #{exception.message} (Error ID: #{event_id})" + redirect_back fallback_location: root_path + end + end + format.json { render json: { error: exception.message, error_id: event_id }, status: :internal_server_error } + end + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/docs_controller.rb b/app/controllers/docs_controller.rb new file mode 100644 index 0000000..9fd2cd1 --- /dev/null +++ b/app/controllers/docs_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class DocsController < ApplicationController + include HighVoltage::StaticPage + + skip_before_action :require_authentication! + + before_action :load_docs_navigation + + def show + @doc = DocPage.find(params[:id]) + end + + private + + def load_docs_navigation + @docs = DocPage.all + end + + def page_finder_factory + DocPage + end +end diff --git a/app/controllers/external_uploads_controller.rb b/app/controllers/external_uploads_controller.rb new file mode 100644 index 0000000..43d025c --- /dev/null +++ b/app/controllers/external_uploads_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class ExternalUploadsController < ApplicationController + skip_before_action :require_authentication! + + def show + upload = Upload.includes(:blob).find(params[:id]) + redirect_to rails_blob_url(upload.blob), allow_other_host: true + rescue ActiveRecord::RecordNotFound + head :not_found + end + + def rescue + url = params[:url] + + if url.blank? + head :bad_request + return + end + + upload = Upload.includes(:blob).find_by(original_url: url) + + if upload + redirect_to upload.cdn_url, allow_other_host: true + else + render_not_found_response(url) + end + end + + private + + def render_not_found_response(url) + if url.match?(/\.(png|jpe?g)$/i) + render_error_image + else + head :not_found + end + end + + def render_error_image + svg = <<~SVG + + + + + 404 + + + Original URL not found in CDN + + + This file hasn't been uploaded or rescued yet. + + + Try uploading it at cdn.hackclub.com + + + SVG + + render inline: svg, content_type: "image/svg+xml" + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..a6ce6c4 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class SessionsController < ApplicationController + skip_before_action :require_authentication!, only: %i[create failure] + + def create + auth = request.env["omniauth.auth"] + user = User.find_or_create_from_omniauth(auth) + session[:user_id] = user.id + + # Check and upgrade verification status if needed + QuotaService.new(user).check_and_upgrade_verification! + + redirect_to root_path, notice: "Signed in successfully!" + end + + def destroy + reset_session + redirect_to root_path, notice: "Signed out successfully!" + end + + def failure + redirect_to root_path, alert: "Authentication failed: #{params[:message]}" + end +end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb new file mode 100644 index 0000000..7780baa --- /dev/null +++ b/app/controllers/static_pages_controller.rb @@ -0,0 +1,12 @@ +class StaticPagesController < ApplicationController + skip_before_action :require_authentication!, only: [ :home ] + + def home + @flavor_text = FlavorTextService.new(user: current_user).generate + if signed_in? + @user_stats = CDNStatsService.user_stats(current_user) + else + @global_stats = CDNStatsService.global_stats + end + end +end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb new file mode 100644 index 0000000..8e4abeb --- /dev/null +++ b/app/controllers/uploads_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class UploadsController < ApplicationController + before_action :set_upload, only: [ :destroy ] + before_action :check_quota, only: [ :create ] + + def index + @uploads = current_user.uploads.includes(:blob).recent + + if params[:query].present? + @uploads = @uploads.search_by_filename(params[:query]) + end + + @uploads = @uploads.page(params[:page]).per(50) + end + + def create + uploaded_file = params[:file] + + if uploaded_file.blank? + redirect_to uploads_path, alert: "Please select a file to upload." + return + end + + blob = ActiveStorage::Blob.create_and_upload!( + io: uploaded_file.tempfile, + filename: uploaded_file.original_filename, + content_type: uploaded_file.content_type + ) + + @upload = current_user.uploads.create!( + blob: blob, + provenance: :web + ) + + redirect_to uploads_path, notice: "File uploaded successfully!" + rescue StandardError => e + redirect_to uploads_path, alert: "Upload failed: #{e.message}" + end + + def destroy + authorize @upload + + @upload.destroy! + redirect_back fallback_location: uploads_path, notice: "Upload deleted successfully." + rescue Pundit::NotAuthorizedError + redirect_back fallback_location: uploads_path, alert: "You are not authorized to delete this upload." + end + + private + + def check_quota + uploaded_file = params[:file] + return if uploaded_file.blank? # Let create action handle missing file + + quota_service = QuotaService.new(current_user) + file_size = uploaded_file.size + policy = quota_service.current_policy + + # Check per-file size limit + if file_size > policy.max_file_size + redirect_to uploads_path, alert: "File size (#{ActiveSupport::NumberHelper.number_to_human_size(file_size)}) exceeds your limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)} per file." + return + end + + # Check if upload would exceed total storage quota + unless quota_service.can_upload?(file_size) + usage = quota_service.current_usage + redirect_to uploads_path, alert: "Uploading this file would exceed your storage quota. You're using #{ActiveSupport::NumberHelper.number_to_human_size(usage[:storage_used])} of #{ActiveSupport::NumberHelper.number_to_human_size(usage[:storage_limit])}." + nil + end + end + + def set_upload + @upload = Upload.find(params[:id]) + end +end diff --git a/app/frontend/controllers/upload_dropzone.js b/app/frontend/controllers/upload_dropzone.js new file mode 100644 index 0000000..b70540e --- /dev/null +++ b/app/frontend/controllers/upload_dropzone.js @@ -0,0 +1,112 @@ +(function() { + let dropzone; + let counter = 0; + let fileInput, form; + let initialized = false; + + function init() { + const formElement = document.querySelector("[data-dropzone-form]"); + if (!formElement) { + fileInput = null; + form = null; + initialized = false; + return; + } + + if (initialized && form === formElement) return; + + form = formElement; + fileInput = form.querySelector("[data-dropzone-input]"); + + if (!fileInput) return; + + initialized = true; + + // Handle file input change + fileInput.addEventListener("change", (e) => { + const file = e.target.files[0]; + if (file) { + form.requestSubmit(); + } + }); + } + + // Prevent default drag behaviors + document.addEventListener("dragover", (e) => { + e.preventDefault(); + }); + + // Show overlay when dragging enters window + document.addEventListener("dragenter", (e) => { + if (!fileInput) return; + e.preventDefault(); + if (counter === 0) { + showDropzone(); + } + counter++; + }); + + // Hide overlay when dragging leaves window + document.addEventListener("dragleave", (e) => { + if (!fileInput) return; + e.preventDefault(); + counter--; + if (counter === 0) { + hideDropzone(); + } + }); + + // Handle file drop + document.addEventListener("drop", (e) => { + if (!fileInput) return; + e.preventDefault(); + counter = 0; + hideDropzone(); + + const files = e.dataTransfer.files; + if (files.length > 0) { + fileInput.files = files; + form.requestSubmit(); + } + }); + + // Show full-screen dropzone overlay + function showDropzone() { + if (!dropzone) { + dropzone = document.createElement("div"); + dropzone.classList.add("file-dropzone"); + + const title = document.createElement("h1"); + title.innerText = "Drop your file here"; + dropzone.appendChild(title); + + document.body.appendChild(dropzone); + document.body.style.overflow = "hidden"; + + // Force reflow for transition + void dropzone.offsetWidth; + + dropzone.classList.add("visible"); + } + } + + // Hide full-screen dropzone overlay + function hideDropzone() { + if (dropzone) { + dropzone.remove(); + dropzone = null; + document.body.style.overflow = "auto"; + counter = 0; + } + } + + // Initialize on first load + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + + // Re-initialize on Turbo navigations + document.addEventListener("turbo:load", init); +})(); diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js new file mode 100644 index 0000000..3eae392 --- /dev/null +++ b/app/frontend/entrypoints/application.js @@ -0,0 +1,5 @@ +import Rails from "@rails/ujs"; +Rails.start(); +import "@primer/view-components/app/components/primer/primer.js"; + +import "../controllers/upload_dropzone.js"; diff --git a/app/frontend/entrypoints/application.scss b/app/frontend/entrypoints/application.scss new file mode 100644 index 0000000..4851ac7 --- /dev/null +++ b/app/frontend/entrypoints/application.scss @@ -0,0 +1,22 @@ +@use '@primer/css/dist/primer.css'; +@use '@primer/view-components/app/assets/styles/primer_view_components.css'; +@use '@primer/primitives/dist/css/primitives.css'; +@use '@primer/primitives/dist/css/functional/themes/light.css'; +@use '@primer/primitives/dist/css/functional/themes/dark.css'; + +@use "@/styles/dark_mode"; +@use "@/styles/hca"; +@use "@/styles/admin_tool"; +@use "@/styles/file_dropzone"; + +.app-header { + position: static; + top: 0; + z-index: 100; + display: flex; + align-items: center; + gap: var(--base-size-12, 12px); + padding: var(--base-size-12, 12px) var(--base-size-16, 16px); + background-color: var(--bgColor-default); + border-bottom: 1px solid var(--borderColor-muted); +} \ No newline at end of file diff --git a/app/frontend/styles/admin_tool.scss b/app/frontend/styles/admin_tool.scss new file mode 100644 index 0000000..2de2fc4 --- /dev/null +++ b/app/frontend/styles/admin_tool.scss @@ -0,0 +1,9 @@ +.admin-tool { + padding: 0.25rem 0.5rem; + border-radius: 0.5rem; + border: 1px dashed #ff8c37; + background: rgba(#ff8c37, 0.125); + overflow: auto; + display: inline-flex; + align-items: center; +} diff --git a/app/frontend/styles/dark_mode.scss b/app/frontend/styles/dark_mode.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/frontend/styles/file_dropzone.scss b/app/frontend/styles/file_dropzone.scss new file mode 100644 index 0000000..9a29f9c --- /dev/null +++ b/app/frontend/styles/file_dropzone.scss @@ -0,0 +1,99 @@ + +.file-dropzone { + background-color: rgba(255, 255, 255, 0.95); + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + pointer-events: none; + + display: flex; + align-items: center; + justify-content: center; + + // Animated dashed border using gradient technique + background-image: + linear-gradient(90deg, #0969da 50%, transparent 50%), + linear-gradient(90deg, #0969da 50%, transparent 50%), + linear-gradient(0deg, #0969da 50%, transparent 50%), + linear-gradient(0deg, #0969da 50%, transparent 50%); + background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; + background-size: + 50px 8px, + 50px 8px, + 8px 50px, + 8px 50px; + background-position: + 0 0, + 0 100%, + 0 100%, + 100% 20px; + animation: border-dance 1s infinite linear; + + opacity: 0; + + transition-property: opacity; + transition-duration: 300ms; + transition-timing-function: ease-out; + + padding: 3rem; + text-align: center; + + h1 { + padding-bottom: 0; + border: none; + color: #0969da; + font-size: 2.5rem; + font-weight: 600; + + transition-property: transform; + transition-duration: 300ms; + transition-timing-function: ease-out; + + transform: scale(1.08); + } +} + +.file-dropzone.visible { + opacity: 1; + + h1 { + transform: none; + } +} + +@keyframes border-dance { + 0% { + background-position: + 0 0, + 0 100%, + 0 100%, + 100% 20px; + } + 100% { + background-position: + -50px 0, + 50px 100%, + 0 calc(100% + 50px), + 100% -30px; + } +} + +// Dark mode support +@media (prefers-color-scheme: dark) { + .file-dropzone { + background-color: rgba(13, 17, 23, 0.95); + + h1 { + color: #58a6ff; + } + + background-image: + linear-gradient(90deg, #58a6ff 50%, transparent 50%), + linear-gradient(90deg, #58a6ff 50%, transparent 50%), + linear-gradient(0deg, #58a6ff 50%, transparent 50%), + linear-gradient(0deg, #58a6ff 50%, transparent 50%); + } +} diff --git a/app/frontend/styles/hca.scss b/app/frontend/styles/hca.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..7d4ed7a --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,6 @@ +module ApplicationHelper + def admin_tool(class_name: "", element: "div", **options, &block) + return unless current_user&.is_admin? + concat content_tag(element, class: "admin-tool #{class_name}", **options, &block) + end +end diff --git a/app/helpers/quota_helper.rb b/app/helpers/quota_helper.rb new file mode 100644 index 0000000..7e107a2 --- /dev/null +++ b/app/helpers/quota_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module QuotaHelper + def quota_banner_for(user) + quota_service = QuotaService.new(user) + usage = quota_service.current_usage + + if quota_service.over_quota? + # Danger banner when over quota + render Primer::Beta::Flash.new(scheme: :danger, full: true) do + plain "You've exceeded your storage quota. " + plain "You're using #{number_to_human_size(usage[:storage_used])} of #{number_to_human_size(usage[:storage_limit])}. " + plain "Please delete some files to continue uploading." + end + elsif quota_service.at_warning? + # Warning banner when >= 80% used + render Primer::Beta::Flash.new(scheme: :warning, full: true) do + plain "You're using #{usage[:percentage_used]}% of your storage quota " + plain "(#{number_to_human_size(usage[:storage_used])} of #{number_to_human_size(usage[:storage_limit])}). " + if usage[:policy] == "unverified" + plain "Get verified at " + a(href: "https://auth.hackclub.com", target: "_blank", rel: "noopener") { "auth.hackclub.com" } + plain " to unlock 50GB of storage." + end + end + end + # Return nil if no warning needed + end +end diff --git a/app/helpers/static_pages_helper.rb b/app/helpers/static_pages_helper.rb new file mode 100644 index 0000000..2d63e79 --- /dev/null +++ b/app/helpers/static_pages_helper.rb @@ -0,0 +1,2 @@ +module StaticPagesHelper +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/refresh_cdn_stats_job.rb b/app/jobs/refresh_cdn_stats_job.rb new file mode 100644 index 0000000..1c6e3d2 --- /dev/null +++ b/app/jobs/refresh_cdn_stats_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RefreshCDNStatsJob < ApplicationJob + queue_as :default + + def perform + CDNStatsService.refresh_global_stats! + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..3c34c81 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/api_key.rb b/app/models/api_key.rb new file mode 100644 index 0000000..b8db22c --- /dev/null +++ b/app/models/api_key.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class APIKey < ApplicationRecord + belongs_to :user + + # Lockbox encryption + has_encrypted :token + + # Blind index for token lookup + blind_index :token + + before_validation :generate_token, on: :create + + validates :name, presence: true, length: { maximum: 255 } + + scope :active, -> { where(revoked: false) } + scope :recent, -> { order(created_at: :desc) } + + # Find by token using blind index + def self.find_by_token(token) + find_by(token: token) # Blind index handles lookup + end + + def revoke! + update!(revoked: true, revoked_at: Time.current) + end + + def active? + !revoked + end + + def masked_token + # Decrypt to get the full token, then mask it + full = token + prefix = full[0...13] # "sk_cdn_" + first 6 chars + suffix = full[-6..] # Last 6 chars + "#{prefix}....#{suffix}" + end + + private + + def generate_token + self.token ||= "sk_cdn_#{SecureRandom.hex(32)}" + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..1decbca --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,12 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class + + before_create :generate_uuid_v7 + + private + + def generate_uuid_v7 + return if self.class.attribute_types["id"].type != :uuid + self.id ||= SecureRandom.uuid_v7 + end +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/concerns/public_identifiable.rb b/app/models/concerns/public_identifiable.rb new file mode 100644 index 0000000..873c821 --- /dev/null +++ b/app/models/concerns/public_identifiable.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# (@msw) Stripe-like public IDs that don't require adding a column to the database. +module PublicIdentifiable + SEPARATOR = ?_ + + extend ActiveSupport::Concern + + included do + include Hashid::Rails + class_attribute :public_id_prefix + end + + def public_id = "#{self.public_id_prefix}#{SEPARATOR}#{hashid}" + + module ClassMethods + def set_public_id_prefix(prefix) + self.public_id_prefix = prefix.to_s.downcase + end + + def find_by_public_id(id) + return nil unless id.is_a? String + + prefix = id.split(SEPARATOR).first.to_s.downcase + hash = id.split(SEPARATOR).last + return nil unless prefix == self.get_public_id_prefix + + find_by_hashid(hash) + end + + def find_by_public_id!(id) + obj = find_by_public_id id + raise ActiveRecord::RecordNotFound.new(nil, self.name) if obj.nil? + + obj + end + + def get_public_id_prefix + return self.public_id_prefix.to_s.downcase if self.public_id_prefix.present? + + raise NotImplementedError, "The #{self.class.name} model includes PublicIdentifiable module, but set_public_id_prefix hasn't been called." + end + end +end diff --git a/app/models/doc_page.rb b/app/models/doc_page.rb new file mode 100644 index 0000000..6e76724 --- /dev/null +++ b/app/models/doc_page.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class DocPage + DOCS_PATH = Rails.root.join("app/views/docs/pages") + + attr_reader :id, :title, :icon, :order, :content + + def initialize(id:, title:, icon:, order:, content:) + @id = id + @title = title + @icon = icon + @order = order + @content = content + end + + class << self + def all + @all ||= load_all_docs.sort_by(&:order) + end + + def find(id) + all.find { |doc| doc.id == id } || raise(ActiveRecord::RecordNotFound, "Doc '#{id}' not found") + end + + def reload! + @all = nil + end + + private + + def load_all_docs + Dir.glob(DOCS_PATH.join("*.md")).map do |file| + parse_doc_file(file) + end + end + + def parse_doc_file(file) + id = File.basename(file, ".md") + raw_content = File.read(file) + frontmatter, content = extract_frontmatter(raw_content) + + new( + id: id, + title: frontmatter["title"] || id.titleize, + icon: (frontmatter["icon"] || "file").to_sym, + order: frontmatter["order"] || 999, + content: render_markdown(content) + ) + end + + def extract_frontmatter(content) + if content.start_with?("---") + parts = content.split("---", 3) + if parts.length >= 3 + frontmatter = YAML.safe_load(parts[1]) || {} + return [ frontmatter, parts[2].strip ] + end + end + [ {}, content ] + end + + def render_markdown(content) + renderer = Redcarpet::Render::HTML.new( + hard_wrap: true, + link_attributes: { target: "_blank", rel: "noopener" } + ) + markdown = Redcarpet::Markdown.new( + renderer, + autolink: true, + tables: true, + fenced_code_blocks: true, + strikethrough: true, + highlight: true, + footnotes: true + ) + markdown.render(content) + end + end +end diff --git a/app/models/quota.rb b/app/models/quota.rb new file mode 100644 index 0000000..edaaae7 --- /dev/null +++ b/app/models/quota.rb @@ -0,0 +1,11 @@ +class Quota + Policy = Data.define(:slug, :max_file_size, :max_total_storage) + + ALL_POLICIES = [ + Policy[:unverified, 10.megabytes, 50.megabytes], + Policy[:verified, 50.megabytes, 50.gigabytes], + Policy[:functionally_unlimited, 200.megabytes, 300.gigabytes] + ].index_by &:slug + + def self.policy(slug) = ALL_POLICIES.fetch slug +end diff --git a/app/models/upload.rb b/app/models/upload.rb new file mode 100644 index 0000000..778b78a --- /dev/null +++ b/app/models/upload.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "open-uri" + +class Upload < ApplicationRecord + include PgSearch::Model + + # UUID v7 primary key (automatic via migration) + + belongs_to :user + belongs_to :blob, class_name: "ActiveStorage::Blob" + + after_destroy :purge_blob + + # Delegate file metadata to blob (no duplication!) + delegate :filename, :byte_size, :content_type, :checksum, to: :blob + + # Search configuration + pg_search_scope :search_by_filename, + associated_against: { + blob: :filename + }, + using: { + tsearch: { prefix: true } + } + + pg_search_scope :search, + against: [ :original_url ], + associated_against: { + blob: :filename, + user: [ :email, :name ] + }, + using: { tsearch: { prefix: true } } + + # Aliases for consistency + alias_method :file_size, :byte_size + alias_method :mime_type, :content_type + + # Provenance enum + enum :provenance, { + slack: "slack", + web: "web", + api: "api", + rescued: "rescued" + }, validate: true + + validates :provenance, presence: true + + scope :recent, -> { order(created_at: :desc) } + scope :by_user, ->(user) { where(user: user) } + scope :today, -> { where("created_at >= ?", Time.zone.now.beginning_of_day) } + scope :this_week, -> { where("created_at >= ?", Time.zone.now.beginning_of_week) } + scope :this_month, -> { where("created_at >= ?", Time.zone.now.beginning_of_month) } + + def human_file_size + ActiveSupport::NumberHelper.number_to_human_size(byte_size) + end + + # Get CDN URL (uses external uploads controller) + def cdn_url + Rails.application.routes.url_helpers.external_upload_url( + id:, + filename:, + host: ENV["CDN_HOST"] || "cdn.hackclub.com" + ) + end + + # Create upload from URL (for API/rescue operations) + def self.create_from_url(url, user:, provenance:, original_url: nil, authorization: nil) + open_options = {} + open_options["Authorization"] = authorization if authorization.present? + + downloaded = URI.open(url, open_options) + + blob = ActiveStorage::Blob.create_and_upload!( + io: downloaded, + filename: File.basename(URI.parse(url).path) + ) + + create!( + user: user, + blob: blob, + provenance: provenance, + original_url: original_url + ) + end + + private + + def purge_blob + blob.purge + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..960e0d3 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class User < ApplicationRecord + include PublicIdentifiable + include PgSearch::Model + set_public_id_prefix :usr + def to_param = public_id + + pg_search_scope :search, + against: [ :email, :name, :slack_id ], + using: { tsearch: { prefix: true } } + + scope :admins, -> { where(is_admin: true) } + + validates :hca_id, presence: true, uniqueness: true + encrypts :hca_access_token + + has_many :uploads, dependent: :destroy + has_many :api_keys, dependent: :destroy, class_name: "APIKey" + + def self.find_or_create_from_omniauth(auth) + hca_id = auth.uid + slack_id = auth.extra.raw_info.slack_id + raise "Missing HCA user ID from authentication" if hca_id.blank? + + user = find_by(hca_id:) || find_by(slack_id:) + + if user + user.update( + hca_id:, + slack_id:, + email: auth.info.email, + name: auth.info.name, + hca_access_token: auth.credentials.token + ) + else + user = create!( + hca_id:, + slack_id:, + email: auth.info.email, + name: auth.info.name, + hca_access_token: auth.credentials.token + ) + end + + user + end + + def hca_profile(access_token) = HCAService.new(access_token).me + + def total_files + uploads.count + end + + def total_storage_bytes + uploads.joins(:blob).sum("active_storage_blobs.byte_size") + end + + def total_storage_gb + (total_storage_bytes / 1.gigabyte.to_f).round(2) + end + + def total_storage_formatted + ActiveSupport::NumberHelper.number_to_human_size(total_storage_bytes) + end +end diff --git a/app/policies/api_key_policy.rb b/app/policies/api_key_policy.rb new file mode 100644 index 0000000..f1c4945 --- /dev/null +++ b/app/policies/api_key_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class APIKeyPolicy < ApplicationPolicy + def index? = true + def create? = true + + def destroy? + user.is_admin? || record.user_id == user.id + end + + class Scope < ApplicationPolicy::Scope + def resolve + user.is_admin? ? scope.all : scope.where(user: user) + end + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 0000000..bdceba4 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? = false + def show? = false + def create? = false + def new? = create? + def update? = false + def edit? = update? + def destroy? = false + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve = raise NotImplementedError, "You must define #resolve in #{self.class}" + + private + + attr_reader :user, :scope + end +end diff --git a/app/policies/upload_policy.rb b/app/policies/upload_policy.rb new file mode 100644 index 0000000..2117b74 --- /dev/null +++ b/app/policies/upload_policy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class UploadPolicy < ApplicationPolicy + def destroy? + # Users can delete their own uploads, admins can delete any upload + user.is_admin? || record.user_id == user.id + end + + class Scope < ApplicationPolicy::Scope + def resolve + if user.is_admin? + scope.all + else + scope.where(user: user) + end + end + end +end diff --git a/app/services/cdn_stats_service.rb b/app/services/cdn_stats_service.rb new file mode 100644 index 0000000..f393b24 --- /dev/null +++ b/app/services/cdn_stats_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class CDNStatsService + CACHE_KEY_GLOBAL = "cdn:stats:global" + CACHE_DURATION = 5.minutes + + # Global stats (cached) - for logged-out users + def self.global_stats + Rails.cache.fetch(CACHE_KEY_GLOBAL, expires_in: CACHE_DURATION) do + calculate_global_stats + end + end + + # Force refresh global stats (called by background job) + def self.refresh_global_stats! + Rails.cache.delete(CACHE_KEY_GLOBAL) + global_stats + end + + # User stats (live) - for logged-in users + def self.user_stats(user) + quota_service = QuotaService.new(user) + usage = quota_service.current_usage + policy = quota_service.current_policy + + used = usage[:storage_used] + max = usage[:storage_limit] + percentage = usage[:percentage_used] + available = [ max - used, 0 ].max + + { + total_files: user.total_files, + total_storage: used, + storage_formatted: user.total_storage_formatted, + files_today: user.uploads.today.count, + files_this_week: user.uploads.this_week.count, + recent_uploads: user.uploads.includes(:blob).recent.limit(5), + quota: { + policy: usage[:policy], + storage_limit: max, + available: available, + percentage_used: percentage, + at_warning: usage[:at_warning], + over_quota: usage[:over_quota] + } + } + end + + private + + def self.calculate_global_stats + total_files = Upload.count + total_storage_bytes = Upload.joins(:blob).sum("active_storage_blobs.byte_size") + total_users = User.joins(:uploads).distinct.count + + { + total_files: total_files, + total_storage_bytes: total_storage_bytes, + storage_formatted: ActiveSupport::NumberHelper.number_to_human_size(total_storage_bytes), + total_users: total_users, + files_today: Upload.today.count, + files_this_week: Upload.this_week.count + } + end +end diff --git a/app/services/flavor_text_service.rb b/app/services/flavor_text_service.rb new file mode 100644 index 0000000..66bdc4d --- /dev/null +++ b/app/services/flavor_text_service.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +class FlavorTextService + include ActionView::Helpers::NumberHelper + + def initialize(user: nil, env: Rails.env, deterministic: true) + @user = user + @env = env + @seed = deterministic ? Time.now.to_i / 5.minutes : Random.new_seed + @random = Random.new(@seed) + end + + def generate + flavor_text = sample + flavor_text = flavor_text.call if flavor_text.respond_to? :call + + flavor_text + end + + def flavor_texts + [ + "bytes bytes bytes bytes bytes", + "A hard drive stuffed with cat photos", + "The Hack Foundation dba The File Store", + "Open on weekends", + "Open on holidays", + "please don't hack", + "Contentedly Delivering Nonsense", + "Cool Dino Network", + "Compressed Data Nuggets", + "Cozy Digital Nest", + "Open late", + "Now in color!", + "Filmed on location", + "Soon to be a major blockchain!", + "As seen on the internet", + "Most viewed site on this domain!", + "Coming to a browser near you", + "#{@random.rand 4..9}0% bug free!", + "#{@random.rand 1..4}0% fewer bugs!", + 'Now with "code"', + "Holds lots of bytes", + "Educational!", + "Don't use while driving", + "Support local file hosting!", + "Take frequent breaks!", + "Technically good!", + "Operating at a loss since 2025!", + "Does anyone actually read this?", + "Like and subscribe!", + "As seen on cdn.hackclub.com", + "As seen on hackclub.com", + "Now running in production!", + "put files in computer", + "TODO: get that bread", + "Coming soon to a screen near your face", + "Coming soon to a screen near you", + "As seen on the internet", + "Operating at a loss so you don't have to", + "It holds files!", + "uwu", + "owo", + "ovo", + "An important part of this nutritional breakfast", + "By people with files, for people with files", + 'Made using "files"', + "Chosen #1 by dinosaurs everywhere", + "IT departments HATE them", + "Congratulations, you are the #{number_with_delimiter(10**@random.rand(1..5))}th visitor!", + "You've got this", + "Don't forget to drink water!", + "Putting the 'fun' in 'upload'", + "Putting the 'fun' in 'cloud storage'", + "Putting the 'do' in 'download'", + "Putting the 'based' in 'cloud-based hosting'", + "Putting the 'host' in 'ghost'", + "Putting the 'sus' in 'sustainable bandwidth'", + "Open on weekdays!", + "Open on #{Date.today.strftime("%A")}s", + "??? storage!", + "Did you see the size of that #{%w[image video file].sample(random: @random)}?!", + "Guess how much it costs to run this thing!", + "Bytes served fresh daily by Cloudflare", + "Running with Ruby on Rails #{Rails.gem_version.canonical_segments.first}", + "Now with 1% downtime!", + "Achievement unlocked!", + "#{@random.rand(10..50)},#{@random.rand(100..999).to_s.rjust(3, '0')} lines of code", + "Your move, Dropbox", + "If you can read this, the page's status code is 200", + "If you can read this, the page has loaded", + "Now go and upload yourself something nice", + "[Insert splash text here]", + "Condemned by the sheriff of storage", + "Coded on location", + 'Voted "3rd"', + "You are now breathing manually", + "If you can read this, thanks!", + "(or similar product)", + "[OK]", + "tell your parents it's educational", + "You found the 3rd Easter egg on the site", + "The best site you're using right now", + "It Is What It Is", + "Made in Vermont, with love", + "Your move S3!", + "Flash plugin failed to load", + "Upload, they said", + "U want sum storage?", + "Check the back of this page for an exclusive promo code!", + "You've found the 5th easter egg on the site!", + "A folder is fine too", + "Welcome to #{%w[data storage].sample(random: @random)} town, population: you", + "So... what's your favorite file format?", + "If you can read this you've got tiny eyes".html_safe, + "Page loaded in: < 24 hrs (I hope)", + "Old and improved!", + "Newly loaded!", + "Refreshing! (if you keep hitting ⌘+R)", + "Recommended by people somewhere!", + "Recommended by people in some places!", + "Recommended by hackers on this site!", + "Recommended by me!", + "Recommended by Hack Club!", + "Recommended by the recommend-o-tron 3000", + "Recommended! (probably)", + "Please stow your files in the upright and locked position", + "Loaded in #{@random.rand(10..35)}ms... jk– i don't actually know how long it took", + "Loaded in #{@random.rand(10..35)}ms... jk– i can't count", + "Turns out it's hard to make one of these things", + "TODO: come up with some actual jokes for this box", + "asdgfhjdk I'm out of jokes", + "Send your jokes to nora@hackclub.com", + "You're looking great today :)", + "Great! You're here!", + "You need to wake up", + "you need to wake up! Pinch yourself", + "stop dreaming, you need to wake up!", + "Are you suuuuure you aren't a robot?", + "Show emotion here if you aren't a robot", + "Your ad here!", + "Are you feeling lucky?", + "...and you can take that to the cloud", + "Ever just wonder... why?", + "Redstone update out now!", + "educational edition", + "Where's the file lebowski?!", + "We put the 'fun' in 'cloud storage' (there isn't any)", + "Not responsible for any major data loss!", + "In today's internet?!", + "Send us your best haiku!", + "«⋄⇠◇«─◆─»⇢$$$⇠«─◆─»◇⇢⋄»", + "¸¸.•*📁*•.¸¸¸.•*📁*•.¸¸¸.•*📁*•.¸¸¸.•*📁*•.¸", + "◥◤◢◤◢📁📁📁◣◥◣◥◤", + "store no evil", + "byte me", + "not running on the blockchain!", + "not available offline!", + "as seen online", + "online only!", + "new strawberry flavor!", + "same classic taste", + "📁📁📁".html_safe, + -> { "#{@random.rand(5..50)} users online" }, + "Raccoon-tested, dinosaur-approved.", + "original recipe!", + "now sugar-free!", + "low-sodium edition", + 'we put the ":3" in "S3"!', + "do not adjust your monitor.", + "only #{@random.rand(5..50)} missing #{[ "file", "files" ].sample(random: @random)}!", + "why are you reading these", + "go outside", + "posture check!", + "have you eaten today?", + "blink if you're okay", + "git commit -m 'idk'", + "git commit -m 'stuff'", + "the files understand", + "everything is fine forever", + "yippee!", + "yayyy :3", + ":D", + "files :)", + "hehe", + "honk", + "meow", + "AAAAAAAAA", + "help", + "this is a cry for help disguised as a CDN", + "my lawyer advised me not to finish this jo-", + "I can see you", + "behind you", + "the call is coming from inside the server", + "this is your sign", + "you dropped this: 👑", + "I'm in your walls", + "feed me files", + "MORE", + "the prophecy is true", + "the ritual is complete", + "you have been chosen", + "you win!", + "thanks for coming to my ted talk", + "anyway", + "tl;dr: files", + "no but like actually what is a file", + "philosophy major dropout energy", + "the void stares back", + "nothing matters and that's okay", + "we're all just files in the end", + "existential dread as a service", + "powered by anxiety", + "college dropout runs a CDN, more at 11", + "I should be studying", + "due tomorrow? do tomorrow.", + "sleep is for the weak (I am weak)", + "it's 3am and I regret everything", + "made at 4am on a tuesday", + "unmedicated energy", + "no sleep, only code", + "shoutout to my therapist", + "certified mess", + "professionally unprofessional", + "fake it till you make it (we're still faking it)", + "I have no idea what I'm doing", + "stackoverflow raised me", + "ctrl+c ctrl+v my beloved", + "works on my machine (I swear)", + "100% bug-free* *no it's not", + "who let me cook", + "cooked (derogatory)", + "this seemed like a good idea at the time" + ] + end + + private + + def sample + flavor_texts.sample(random: @random) + end +end diff --git a/app/services/hca_service.rb b/app/services/hca_service.rb new file mode 100644 index 0000000..decf3ae --- /dev/null +++ b/app/services/hca_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class HCAService + BASE_URL = Rails.application.config.hack_club_auth.base_url + + def initialize(access_token) + @conn = Faraday.new(url: BASE_URL) do |f| + f.request :json + f.response :json, parser_options: { symbolize_names: true } + f.response :raise_error + f.headers["Authorization"] = "Bearer #{access_token}" + end + end + + def me = @conn.get("/api/v1/me").body + + def check_verification(idv_id: nil, email: nil, slack_id: nil) + params = { idv_id:, email:, slack_id: }.compact + raise ArgumentError, "Provide one of: idv_id, email, or slack_id" if params.empty? + + @conn.get("/api/external/check", params).body + end +end diff --git a/app/services/quota_service.rb b/app/services/quota_service.rb new file mode 100644 index 0000000..f05a62c --- /dev/null +++ b/app/services/quota_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +class QuotaService + WARNING_THRESHOLD_PERCENTAGE = 80 + + def initialize(user) + @user = user + end + + # Returns the applicable Quota::Policy for the user + # Checks HCA if quota_policy is NULL, upgrades to verified if confirmed + def current_policy + if @user.quota_policy.present? + # User has explicit policy set - use it + Quota.policy(@user.quota_policy.to_sym) + else + # No policy set - check HCA verification + if hca_verified? + # User is verified - upgrade them permanently + @user.update_column(:quota_policy, "verified") + Quota.policy(:verified) + else + # Not verified - use unverified tier (don't set field) + Quota.policy(:unverified) + end + end + rescue KeyError + # Invalid policy slug - fall back to unverified + Quota.policy(:unverified) + end + + # Returns hash with storage info, policy, and flags + def current_usage + policy = current_policy + used = @user.total_storage_bytes + max = policy.max_total_storage + percentage = percentage_used + + { + storage_used: used, + storage_limit: max, + policy: policy.slug.to_s, + percentage_used: percentage, + at_warning: at_warning?, + over_quota: over_quota? + } + end + + # Validates if upload is allowed based on file size and total storage + def can_upload?(file_size) + policy = current_policy + + # Check file size against per-file limit + return false if file_size > policy.max_file_size + + # Check total storage after upload + total_after = @user.total_storage_bytes + file_size + return false if total_after > policy.max_total_storage + + true + end + + # Boolean if storage exceeded + def over_quota? + @user.total_storage_bytes >= current_policy.max_total_storage + end + + # Boolean if >= 80% used + def at_warning? + percentage_used >= WARNING_THRESHOLD_PERCENTAGE + end + + # Calculate usage percentage + def percentage_used + max = current_policy.max_total_storage + return 0 if max.zero? + + ((@user.total_storage_bytes.to_f / max) * 100).round(2) + end + + # Check HCA and upgrade to verified if confirmed + # Returns true if verification successful, false otherwise + def check_and_upgrade_verification! + return true if @user.quota_policy.present? # Already has policy set + + if hca_verified? + @user.update_column(:quota_policy, "verified") + true + else + false + end + rescue Faraday::Error => e + Rails.logger.warn "HCA verification check failed for user #{@user.id}: #{e.message}" + false + end + + private + + # Check if user is verified via HCA + def hca_verified? + return false unless @user.hca_access_token.present? + return false unless @user.hca_id.present? + + hca = HCAService.new(@user.hca_access_token) + response = hca.check_verification(idv_id: @user.hca_id) + response[:verified] == true + rescue Faraday::Error, ArgumentError => e + Rails.logger.warn "HCA API error for user #{@user.id}: #{e.message}" + false + end +end diff --git a/app/views/admin/search/index.html.erb b/app/views/admin/search/index.html.erb new file mode 100644 index 0000000..a3858ce --- /dev/null +++ b/app/views/admin/search/index.html.erb @@ -0,0 +1 @@ +<%= render Components::Admin::Search::Index.new(query: @query, users: @users || [], uploads: @uploads || [], type: params[:type] || "all") %> diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb new file mode 100644 index 0000000..3c91a2e --- /dev/null +++ b/app/views/admin/users/show.html.erb @@ -0,0 +1 @@ +<%= render Components::Admin::Users::Show.new(user: @user) %> diff --git a/app/views/api_keys/index.html.erb b/app/views/api_keys/index.html.erb new file mode 100644 index 0000000..9928d57 --- /dev/null +++ b/app/views/api_keys/index.html.erb @@ -0,0 +1,4 @@ +<%= render Components::APIKeys::Index.new( + api_keys: @api_keys, + new_token: flash[:api_key_token] +) %> diff --git a/app/views/base.rb b/app/views/base.rb new file mode 100644 index 0000000..5bb8307 --- /dev/null +++ b/app/views/base.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class Views::Base < Components::Base + # The `Views::Base` is an abstract class for all your views. + + # By default, it inherits from `Components::Base`, but you + # can change that to `Phlex::HTML` if you want to keep views and + # components independent. + + # More caching options at https://www.phlex.fun/components/caching + def cache_store = Rails.cache +end diff --git a/app/views/docs/pages/api.md b/app/views/docs/pages/api.md new file mode 100644 index 0000000..9246917 --- /dev/null +++ b/app/views/docs/pages/api.md @@ -0,0 +1,157 @@ +--- +title: API Documentation +icon: code +order: 3 +--- + +# API Documentation + +Upload images programmatically using the CDN API. + +## Authentication + +Create an API key at [API Keys](/api_keys). Keys are shown once, so copy it immediately. + +Include the key in the `Authorization` header: + +``` +Authorization: Bearer sk_cdn_your_key_here +``` + +## POST /api/v4/upload + +Upload a file via multipart form data. + +```bash +curl -X POST \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + -F "file=@photo.jpg" \ + https://cdn.hackclub.com/api/v4/upload +``` + +```javascript +const formData = new FormData(); +formData.append('file', fileInput.files[0]); + +const response = await fetch('https://cdn.hackclub.com/api/v4/upload', { + method: 'POST', + headers: { 'Authorization': 'Bearer sk_cdn_your_key_here' }, + body: formData +}); + +const { url } = await response.json(); +``` + +**Response:** + +```json +{ + "id": "01234567-89ab-cdef-0123-456789abcdef", + "filename": "photo.jpg", + "size": 12345, + "content_type": "image/jpeg", + "url": "https://cdn.hackclub.com/01234567-89ab-cdef-0123-456789abcdef/photo.jpg", + "created_at": "2026-01-29T12:00:00Z" +} +``` + +## POST /api/v4/upload\_from\_url + +Upload an image from a URL. + +**Optional header:** `X-Download-Authorization` — passed as `Authorization` when fetching the source URL (useful for protected resources). + +```bash +curl -X POST \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + -H "Content-Type: application/json" \ + -d '{"url":"https://example.com/image.jpg"}' \ + https://cdn.hackclub.com/api/v4/upload_from_url + +# With authentication for the source URL: +curl -X POST \ + -H "Authorization: Bearer sk_cdn_your_key_here" \ + -H "X-Download-Authorization: Bearer source_token_here" \ + -H "Content-Type: application/json" \ + -d '{"url":"https://protected.example.com/image.jpg"}' \ + https://cdn.hackclub.com/api/v4/upload_from_url +``` + +```javascript +const response = await fetch('https://cdn.hackclub.com/api/v4/upload_from_url', { + method: 'POST', + headers: { + 'Authorization': 'Bearer sk_cdn_your_key_here', + 'Content-Type': 'application/json', + // Optional: auth for the source URL + 'X-Download-Authorization': 'Bearer source_token_here' + }, + body: JSON.stringify({ url: 'https://example.com/image.jpg' }) +}); + +const { url } = await response.json(); +``` + +## GET /api/v4/me + +Get the authenticated user and quota information. + +```bash +curl -H "Authorization: Bearer sk_cdn_your_key_here" \ + https://cdn.hackclub.com/api/v4/me +``` + +```json +{ + "id": "usr_abc123", + "email": "you@hackclub.com", + "name": "Your Name", + "storage_used": 1048576000, + "storage_limit": 53687091200, + "quota_tier": "verified" +} +``` + +**Quota fields:** +- `storage_used` — bytes used +- `storage_limit` — bytes allowed +- `quota_tier` — `"unverified"`, `"verified"`, or `"functionally_unlimited"` + +## Errors + +| Status | Meaning | +|--------|---------| +| 400 | Missing required parameters | +| 401 | Invalid or missing API key | +| 402 | Storage quota exceeded | +| 404 | Resource not found | +| 422 | Validation failed | + +**Standard error:** + +```json +{ + "error": "Missing file parameter" +} +``` + +**Quota error (402):** + +```json +{ + "error": "Storage quota exceeded", + "quota": { + "storage_used": 52428800, + "storage_limit": 52428800, + "quota_tier": "unverified", + "percentage_used": 100.0 + } +} +``` + +See [Storage Quotas](/docs/quotas) for details on getting more space. + +## Help + +- [#cdn-dev on Slack](https://hackclub.slack.com/archives/C08RYDPS36V) +- [GitHub Issues](https://github.com/hackclub/cdn/issues) diff --git a/app/views/docs/pages/getting-started.md b/app/views/docs/pages/getting-started.md new file mode 100644 index 0000000..1a77f7e --- /dev/null +++ b/app/views/docs/pages/getting-started.md @@ -0,0 +1,44 @@ +--- +title: Getting Started +icon: rocket +order: 1 +--- + +# Getting Started + +Hack Club CDN is image hosting for your HTML pages. Upload files, get permanent URLs, embed them anywhere. + +## Sign In + +Click **Sign in with Hack Club** on the homepage to authenticate. + +## Upload an Image + +1. Go to **My Files** +2. Drag and drop images or click **Upload** +3. Copy the URL + +## Use in HTML + +```html +My image +``` + +## Use in Markdown + +```markdown +![](https://cdn.hackclub.com/019505e2-c85b-7f80-9c31-4b2e5a8d9f12/photo.jpg) +``` + +## Hotlinking + +URLs work everywhere - READMEs, personal websites, whatever. + +## Programmatic Uploads + +Need to upload from code? See the [API documentation](/docs/api). + +## Need Help? + +- [#cdn-dev on Slack](https://hackclub.slack.com/archives/C08RYDPS36V) +- [GitHub Issues](https://github.com/hackclub/cdn/issues) diff --git a/app/views/docs/pages/privacy.md b/app/views/docs/pages/privacy.md new file mode 100644 index 0000000..a16bec1 --- /dev/null +++ b/app/views/docs/pages/privacy.md @@ -0,0 +1,18 @@ +--- +title: Privacy Policy +icon: shield +order: 5 +--- + +# Privacy Policy + +This service is operated by Hack Club. For complete details on how Hack Club handles your data, please see the [Hack Club Privacy Policy](https://hackclub.com/privacy/). + +## What CDN Collects + +In addition to the standard Hack Club privacy practices, this CDN service stores: + +- **Uploaded files**: Content you upload, along with original filenames and file metadata +- **Upload history**: Timestamps and file sizes for quota tracking + +We do not analyze the content of your uploads. Files are publicly accessible via their URLs and served directly as uploaded. \ No newline at end of file diff --git a/app/views/docs/pages/quotas.md b/app/views/docs/pages/quotas.md new file mode 100644 index 0000000..922b14b --- /dev/null +++ b/app/views/docs/pages/quotas.md @@ -0,0 +1,66 @@ +--- +title: Storage Quotas +icon: database +order: 4 +--- + +# Storage Quotas + +CDN provides free storage for the Hack Club community. Your quota depends on whether you're verified. + +## What's My Quota? + +| Tier | Per File | Total Storage | +|------|----------|---------------| +| **Unverified** | 10 MB | 50 MB | +| **Verified** | 50 MB | 50 GB | +| **Unlimited** | 200 MB | 300 GB | + +**New users start unverified.** Once you verify with Hack Club, you automatically get 50GB. + +## Get 50GB Free (Verified Tier) + +1. Visit [auth.hackclub.com](https://auth.hackclub.com) and submit your ID for verification +2. Wait for HCA ops to approve your ID (usually takes a day or two) +3. Once approved, sign in to CDN again to automatically unlock 50GB + +Your quota upgrades automatically once HCA confirms your verification. + +## Check Your Usage + +Your homepage shows available storage with a progress bar. You'll see warnings when you hit 80% usage, and uploads will be blocked at 100%. + +**Via API:** + +```bash +curl -H "Authorization: Bearer YOUR_API_KEY" \ + https://cdn.hackclub.com/api/v4/me +``` + +```json +{ + "storage_used": 1048576000, + "storage_limit": 53687091200, + "quota_tier": "verified" +} +``` + +## What Happens When I'm Over Quota? + +**Web:** You'll see a red banner and uploads will fail with an error message. + +**API:** Returns `402 Payment Required` with quota details: + +```json +{ + "error": "Storage quota exceeded", + "quota": { + "storage_used": 52428800, + "storage_limit": 52428800, + "quota_tier": "unverified", + "percentage_used": 100.0 + } +} +``` + +Delete some files from **Uploads** to free up space. \ No newline at end of file diff --git a/app/views/docs/pages/terms.md b/app/views/docs/pages/terms.md new file mode 100644 index 0000000..2c05672 --- /dev/null +++ b/app/views/docs/pages/terms.md @@ -0,0 +1,15 @@ +--- +title: Terms of Service +icon: law +order: 4 +--- + +# Terms of Service + +Hack Club CDN is a Hack Club service. By using it, you agree to follow the [Hack Club Code of Conduct](https://hackclub.com/conduct/). + +Use this service for personal projects, educational work, and open source stuff. Don't upload anything illegal under US law, harmful, or that you don't have rights to. + +We provide this on an "as is" basis and may remove content or suspend accounts that violate the Code of Conduct. + +Questions? [#cdn-dev on Slack](https://hackclub.slack.com/archives/C08RYDPS36V) or [nora@hackclub.com](mailto:nora@hackclub.com) \ No newline at end of file diff --git a/app/views/docs/pages/using-cdn-urls.md b/app/views/docs/pages/using-cdn-urls.md new file mode 100644 index 0000000..3d1ac5d --- /dev/null +++ b/app/views/docs/pages/using-cdn-urls.md @@ -0,0 +1,60 @@ +--- +title: Using CDN URLs +icon: link +order: 2 +--- + +# Using CDN URLs + +## URL Structure + +``` +https://cdn.hackclub.com/{id}/{filename} +``` + +Requests are 301 redirected to the underlying storage bucket. + +## Embedding + +### Images + +```html + +``` + +### Links + +```html +Download +``` + +### Markdown + +```markdown +![](https://cdn.hackclub.com/019505e2-e7f3-7d40-a156-9c4e8b2d1f03/screenshot.png) +``` + +## Hotlinking + +Supported. URLs can be embedded in GitHub, Notion, Discord, Slack, etc. + +## Content-Type + +Served based on file extension. + +## URL Rescue + +Lookup endpoint for files migrated from legacy CDNs: + +``` +GET /rescue?url={original_url} +``` + +Examples: + +``` +/rescue?url=https://hc-cdn.hel1.your-objectstorage.com/s/v3/sdhfksdjfhskdjf.png +/rescue?url=https://cloud-xxxx-hack-club-bot.vercel.app/0awawawa.png +``` + +Returns 301 redirect to the new CDN URL if found. For image URLs (`.png`, `.jpg`, `.jpeg`), returns an SVG 404 placeholder if not found. Otherwise returns HTTP 404. diff --git a/app/views/docs/show.html.erb b/app/views/docs/show.html.erb new file mode 100644 index 0000000..30ae7c2 --- /dev/null +++ b/app/views/docs/show.html.erb @@ -0,0 +1,4 @@ +<% content_for(:title) { "#{@doc.title} - Docs" } %> + +<%= render(Components::HeaderBar.new) unless signed_in? %> +<%= render Components::Docs::Page.new(doc: @doc, docs: @docs) %> diff --git a/app/views/errors/internal_server_error.html.erb b/app/views/errors/internal_server_error.html.erb new file mode 100644 index 0000000..0e5c8df --- /dev/null +++ b/app/views/errors/internal_server_error.html.erb @@ -0,0 +1,62 @@ + + + + Something went wrong - CDN + + + + + +
+

Something went wrong

+ <% if local_assigns[:error_message] && error_message.present? %> +

<%= error_message %>

+ <% else %> +

We've been notified and are looking into it.

+ <% end %> + <% if local_assigns[:error_id] && error_id.present? %> +

+ Error ID: <%= error_id %> +

+ <% end %> +

← Back to home

+
+ + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..b56e884 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,53 @@ + + + + <%= content_for(:title) || "CDN" %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= vite_stylesheet_tag "application.scss" %> + <%= vite_client_tag %> + <%= vite_javascript_tag 'application' %> + + + + + + <%= render(Components::HeaderBar.new) if signed_in? %> + <% if signed_in? %> + <%= quota_banner_for(current_user) %> + <% end %> + <% if flash[:notice] || flash[:alert] %> +
+ <% if flash[:notice] %> + <%= render(Primer::Beta::Flash.new(scheme: :success, icon: :check)) { flash[:notice] } %> + <% end %> + <% if flash[:alert] %> + <%= render(Primer::Beta::Flash.new(scheme: :danger, icon: :alert)) { flash[:alert] } %> + <% end %> +
+ <% end %> + <%= yield %> + + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..617bb2d --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "CDN", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "CDN.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/app/views/static_pages/home.html.erb b/app/views/static_pages/home.html.erb new file mode 100644 index 0000000..9e4e9b7 --- /dev/null +++ b/app/views/static_pages/home.html.erb @@ -0,0 +1,5 @@ +<% if signed_in? %> + <%= render Components::StaticPages::Home.new(stats: @user_stats, user: current_user, flavor_text: @flavor_text) %> +<% else %> + <%= render Components::StaticPages::LoggedOut.new(stats: @global_stats, flavor_text: @flavor_text) %> +<% end %> diff --git a/app/views/uploads/index.html.erb b/app/views/uploads/index.html.erb new file mode 100644 index 0000000..145246f --- /dev/null +++ b/app/views/uploads/index.html.erb @@ -0,0 +1 @@ +<%= render Components::Uploads::Index.new(uploads: @uploads, query: params[:query]) %> diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..5f91c20 --- /dev/null +++ b/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..57567d6 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,14 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency. +if [ -z "${LD_PRELOAD+x}" ]; then + LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) + export LD_PRELOAD +fi + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 0000000..40330c0 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..be3db3c --- /dev/null +++ b/bin/setup @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000..36bde2d --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/bin/vite b/bin/vite new file mode 100755 index 0000000..9664d0d --- /dev/null +++ b/bin/vite @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'vite' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("vite_ruby", "vite") diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index 14bf8d2c02b95e264d81f11a8600986a645090f0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98096 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 params[:url]).cdn_url, :allow_other_host => true)", + "render_path": null, + "location": { + "type": "method", + "class": "ExternalUploadsController", + "method": "rescue" + }, + "user_input": "Upload.includes(:blob).find_by(:original_url => params[:url]).cdn_url", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "" + } + ], + "brakeman_version": "8.0.1" +} diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..b9adc5a --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,17 @@ +# Async adapter only works within the same process, so for manually triggering cable updates from a console, +# and seeing results in the browser, you must do so from the web console (running inside the dev process), +# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view +# to make the web console appear. +development: + adapter: async + +test: + adapter: test + +production: + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: 0.1.seconds + message_retention: 1.day diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 0000000..19d4908 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000..4b8bfad --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +wezrhR9Ha5VCd9yUTP9G0YiyETRf+X7aSaSwXmtpCW9k2BcmmcSYniGs0b72tGaXrRyMYdOmzCdgX0n6Zh4FC6h8lT7RRS6pzu0t8S3PalOKItaeZOyKqg+OpC90zJObwR3C2JzVklK1OWR7IwC2Y4rX8gU846Rd/0S9gI+bngtA048pqk4j96ORpDzykaQWSi62wA6eonGDX6LvFE5q27QyN5ybQlsJH2gtLzSmTpFCsw6CahZRbb/uia4p4/BGlPoQgy36rJQMax884aHkAntpSskn+36+jZo4oME+lKAnIisI+9fuY8pZgjgqsBWHorVFHG8wvIemr28mh5bUs9G1laZHCyw1XRfGM2PNcI+vbpeH5A4vnh9CjsoBjT5nY/Kbyfax1zHJohNrSXUjjNQ0E5iroJtNWzBRkLjCfaW/olNMxow/EUhpESOb7iZxnQ8RnXpDve8+ZdIpZ8LmhgUl2uT/pwoQu9+iIMVRM488iMRrEPeHNopj--C1zHbKDUYtIXHXdu--fYJAhuO4LgkcpZdIlQ0yBA== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..dd21b74 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,51 @@ +# PostgreSQL. Versions 9.3 and up are supported. +# gem install pg +# +# Ensure the pg gem is defined in your Gemfile +# gem "pg" +# +default: &default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: cdn_development + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: cdn_test + +production: + primary: + <<: *default + database: <%= ENV["DATABASE_NAME"] || "cdn_production" %> + host: <%= ENV["DATABASE_HOST"] %> + user: <%= ENV["DATABASE_USER"] %> + password: <%= ENV["DATABASE_PASSWORD"] %> + cache: + <<: *default + database: <%= ENV["CACHE_DATABASE_NAME"] || "cdn_cache" %> + host: <%= ENV["CACHE_DATABASE_HOST"] %> + user: <%= ENV["CACHE_DATABASE_USER"] %> + password: <%= ENV["CACHE_DATABASE_PASSWORD"] %> + migrations_paths: db/cache_migrate + queue: + <<: *default + database: <%= ENV["QUEUE_DATABASE_NAME"] || "cdn_queue" %> + host: <%= ENV["QUEUE_DATABASE_HOST"] %> + user: <%= ENV["QUEUE_DATABASE_USER"] %> + password: <%= ENV["QUEUE_DATABASE_PASSWORD"] %> + migrations_paths: db/queue_migrate + cable: + <<: *default + database: <%= ENV["CABLE_DATABASE_NAME"] || "cdn_cable" %> + host: <%= ENV["CABLE_DATABASE_HOST"] %> + user: <%= ENV["CABLE_DATABASE_USER"] %> + password: <%= ENV["CABLE_DATABASE_PASSWORD"] %> + migrations_paths: db/cable_migrate diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..4cc21c4 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,72 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # 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 + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..c5d3346 --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,90 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on Amazon S3 or S3-compatible storage (see config/storage.yml for options). + config.active_storage.service = :amazon + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!) + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # 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. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..c2095b1 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,53 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 0000000..4873244 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/blind_index.rb b/config/initializers/blind_index.rb new file mode 100644 index 0000000..8ea72e3 --- /dev/null +++ b/config/initializers/blind_index.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +BlindIndex.master_key = ENV.fetch("BLIND_INDEX_MASTER_KEY") diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..5c02038 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,43 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# Allow @vite/client to hot reload javascript changes in development +# policy.script_src *policy.script_src, :unsafe_eval, "http://#{ ViteRuby.config.host_with_port }" if Rails.env.development? + +# You may need to enable this in production as well depending on your setup. +# policy.script_src *policy.script_src, :blob if Rails.env.test? + +# Allow @vite/client to hot reload javascript changes in development +# policy.script_src *policy.script_src, :unsafe_eval, "http://#{ ViteRuby.config.host_with_port }" if Rails.env.development? + +# You may need to enable this in production as well depending on your setup. +# policy.script_src *policy.script_src, :blob if Rails.env.test? + +# policy.style_src :self, :https +# Allow @vite/client to hot reload style changes in development +# policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development? + +# Allow @vite/client to hot reload style changes in development +# policy.style_src *policy.style_src, :unsafe_inline if Rails.env.development? + +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c0b717f --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/hack_club_auth.rb b/config/initializers/hack_club_auth.rb new file mode 100644 index 0000000..313140a --- /dev/null +++ b/config/initializers/hack_club_auth.rb @@ -0,0 +1,4 @@ +Rails.application.config.hack_club_auth = ActiveSupport::OrderedOptions.new +Rails.application.config.hack_club_auth.client_id = ENV.fetch("HACKCLUB_CLIENT_ID", nil) +Rails.application.config.hack_club_auth.client_secret = ENV.fetch("HACKCLUB_CLIENT_SECRET", nil) +Rails.application.config.hack_club_auth.base_url = ENV.fetch("HACKCLUB_AUTH_URL") { Rails.env.production? ? "https://auth.hackclub.com" : "https://hca.dinosaurbbq.org" } diff --git a/config/initializers/hashid.rb b/config/initializers/hashid.rb new file mode 100644 index 0000000..604466f --- /dev/null +++ b/config/initializers/hashid.rb @@ -0,0 +1,4 @@ +Hashid::Rails.configure do |config| + config.salt = ENV.fetch("HASHID_SALT") { Rails.application.secret_key_base } + config.min_hash_length = 6 +end diff --git a/config/initializers/high_voltage.rb b/config/initializers/high_voltage.rb new file mode 100644 index 0000000..fa3ea20 --- /dev/null +++ b/config/initializers/high_voltage.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +HighVoltage.configure do |config| + config.routes = false +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..aac4cf4 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,22 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end + +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym "CDN" + inflect.acronym "HCA" + inflect.acronym "API" +end diff --git a/config/initializers/lockbox.rb b/config/initializers/lockbox.rb new file mode 100644 index 0000000..7f89ac6 --- /dev/null +++ b/config/initializers/lockbox.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Lockbox.master_key = ENV.fetch("LOCKBOX_MASTER_KEY") diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 0000000..490b27e --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,9 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + provider :hack_club, + Rails.application.config.hack_club_auth.client_id, + Rails.application.config.hack_club_auth.client_secret, + scope: "openid email name slack_id verification_status", + staging: !Rails.env.production? +end +OmniAuth.config.allowed_request_methods = [ :post ] +OmniAuth.config.request_validation_phase = OmniAuth::AuthenticityTokenProtection.new(key: :_csrf_token) diff --git a/config/initializers/phlex.rb b/config/initializers/phlex.rb new file mode 100644 index 0000000..c18fe08 --- /dev/null +++ b/config/initializers/phlex.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Views +end + +module Components + extend Phlex::Kit +end + +Rails.autoloaders.main.push_dir( + Rails.root.join("app/views"), namespace: Views, +) + +Rails.autoloaders.main.push_dir( + Rails.root.join("app/components"), namespace: Components, +) diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 0000000..a56e2e8 --- /dev/null +++ b/config/initializers/sentry.rb @@ -0,0 +1,7 @@ +Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.breadcrumbs_logger = [ :active_support_logger, :http_logger ] + config.traces_sample_rate = 0.1 + config.send_default_pii = false + config.enabled_environments = %w[production staging] +end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..a248513 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,41 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000..9eace59 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000..bc107dc --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,18 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 + refresh_cdn_stats: + class: RefreshCDNStatsJob + schedule: every 5 minutes diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..3ffabb6 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,53 @@ +Rails.application.routes.draw do + namespace :admin do + get "search", to: "search#index" + resources :users, only: [ :show, :destroy ] do + member do + patch "set_quota" + end + end + resources :uploads, only: [ :destroy ] + resources :api_keys, only: [ :destroy ] + end + + delete "/logout", to: "sessions#destroy", as: :logout + get "/login", to: "static_pages#login", as: :login + root "static_pages#home", as: :root + post "/auth/hack_club", as: :hack_club_auth + get "/auth/hack_club/callback", to: "sessions#create" + get "/auth/failure", to: "sessions#failure" + + resources :uploads, only: [ :index, :create, :destroy ] + + resources :api_keys, only: [ :index, :create, :destroy ] + + namespace :api do + namespace :v4 do + get "me", to: "users#show" + post "upload", to: "uploads#create" + post "upload_from_url", to: "uploads#create_from_url" + post "revoke", to: "api_keys#revoke" + end + end + + get "/docs", to: redirect("/docs/getting-started") + get "/docs/:id", to: "docs#show", as: :doc + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # 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 + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" + + # Rescue endpoint to find uploads by original URL + get "/rescue", to: "external_uploads#rescue", as: :rescue_upload + + # External upload redirects (must be last to avoid conflicts) + get "/:id/*filename", to: "external_uploads#show", constraints: { id: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ }, as: :external_upload +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..f4eaa9d --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,36 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Cloudflare R2 (S3-compatible) +amazon: + service: S3 + access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %> + secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %> + region: auto + bucket: <%= ENV['R2_BUCKET_NAME'] %> + endpoint: <%= ENV['R2_ENDPOINT'] %> # Format: https://.r2.cloudflarestorage.com + force_path_style: true + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/config/vite.json b/config/vite.json new file mode 100644 index 0000000..5240bd2 --- /dev/null +++ b/config/vite.json @@ -0,0 +1,16 @@ +{ + "all": { + "sourceCodeDir": "app/frontend", + "watchAdditionalPaths": [] + }, + "development": { + "autoBuild": true, + "publicOutputDir": "vite-dev", + "port": 3036 + }, + "test": { + "autoBuild": true, + "publicOutputDir": "vite-test", + "port": 3037 + } +} diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 0000000..2366660 --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 0000000..81a410d --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,12 @@ +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20260127174404_create_users.rb b/db/migrate/20260127174404_create_users.rb new file mode 100644 index 0000000..753e0b9 --- /dev/null +++ b/db/migrate/20260127174404_create_users.rb @@ -0,0 +1,14 @@ +class CreateUsers < ActiveRecord::Migration[8.0] + def change + create_table :users do |t| + t.string :hca_id + t.text :hca_access_token + t.string :email + t.string :name + t.boolean :is_admin, default: false, null: false + + t.timestamps + end + add_index :users, :hca_id, unique: true + end +end diff --git a/db/migrate/20260127_add_slack_id_to_users.rb b/db/migrate/20260127_add_slack_id_to_users.rb new file mode 100644 index 0000000..2351e3a --- /dev/null +++ b/db/migrate/20260127_add_slack_id_to_users.rb @@ -0,0 +1,6 @@ +class AddSlackIdToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :slack_id, :string + add_index :users, :slack_id, unique: true + end +end diff --git a/db/migrate/20260129051530_create_active_storage_tables.active_storage.rb b/db/migrate/20260129051530_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..6bd8bd0 --- /dev/null +++ b/db/migrate/20260129051530_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [ primary_key_type, foreign_key_type ] + end +end diff --git a/db/migrate/20260129051531_create_uploads.rb b/db/migrate/20260129051531_create_uploads.rb new file mode 100644 index 0000000..a5cdfb6 --- /dev/null +++ b/db/migrate/20260129051531_create_uploads.rb @@ -0,0 +1,20 @@ +class CreateUploads < ActiveRecord::Migration[8.0] + def change + create_table :uploads, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :bigint + t.references :blob, null: false, foreign_key: { to_table: :active_storage_blobs } + + # Upload source tracking + t.string :provenance, null: false # enum: slack, web, api, rescued + + # For rescued files from old hel1 bucket + t.string :original_url # Old CDN URL to fixup + + t.timestamps + + t.index [ :user_id, :created_at ] + t.index :created_at + t.index :provenance + end + end +end diff --git a/db/migrate/20260129201832_create_api_keys.rb b/db/migrate/20260129201832_create_api_keys.rb new file mode 100644 index 0000000..557e8da --- /dev/null +++ b/db/migrate/20260129201832_create_api_keys.rb @@ -0,0 +1,16 @@ +class CreateAPIKeys < ActiveRecord::Migration[8.0] + def change + create_table :api_keys do |t| + t.references :user, null: false, foreign_key: true, type: :bigint + t.string :name, null: false + t.text :token_ciphertext, null: false # Lockbox encrypted token + t.string :token_bidx, null: false # Blind index for lookup + t.boolean :revoked, default: false, null: false + t.datetime :revoked_at + t.timestamps + + t.index :token_bidx, unique: true + t.index [ :user_id, :revoked ] + end + end +end diff --git a/db/migrate/20260130161152_add_quota_policy_to_users.rb b/db/migrate/20260130161152_add_quota_policy_to_users.rb new file mode 100644 index 0000000..4494108 --- /dev/null +++ b/db/migrate/20260130161152_add_quota_policy_to_users.rb @@ -0,0 +1,5 @@ +class AddQuotaPolicyToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :quota_policy, :string + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 0000000..4b2cfdb --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,129 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release" + t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id" + t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name" + t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at" + t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering" + t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index ["priority", "job_id"], name: "index_solid_queue_poll_all" + t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at" + t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value" + t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..af9feca --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,92 @@ +# 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[8.0].define(version: 2026_01_30_161152) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + + create_table "api_keys", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "name", null: false + t.text "token_ciphertext", null: false + t.string "token_bidx", null: false + t.boolean "revoked", default: false, null: false + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token_bidx"], name: "index_api_keys_on_token_bidx", unique: true + t.index ["user_id", "revoked"], name: "index_api_keys_on_user_id_and_revoked" + t.index ["user_id"], name: "index_api_keys_on_user_id" + end + + create_table "uploads", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "blob_id", null: false + t.string "provenance", null: false + t.string "original_url" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["blob_id"], name: "index_uploads_on_blob_id" + t.index ["created_at"], name: "index_uploads_on_created_at" + t.index ["provenance"], name: "index_uploads_on_provenance" + t.index ["user_id", "created_at"], name: "index_uploads_on_user_id_and_created_at" + t.index ["user_id"], name: "index_uploads_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.string "hca_id" + t.text "hca_access_token" + t.string "email" + t.string "name" + t.boolean "is_admin", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "slack_id" + t.string "quota_policy" + t.index ["hca_id"], name: "index_users_on_hca_id", unique: true + t.index ["slack_id"], name: "index_users_on_slack_id", unique: true + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "api_keys", "users" + add_foreign_key "uploads", "active_storage_blobs", column: "blob_id" + add_foreign_key "uploads", "users" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..c1f61c8 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,47 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end + +if Rails.env.development? + user = User.first || User.create!( + hca_id: 'dev_user', + email: 'dev@example.com', + name: 'Dev User' + ) + + provenances = [ :web, :api, :slack, :rescued ] + + 10.times do |i| + # Create dummy file content + content = "This is test file #{i} content. " * 100 + + # Create blob (ActiveStorage handles file storage to ./storage/ in dev) + blob = ActiveStorage::Blob.create_and_upload!( + io: StringIO.new(content), + filename: "test_file_#{i}.jpg", + content_type: 'image/jpeg' + ) + + # Create upload record + provenance = provenances.sample + Upload.create!( + user: user, + blob: blob, + provenance: provenance, + original_url: provenance == :rescued ? "https://hel1.cdn.hackclub.com/old_file_#{i}.jpg" : nil, + created_at: rand(30.days.ago..Time.current) + ) + end + + puts "Created #{Upload.count} sample uploads for #{user.name}" + puts "Provenance breakdown:" + Upload.group(:provenance).count.each do |prov, count| + puts " #{prov}: #{count}" + end +end diff --git a/index.js b/index.js deleted file mode 100644 index 788abff..0000000 --- a/index.js +++ /dev/null @@ -1,57 +0,0 @@ -const logger = require('./src/config/logger'); - -logger.info('Starting CDN application 🚀'); - -const express = require('express'); -const cors = require('cors'); -const apiRoutes = require('./src/api/index.js'); - -// API server -const expressApp = express(); -expressApp.use(cors()); -expressApp.use(express.json()); -expressApp.use(express.urlencoded({ extended: true })); - -// Mount API for all versions -expressApp.use('/api', apiRoutes); - -// redirect route to "https://github.com/hackclub/cdn" -expressApp.get('/', (req, res) => { - res.redirect('https://github.com/hackclub/cdn'); -}); - -// Error handling middleware -expressApp.use((err, req, res, next) => { - logger.error('API Error:', { - error: err.message, - stack: err.stack, - path: req.path, - method: req.method - }); - res.status(500).json({ error: 'Internal server error' }); -}); - -// Fallback route for unhandled paths -expressApp.use((req, res, next) => { - logger.warn(`Unhandled route: ${req.method} ${req.path}`); - res.status(404).json({ error: 'Not found' }); -}); - -// Startup LOGs -(async () => { - try { - const port = parseInt(process.env.PORT || '4553', 10); - expressApp.listen(port, () => { - logger.info('CDN started successfully 🔥', { - apiPort: port, - startTime: new Date().toISOString() - }); - }); - } catch (error) { - logger.error('Failed to start application:', { - error: error.message, - stack: error.stack - }); - process.exit(1); - } -})(); diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index 1b86fab..1548729 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,16 @@ { - "name": "cdn-v2-hackclub", - "version": "1.0.0", - "description": "API to upload files to S3-compatible storage with unique URLs", - "main": "index.js", - "scripts": { - "start": "bun index.js", - "dev": "bun --watch index.js" + "private": true, + "type": "module", + "devDependencies": { + "sass-embedded": "^1.97.3", + "vite": "^6.0.0", + "vite-plugin-ruby": "5.1.1" }, "dependencies": { - "@aws-sdk/client-s3": "^3.478.0", - "@smithy/fetch-http-handler": "^5.1.0", - "cors": "^2.8.5", - "express": "^4.21.2", - "multer": "^1.4.5-lts.1", - "node-fetch": "^2.6.1", - "p-limit": "^6.2.0", - "winston": "^3.17.0" - }, - "author": "", - "license": "MIT" + "@hotwired/turbo": "^8.0.22", + "@primer/css": "^22.1.0", + "@primer/primitives": "^11.3.2", + "@primer/view-components": "^0.49.0", + "@rails/ujs": "^7.1.3-4" + } } diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000..282dbc8 --- /dev/null +++ b/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..c0670bc --- /dev/null +++ b/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn’t exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 0000000..9532a9c --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,114 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..8bcf060 --- /dev/null +++ b/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..d77718c --- /dev/null +++ b/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We’re sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 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 literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..04b34bf --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/api.js b/src/api.js deleted file mode 100644 index bfc5247..0000000 --- a/src/api.js +++ /dev/null @@ -1,17 +0,0 @@ -const express = require('express'); -const multer = require('multer'); -const router = express.Router(); -const upload = multer({dest: 'uploads/'}); - -router.post('/upload', upload.single('file'), (req, res) => { - if (!req.file) { - return res.status(400).send('No file uploaded.'); - } - - // Handle the uploaded file - console.log('Uploaded file:', req.file); - - res.send('File uploaded successfully.'); -}); - -module.exports = router; diff --git a/src/api/deploy.js b/src/api/deploy.js deleted file mode 100644 index 5c5b4f1..0000000 --- a/src/api/deploy.js +++ /dev/null @@ -1,27 +0,0 @@ -const logger = require('../config/logger'); -const {generateApiUrl, getCdnUrl} = require('./utils'); - -const deployEndpoint = async (files) => { - try { - const deployedFiles = files.map(file => ({ - deployedUrl: generateApiUrl('v3', file.file), - cdnUrl: getCdnUrl(), - contentType: file.contentType || 'application/octet-stream', - ...file - })); - - return { - status: 200, - files: deployedFiles, - cdnBase: getCdnUrl() - }; - } catch (error) { - logger.error('S3 deploy error:', error); - return { - status: 500, - files: [] - }; - } -}; - -module.exports = {deployEndpoint}; \ No newline at end of file diff --git a/src/api/index.js b/src/api/index.js deleted file mode 100644 index 3b85d3e..0000000 --- a/src/api/index.js +++ /dev/null @@ -1,86 +0,0 @@ -const express = require('express'); -const {validateToken, validateRequest, getCdnUrl} = require('./utils'); -const {uploadEndpoint, handleUpload} = require('./upload'); -const logger = require('../config/logger'); - -const router = express.Router(); - -// Require valid API token for all routes -router.use((req, res, next) => { - const tokenCheck = validateToken(req); - if (tokenCheck.status !== 200) { - return res.status(tokenCheck.status).json(tokenCheck.body); - } - next(); -}); - -// Health check route -router.get('/health', (req, res) => { - res.status(200).json({ status: 'ok' }); -}); - -// Format response based on API version compatibility -const formatResponse = (results, version) => { - switch (version) { - case 1: - return results.map(r => r.url); - case 2: - return results.reduce((acc, r, i) => { - const fileName = r.url.split('/').pop(); - acc[`${i}${fileName}`] = r.url; - return acc; - }, {}); - default: - return { - files: results.map((r, i) => ({ - deployedUrl: r.url, - file: `${i}_${r.url.split('/').pop()}`, - sha: r.sha, - size: r.size - })), - cdnBase: getCdnUrl() - }; - } -}; - -// Handle bulk file uploads with version-specific responses -const handleBulkUpload = async (req, res, version) => { - try { - const urls = req.body; - // Basic validation - if (!Array.isArray(urls) || !urls.length) { - return res.status(422).json({error: 'Empty/invalid file array'}); - } - - const downloadAuth = req.headers?.['x-download-authorization']; - // Process all URLs concurrently - logger.debug(`Processing ${urls.length} URLs`); - const results = await Promise.all( - urls.map(url => uploadEndpoint(url, downloadAuth)) - ); - - res.json(formatResponse(results, version)); - } catch (error) { - logger.error('Bulk upload failed:', error); - res.status(500).json({error: 'Internal server error'}); - } -}; - -// API Routes -router.post('/v1/new', (req, res) => handleBulkUpload(req, res, 1)); // Legacy support -router.post('/v2/new', (req, res) => handleBulkUpload(req, res, 2)); // Legacy support -router.post('/v3/new', (req, res) => handleBulkUpload(req, res, 3)); // Current version -router.post('/new', (req, res) => handleBulkUpload(req, res, 3)); // Alias for v3 (latest) - -// Single file upload endpoint -router.post('/upload', async (req, res) => { - try { - const result = await handleUpload(req); - res.status(result.status).json(result.body); - } catch (error) { - logger.error('S3 upload handler error:', error); - res.status(500).json({error: 'Storage upload failed'}); - } -}); - -module.exports = router; \ No newline at end of file diff --git a/src/api/upload.js b/src/api/upload.js deleted file mode 100644 index b982bc1..0000000 --- a/src/api/upload.js +++ /dev/null @@ -1,121 +0,0 @@ -const fetch = require('node-fetch'); -const crypto = require('crypto'); -const {uploadToStorage} = require('../storage'); -const {generateUrl} = require('./utils'); -const logger = require('../config/logger'); - -// Sanitize file name for storage -function sanitizeFileName(fileName) { - let sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - if (!sanitizedFileName) { - sanitizedFileName = 'upload_' + Date.now(); - } - return sanitizedFileName; -} - -// Handle remote file upload to S3 storage -const uploadEndpoint = async (url, downloadAuth = null) => { - try { - logger.debug('Starting download', { url }); - const headers = {}; - - if (downloadAuth) { - headers['Authorization'] = downloadAuth.startsWith('Bearer ') - ? downloadAuth - : `Bearer ${downloadAuth}`; - } - - const response = await fetch(url, { headers }); - - if (!response.ok) { - const error = new Error(`Download failed: ${response.statusText}`); - error.statusCode = response.status; - error.code = 'DOWNLOAD_FAILED'; - if (response.status === 401 || response.status === 403) { - error.code = 'AUTH_FAILED'; - error.message = 'Authentication failed for protected resource'; - } - throw error; - } - - // Generate unique filename using SHA1 (hash) of file contents - const buffer = await response.buffer(); - const sha = crypto.createHash('sha1').update(buffer).digest('hex'); - const originalName = url.split('/').pop(); - const sanitizedFileName = sanitizeFileName(originalName); - const fileName = `${sha}_${sanitizedFileName}`; - - // Upload to S3 storage - logger.debug(`Uploading: ${fileName}`); - const uploadResult = await uploadToStorage('s/v3', fileName, buffer, response.headers.get('content-type'), buffer.length); - if (uploadResult.success === false) { - throw new Error(`Storage upload failed: ${uploadResult.error}`); - } - - return { - url: generateUrl('s/v3', fileName), - sha, - size: buffer.length, - type: response.headers.get('content-type') - }; - } catch (error) { - logger.error('Upload process failed', { - url, - error: error.message, - code: error.code, - statusCode: error.statusCode, - stack: error.stack - }); - - const statusCode = error.statusCode || 500; - const errorResponse = { - error: { - message: error.message, - code: error.code || 'INTERNAL_ERROR', - details: error.details || null, - url: url - }, - success: false - }; - - throw { statusCode, ...errorResponse }; - } -}; - -// Express request handler for file uploads -const handleUpload = async (req) => { - try { - const url = req.body || await req.text(); - const downloadAuth = req.headers?.['x-download-authorization']?.toString(); - - if (url.includes('files.slack.com') && !downloadAuth) { - return { - status: 400, - body: { - error: { - message: 'X-Download-Authorization required for Slack files', - code: 'AUTH_REQUIRED', - details: 'Slack files require authentication' - }, - success: false - } - }; - } - - const result = await uploadEndpoint(url, downloadAuth); - return { status: 200, body: result }; - } catch (error) { - return { - status: error.statusCode || 500, - body: { - error: error.error || { - message: 'Internal server error', - code: 'INTERNAL_ERROR' - }, - success: false - } - }; - } -}; - -module.exports = {uploadEndpoint, handleUpload}; diff --git a/src/api/utils.js b/src/api/utils.js deleted file mode 100644 index 1dacc59..0000000 --- a/src/api/utils.js +++ /dev/null @@ -1,43 +0,0 @@ -const getCdnUrl = () => process.env.AWS_CDN_URL; - -const generateUrl = (version, fileName) => { - return `${getCdnUrl()}/${version}/${fileName}`; -}; - -const validateToken = (req) => { - const token = req.headers.authorization?.split('Bearer ')[1]; - if (!token || token !== process.env.API_TOKEN) { - return { - status: 401, - body: {error: 'Unauthorized - Invalid or missing API token'} - }; - } - return {status: 200}; -}; - -const validateRequest = (req) => { - // First check token - const tokenCheck = validateToken(req); - if (tokenCheck.status !== 200) { - return tokenCheck; - } - - // Then check method (copied the thing from old api maybe someone is insane and uses the status and not the code) - if (req.method === 'OPTIONS') { - return {status: 204, body: {status: 'YIPPE YAY. YOU HAVE CLEARANCE TO PROCEED.'}}; - } - if (req.method !== 'POST') { - return { - status: 405, - body: {error: 'Method not allowed, use POST'} - }; - } - return {status: 200}; -}; - -module.exports = { - validateRequest, - validateToken, - generateUrl, - getCdnUrl -}; diff --git a/src/config/logger.js b/src/config/logger.js deleted file mode 100644 index 6b9ca0a..0000000 --- a/src/config/logger.js +++ /dev/null @@ -1,19 +0,0 @@ -const winston = require('winston'); - -const logger = winston.createLogger({ - level: 'info', - format: winston.format.combine( - winston.format.timestamp(), - winston.format.colorize(), - winston.format.printf(({ level, message, timestamp, ...meta }) => { - let output = `${timestamp} ${level}: ${message}`; - if (Object.keys(meta).length > 0) { - output += ` ${JSON.stringify(meta)}`; - } - return output; - }) - ), - transports: [new winston.transports.Console()] -}); - -module.exports = logger; \ No newline at end of file diff --git a/src/storage.js b/src/storage.js deleted file mode 100644 index 6196c3d..0000000 --- a/src/storage.js +++ /dev/null @@ -1,605 +0,0 @@ -const { S3Client, PutObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require('@aws-sdk/client-s3'); -const { FetchHttpHandler } = require('@smithy/fetch-http-handler'); -const crypto = require('crypto'); -const logger = require('./config/logger'); -const {generateFileUrl} = require('./utils'); - -const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB in bytes -const CONCURRENT_UPLOADS = 3; // Max concurrent uploads (messages) - -// processed messages -const processedMessages = new Map(); - -let uploadLimit; - -async function initialize() { - const pLimit = (await import('p-limit')).default; - uploadLimit = pLimit(CONCURRENT_UPLOADS); -} - -// Check if the message is older than 24 hours for when the bot was offline -function isMessageTooOld(eventTs) { - const eventTime = parseFloat(eventTs) * 1000; - const currentTime = Date.now(); - const timeDifference = currentTime - eventTime; - const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - return timeDifference > maxAge; -} - -// check if the message has already been processed -function isMessageProcessed(messageTs) { - return processedMessages.has(messageTs); -} - -function markMessageAsProcessing(messageTs) { - processedMessages.set(messageTs, true); -} - -// Processing reaction -async function addProcessingReaction(client, event, fileMessage) { - try { - await client.reactions.add({ - name: 'beachball', - timestamp: fileMessage.ts, - channel: event.channel_id - }); - } catch (error) { - logger.error('Failed to add processing reaction:', error.message); - } -} - -// sanitize file names and ensure it's not empty (I don't even know if that's possible but let's be safe) -function sanitizeFileName(fileName) { - let sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - if (!sanitizedFileName) { - sanitizedFileName = 'upload_' + Date.now(); - } - return sanitizedFileName; -} - -// Generate a unique file name -function generateUniqueFileName(fileName) { - const sanitizedFileName = sanitizeFileName(fileName); - const uniqueFileName = `${Date.now()}-${crypto.randomBytes(16).toString('hex')}-${sanitizedFileName}`; - return uniqueFileName; -} - -function calculatePartSize(fileSize) { - const MIN_PSIZE = 5242880; // r2 has a 5mb min part size (except last part) - const MAX_PSIZE = 100 * 1024 * 1024; // 100mb maximum per part - const MAX_PARTS = 1000; // aws limit - - let partSize = MIN_PSIZE; - - if (fileSize / MIN_PSIZE > MAX_PARTS) { - partSize = Math.ceil(fileSize / MAX_PARTS); - } - - // hardcode a bit - if (fileSize > 100 * 1024 * 1024) partSize = Math.max(partSize, 10 * 1024 * 1024); // >100mb use 10mb parts - if (fileSize > 500 * 1024 * 1024) partSize = Math.max(partSize, 25 * 1024 * 1024); // >500mb use 25mb parts - if (fileSize > 1024 * 1024 * 1024) partSize = Math.max(partSize, 50 * 1024 * 1024); // >1gb use 50mb parts - - return Math.min(Math.max(partSize, MIN_PSIZE), MAX_PSIZE); -} - -// download file using 206 partial content in chunks for slack only -async function downloadFileInChunks(url, fileSize, authHeader) { - logger.debug('Attempting chunked download', { url, fileSize, chunks: 4 }); - - // First, check if server supports range requests - try { - const headResponse = await fetch(url, { - method: 'HEAD', - headers: { Authorization: authHeader } - }); - - if (!headResponse.ok) { - throw new Error(`HEAD request failed: ${headResponse.status}`); - } - - const acceptsRanges = headResponse.headers.get('accept-ranges'); - if (acceptsRanges !== 'bytes') { - logger.warn('Server may not support range requests', { acceptsRanges }); - } - - // Verify the file size matches - const contentLength = parseInt(headResponse.headers.get('content-length') || '0'); - if (contentLength !== fileSize && contentLength > 0) { - logger.warn('File size mismatch detected', { - expectedSize: fileSize, - actualSize: contentLength - }); - // Use the actual size from the server - fileSize = contentLength; - } - - } catch (headError) { - logger.warn('HEAD request failed, proceeding with chunked download anyway', { - error: headError.message - }); - } - - const chunkSize = Math.ceil(fileSize / 4); - const chunks = []; - - try { - // Download all chunks in parallel - const chunkPromises = []; - - for (let i = 0; i < 4; i++) { - const start = i * chunkSize; - const end = Math.min(start + chunkSize - 1, fileSize - 1); - - chunkPromises.push(downloadChunk(url, start, end, authHeader, i)); - } - - const chunkResults = await Promise.all(chunkPromises); - - // Verify all chunks downloaded successfully - for (let i = 0; i < chunkResults.length; i++) { - if (!chunkResults[i]) { - throw new Error(`Chunk ${i} failed to download`); - } - chunks[i] = chunkResults[i]; - } - - // Combine all chunks into a single buffer - const totalBuffer = Buffer.concat(chunks); - - logger.debug('Chunked download successful', { - totalSize: totalBuffer.length, - expectedSize: fileSize - }); - - return totalBuffer; - - } catch (error) { - logger.error('Chunked download failed', { error: error.message }); - throw error; - } -} - -// Download a single chunk using Range header -async function downloadChunk(url, start, end, authHeader, chunkIndex, retryCount = 0) { - const maxRetries = 3; - - try { - logger.debug(`Downloading chunk ${chunkIndex} (attempt ${retryCount + 1})`, { - start, - end, - size: end - start + 1 - }); - - const response = await fetch(url, { - headers: { - 'Authorization': authHeader, - 'Range': `bytes=${start}-${end}` - } - }); - - if (!response.ok) { - throw new Error(`Chunk ${chunkIndex} download failed: ${response.status} ${response.statusText}`); - } - - // Check if server supports partial content - if (response.status !== 206) { - // If it's a 200 response, the server might be returning the whole file - if (response.status === 200) { - logger.warn(`Chunk ${chunkIndex}: Server returned full file instead of partial content`); - const fullBuffer = await response.buffer(); - - // Extract just the chunk we need from the full file - const chunkBuffer = fullBuffer.slice(start, end + 1); - - logger.debug(`Chunk ${chunkIndex} extracted from full download`, { - actualSize: chunkBuffer.length, - expectedSize: end - start + 1 - }); - - return chunkBuffer; - } else { - throw new Error(`Server doesn't support partial content, got status ${response.status}`); - } - } - - const buffer = await response.buffer(); - - // Verify chunk size - const expectedSize = end - start + 1; - if (buffer.length !== expectedSize) { - throw new Error(`Chunk ${chunkIndex} size mismatch: expected ${expectedSize}, got ${buffer.length}`); - } - - logger.debug(`Chunk ${chunkIndex} downloaded successfully`, { - actualSize: buffer.length, - expectedSize: expectedSize - }); - - return buffer; - - } catch (error) { - logger.error(`Chunk ${chunkIndex} download failed (attempt ${retryCount + 1})`, { - error: error.message - }); - - // Retry logic - if (retryCount < maxRetries) { - const delay = Math.pow(2, retryCount) * 1000; // Exponential backoff - logger.debug(`Retrying chunk ${chunkIndex} in ${delay}ms`); - - await new Promise(resolve => setTimeout(resolve, delay)); - return downloadChunk(url, start, end, authHeader, chunkIndex, retryCount + 1); - } - - throw error; - } -} - -// upload files to the /s/ directory -async function processFiles(fileMessage, client) { - const uploadedFiles = []; - const failedFiles = []; - - logger.debug('Starting file processing', { - userId: fileMessage.user, - fileCount: fileMessage.files?.length || 0 - }); - - const files = fileMessage.files || []; - for (const file of files) { - logger.debug('Processing file', { - name: file.name, - size: file.size, - type: file.mimetype, - id: file.id - }); - - if (file.size > MAX_FILE_SIZE) { - logger.warn('File exceeds size limit', { - name: file.name, - size: file.size, - limit: MAX_FILE_SIZE - }); - failedFiles.push(file.name); - continue; - } - - try { - logger.debug('Fetching file from Slack', { - name: file.name, - url: file.url_private - }); - - let uploadData; - const authHeader = `Bearer ${process.env.SLACK_BOT_TOKEN}`; - - try { - const response = await fetch(file.url_private, { - headers: { Authorization: authHeader } - }); - - if (!response.ok) { - throw new Error(`Slack download failed: ${response.status} ${response.statusText}`); - } - - uploadData = await response.buffer(); - logger.debug('File downloaded', { - fileName: file.name, - size: uploadData.length - }); - - } catch (downloadError) { - logger.warn('Regular download failed, trying chunked download', { - fileName: file.name, - error: downloadError.message - }); - - try { - uploadData = await downloadFileInChunks(file.url_private, file.size, authHeader); - logger.info('Chunked download successful as fallback', { - fileName: file.name, - size: uploadData.length - }); - } catch (chunkedError) { - logger.error('Both regular and chunked downloads failed', { - fileName: file.name, - regularError: downloadError.message, - chunkedError: chunkedError.message - }); - throw new Error(`All download methods failed. Regular: ${downloadError.message}, Chunked: ${chunkedError.message}`); - } - } - - const contentType = file.mimetype || 'application/octet-stream'; - const uniqueFileName = generateUniqueFileName(file.name); - const userDir = `s/${fileMessage.user}`; - - const uploadResult = await uploadLimit(() => - uploadToStorage(userDir, uniqueFileName, uploadData, contentType, file.size) - ); - - if (uploadResult.success === false) { - throw new Error(uploadResult.error); - } - - const url = generateFileUrl(userDir, uniqueFileName); - uploadedFiles.push({ - name: uniqueFileName, - url, - contentType - }); - } catch (error) { - logger.error('File processing failed', { - fileName: file.name, - error: error.message, - stack: error.stack, - slackFileId: file.id, - userId: fileMessage.user - }); - failedFiles.push(file.name); - } - } - - logger.debug('File processing complete', { - successful: uploadedFiles.length, - failed: failedFiles.length - }); - - return {uploadedFiles, failedFiles}; -} - -// update reactions based on success -async function updateReactions(client, event, fileMessage, success) { - try { - await client.reactions.remove({ - name: 'beachball', - timestamp: fileMessage.ts, - channel: event.channel_id - }); - await client.reactions.add({ - name: success ? 'white_check_mark' : 'x', - timestamp: fileMessage.ts, - channel: event.channel_id - }); - } catch (error) { - logger.error('Failed to update reactions:', error.message); - } -} - -// find a file message -async function findFileMessage(event, client) { - try { - const fileInfo = await client.files.info({ - file: event.file_id, - include_shares: true - }); - - if (!fileInfo.ok || !fileInfo.file) { - throw new Error('Could not get file info'); - } - - const channelShare = fileInfo.file.shares?.public?.[event.channel_id] || - fileInfo.file.shares?.private?.[event.channel_id]; - - if (!channelShare || !channelShare.length) { - throw new Error('No share info found for this channel'); - } - - // Get the exact message using the ts from share info - const messageTs = channelShare[0].ts; - - const messageInfo = await client.conversations.history({ - channel: event.channel_id, - latest: messageTs, - limit: 1, - inclusive: true - }); - - if (!messageInfo.ok || !messageInfo.messages.length) { - throw new Error('Could not find original message'); - } - - return messageInfo.messages[0]; - } catch (error) { - logger.error('Error finding file message:', error); - return null; - } -} - -async function sendResultsMessage(client, channelId, fileMessage, uploadedFiles, failedFiles) { - let message = `Hey <@${fileMessage.user}>, `; - if (uploadedFiles.length > 0) { - message += `here ${uploadedFiles.length === 1 ? 'is your link' : 'are your links'}:\n`; - message += uploadedFiles.map(f => `• ${f.name}: ${f.url}`).join('\n'); - } - if (failedFiles.length > 0) { - message += `\n\nFailed to process: ${failedFiles.join(', ')}`; - } - - await client.chat.postMessage({ - channel: channelId, - thread_ts: fileMessage.ts, - text: message - }); -} - -async function handleError(client, channelId, fileMessage, reactionAdded) { - if (fileMessage && reactionAdded) { - try { - await client.reactions.remove({ - name: 'beachball', - timestamp: fileMessage.ts, - channel: channelId - }); - } catch (cleanupError) { - if (cleanupError.data.error !== 'no_reaction') { - logger.error('Cleanup error:', cleanupError); - } - } - try { - await client.reactions.add({ - name: 'x', - timestamp: fileMessage.ts, - channel: channelId - }); - } catch (cleanupError) { - logger.error('Cleanup error:', cleanupError); - } - } -} - -async function handleFileUpload(event, client) { - let fileMessage = null; - let reactionAdded = false; - - try { - if (isMessageTooOld(event.event_ts)) return; - - fileMessage = await findFileMessage(event, client); - if (!fileMessage || isMessageProcessed(fileMessage.ts)) return; - - markMessageAsProcessing(fileMessage.ts); - await addProcessingReaction(client, event, fileMessage); - reactionAdded = true; - - const {uploadedFiles, failedFiles} = await processFiles(fileMessage, client); - await sendResultsMessage(client, event.channel_id, fileMessage, uploadedFiles, failedFiles); - - await updateReactions(client, event, fileMessage, failedFiles.length === 0); - - } catch (error) { - logger.error('Upload failed:', error.message); - await handleError(client, event.channel_id, fileMessage, reactionAdded); - throw error; - } -} - -const s3Client = new S3Client({ - region: process.env.AWS_REGION, - endpoint: process.env.AWS_ENDPOINT, - requestHandler: new FetchHttpHandler(), - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY - }, - forcePathStyle: true, - requestTimeout: 300000, - maxAttempts: 3 -}); - -async function uploadToStorage(userDir, uniqueFileName, bodyData, contentType = 'application/octet-stream', fileSize) { - try { - const key = `${userDir}/${uniqueFileName}`; - - if (fileSize >= 10485760) { // 10mb threshold - return await uploadMultipart(key, bodyData, contentType); - } else { - const params = { - Bucket: process.env.AWS_BUCKET_NAME, - Key: key, - Body: bodyData, - ContentType: contentType, - CacheControl: 'public, immutable, max-age=31536000' - }; - - logger.info(`Single part upload: ${key}`); - await s3Client.send(new PutObjectCommand(params)); - return { success: true }; - } - } catch (error) { - logger.error(`Upload failed: ${error.message}`, { - path: `${userDir}/${uniqueFileName}`, - error: error.message - }); - return { success: false, error: error.message }; - } -} - -async function uploadMultipart(key, bodyData, contentType) { - let uploadId; - - try { - const createParams = { - Bucket: process.env.AWS_BUCKET_NAME, - Key: key, - ContentType: contentType, - CacheControl: 'public, immutable, max-age=31536000' - }; - - const createResult = await s3Client.send(new CreateMultipartUploadCommand(createParams)); - uploadId = createResult.UploadId; - - const partSize = calculatePartSize(bodyData.length); - const totalParts = Math.ceil(bodyData.length / partSize); - - logger.info(`multipart upload: ${key}`, { - uploadId, - fileSize: bodyData.length, - partSize, - totalParts - }); - - const uploadPromises = []; - - for (let partNumber = 1; partNumber <= totalParts; partNumber++) { - const start = (partNumber - 1) * partSize; - const end = Math.min(start + partSize, bodyData.length); // last part can be below 5mb and below but not above normal part size - const partData = bodyData.slice(start, end); - - const uploadPartParams = { - Bucket: process.env.AWS_BUCKET_NAME, - Key: key, - PartNumber: partNumber, - UploadId: uploadId, - Body: partData - }; - - const uploadPromise = s3Client.send(new UploadPartCommand(uploadPartParams)) - .then(result => ({ - PartNumber: partNumber, - ETag: result.ETag - })); - - uploadPromises.push(uploadPromise); - } - - const parts = await Promise.all(uploadPromises); - parts.sort((a, b) => a.PartNumber - b.PartNumber); - - const completeParams = { - Bucket: process.env.AWS_BUCKET_NAME, - Key: key, - UploadId: uploadId, - MultipartUpload: { Parts: parts } - }; - - await s3Client.send(new CompleteMultipartUploadCommand(completeParams)); - logger.info(`multipart upload completed: ${key}`); - - return { success: true }; - - } catch (error) { - if (uploadId) { - try { - await s3Client.send(new AbortMultipartUploadCommand({ - Bucket: process.env.AWS_BUCKET_NAME, - Key: key, - UploadId: uploadId - })); - logger.info(`aborted multipart upload: ${key}`); - } catch (abortError) { - logger.error(`failed to abort multipart upload: ${abortError.message}`); - } - } - throw error; - } -} - -module.exports = { - handleFileUpload, - initialize, - uploadToStorage, - downloadFileInChunks, - downloadChunk -}; diff --git a/src/upload.js b/src/upload.js deleted file mode 100644 index fa73ab2..0000000 --- a/src/upload.js +++ /dev/null @@ -1,35 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const {uploadToStorage} = require('../storage'); -const {generateUrl} = require('./utils'); -const logger = require('../config/logger'); - -// Handle individual file upload -const handleUpload = async (file) => { - try { - const buffer = fs.readFileSync(file.path); - const fileName = path.basename(file.originalname); - // content type detection for S3 - const contentType = file.mimetype || 'application/octet-stream'; - const uniqueFileName = `${Date.now()}-${fileName}`; - - // Upload to S3 - logger.debug(`Uploading: ${uniqueFileName}`); - const uploaded = await uploadToStorage('s/v3', uniqueFileName, buffer, contentType); - if (!uploaded) throw new Error('Storage upload failed'); - - return { - name: fileName, - url: generateUrl('s/v3', uniqueFileName), - contentType - }; - } catch (error) { - logger.error('Upload failed:', error); - throw error; - } finally { - // Clean up the temporary file - fs.unlinkSync(file.path); - } -}; - -module.exports = {handleUpload}; diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index b3fabf8..0000000 --- a/src/utils.js +++ /dev/null @@ -1,8 +0,0 @@ -// Make the CDN URL - -function generateFileUrl(userDir, uniqueFileName) { - const cdnUrl = process.env.AWS_CDN_URL; - return `${cdnUrl}/${userDir}/${uniqueFileName}`; -} - -module.exports = {generateFileUrl}; \ No newline at end of file diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 0000000..cee29fd --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,5 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/controllers/api/v4/uploads_controller_test.rb b/test/controllers/api/v4/uploads_controller_test.rb new file mode 100644 index 0000000..18e2408 --- /dev/null +++ b/test/controllers/api/v4/uploads_controller_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "test_helper" + +class API::V4::UploadsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + @api_key = @user.api_keys.create!(name: "Test Key") + @token = @api_key.token + end + + test "should upload file with valid token" do + file = fixture_file_upload("test.png", "image/png") + + assert_difference("Upload.count", 1) do + post api_v4_upload_url, + params: { file: file }, + headers: { "Authorization" => "Bearer #{@token}" } + end + + assert_response :created + json = JSON.parse(response.body) + assert json["id"].present? + assert_equal "test.png", json["filename"] + assert json["url"].present? + assert json["created_at"].present? + end + + test "should reject upload without token" do + file = fixture_file_upload("test.png", "image/png") + + post api_v4_upload_url, params: { file: file } + + assert_response :unauthorized + end + + test "should reject upload without file parameter" do + post api_v4_upload_url, + headers: { "Authorization" => "Bearer #{@token}" } + + assert_response :bad_request + json = JSON.parse(response.body) + assert_equal "Missing file parameter", json["error"] + end + + test "should upload from URL with valid token" do + url = "https://example.com/test.jpg" + + # Stub the URI.open call + file_double = StringIO.new("fake image data") + URI.stub :open, file_double do + assert_difference("Upload.count", 1) do + post api_v4_upload_from_url_url, + params: { url: url }.to_json, + headers: { + "Authorization" => "Bearer #{@token}", + "Content-Type" => "application/json" + } + end + end + + assert_response :created + json = JSON.parse(response.body) + assert json["id"].present? + assert json["url"].present? + end + + test "should reject upload from URL without url parameter" do + post api_v4_upload_from_url_url, + params: {}.to_json, + headers: { + "Authorization" => "Bearer #{@token}", + "Content-Type" => "application/json" + } + + assert_response :bad_request + json = JSON.parse(response.body) + assert_equal "Missing url parameter", json["error"] + end + + test "should handle upload errors gracefully" do + url = "https://example.com/broken.jpg" + + # Simulate an error + URI.stub :open, ->(_) { raise StandardError, "Network error" } do + post api_v4_upload_from_url_url, + params: { url: url }.to_json, + headers: { + "Authorization" => "Bearer #{@token}", + "Content-Type" => "application/json" + } + end + + assert_response :unprocessable_entity + json = JSON.parse(response.body) + assert json["error"].include?("Upload failed") + end +end diff --git a/test/controllers/api/v4/users_controller_test.rb b/test/controllers/api/v4/users_controller_test.rb new file mode 100644 index 0000000..26eb12a --- /dev/null +++ b/test/controllers/api/v4/users_controller_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" + +class API::V4::UsersControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:one) + @api_key = @user.api_keys.create!(name: "Test Key") + @token = @api_key.token + end + + test "should get user info with valid token" do + get api_v4_me_url, headers: { "Authorization" => "Bearer #{@token}" } + + assert_response :success + json = JSON.parse(response.body) + assert_equal @user.public_id, json["id"] + assert_equal @user.email, json["email"] + assert_equal @user.name, json["name"] + end + + test "should reject request without token" do + get api_v4_me_url + + assert_response :unauthorized + json = JSON.parse(response.body) + assert_equal "invalid_auth", json["error"] + end + + test "should reject request with invalid token" do + get api_v4_me_url, headers: { "Authorization" => "Bearer sk_cdn_invalid" } + + assert_response :unauthorized + json = JSON.parse(response.body) + assert_equal "invalid_auth", json["error"] + end + + test "should reject request with revoked token" do + @api_key.revoke! + + get api_v4_me_url, headers: { "Authorization" => "Bearer #{@token}" } + + assert_response :unauthorized + json = JSON.parse(response.body) + assert_equal "invalid_auth", json["error"] + end +end diff --git a/test/controllers/api_keys_controller_test.rb b/test/controllers/api_keys_controller_test.rb new file mode 100644 index 0000000..475a843 --- /dev/null +++ b/test/controllers/api_keys_controller_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "test_helper" + +class APIKeysControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + setup do + @user = users(:one) + sign_in @user + end + + test "should get index" do + get api_keys_url + assert_response :success + end + + test "should create api key" do + assert_difference("APIKey.count", 1) do + post api_keys_url, params: { api_key: { name: "New Key" } } + end + + assert_redirected_to api_keys_url + assert flash[:api_key_token].present? + assert flash[:api_key_token].start_with?("sk_cdn_") + end + + test "should not create api key without name" do + assert_no_difference("APIKey.count") do + post api_keys_url, params: { api_key: { name: "" } } + end + + assert_redirected_to api_keys_url + assert flash[:alert].present? + end + + test "should revoke api key" do + api_key = @user.api_keys.create!(name: "Test Key") + + delete api_key_url(api_key) + + assert_redirected_to api_keys_url + assert api_key.reload.revoked + end + + test "should not allow revoking other users api keys" do + other_user = users(:two) + api_key = other_user.api_keys.create!(name: "Other Key") + + delete api_key_url(api_key) + + assert_redirected_to api_keys_url + assert flash[:alert].present? + assert_not api_key.reload.revoked + end +end diff --git a/test/controllers/static_pages_controller_test.rb b/test/controllers/static_pages_controller_test.rb new file mode 100644 index 0000000..bbd5bd9 --- /dev/null +++ b/test/controllers/static_pages_controller_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class StaticPagesControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/fixtures/api_keys.yml b/test/fixtures/api_keys.yml new file mode 100644 index 0000000..17672e9 --- /dev/null +++ b/test/fixtures/api_keys.yml @@ -0,0 +1,17 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + name: Test Key 1 + revoked: false + +two: + user: one + name: Test Key 2 + revoked: false + +revoked: + user: one + name: Revoked Key + revoked: true + revoked_at: <%= 1.day.ago.to_s(:db) %> diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/files/test.png b/test/fixtures/files/test.png new file mode 100644 index 0000000000000000000000000000000000000000..d624eebcc1a0c2b8e1a883dd19cb2b87282eba2c GIT binary patch literal 5182 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 Ua=0Y2oPCr*)NiYmsoo9zKkc_R;{X5v literal 0 HcmV?d00001 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..f9bb65f --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + hca_id: MyString + hca_access_token: MyString + email: MyString + name: MyString + is_admin: false + +two: + hca_id: MyString + hca_access_token: MyString + email: MyString + name: MyString + is_admin: false diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb new file mode 100644 index 0000000..531d87b --- /dev/null +++ b/test/models/api_key_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "test_helper" + +class APIKeyTest < ActiveSupport::TestCase + test "generates token on create" do + user = users(:one) + api_key = user.api_keys.create!(name: "Test Key") + + assert api_key.token.present? + assert api_key.token.start_with?("sk_cdn_") + end + + test "token is encrypted in database" do + user = users(:one) + api_key = user.api_keys.create!(name: "Test Key") + + # Check that the ciphertext is different from the plaintext + raw_record = APIKey.connection.select_one( + "SELECT token_ciphertext FROM api_keys WHERE id = #{api_key.id}" + ) + assert_not_equal api_key.token, raw_record["token_ciphertext"] + end + + test "find_by_token uses blind index" do + user = users(:one) + api_key = user.api_keys.create!(name: "Test Key") + token = api_key.token + + found = APIKey.find_by_token(token) + assert_equal api_key.id, found.id + end + + test "find_by_token returns nil for invalid token" do + found = APIKey.find_by_token("sk_cdn_invalid_token") + assert_nil found + end + + test "active scope excludes revoked keys" do + active_count = APIKey.active.count + APIKey.create!(user: users(:one), name: "New Key") + + assert_equal active_count + 1, APIKey.active.count + end + + test "revoke! marks key as revoked" do + api_key = api_keys(:one) + assert api_key.active? + + api_key.revoke! + + assert api_key.revoked + assert_not api_key.active? + assert api_key.revoked_at.present? + end + + test "masked_token shows prefix and suffix" do + user = users(:one) + api_key = user.api_keys.create!(name: "Test Key") + + masked = api_key.masked_token + assert masked.include?("sk_cdn_") + assert masked.include?("....") + assert_equal 23, masked.length # "sk_cdn_" (7) + 6 chars + "...." (4) + 6 chars + end + + test "validates name presence" do + api_key = APIKey.new(user: users(:one)) + assert_not api_key.valid? + assert_includes api_key.errors[:name], "can't be blank" + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..5c07f49 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/system/.keep b/test/system/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..0c22470 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,15 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..784ca10 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,31 @@ +import {defineConfig} from 'vite' +import RubyPlugin from 'vite-plugin-ruby' + +export default defineConfig({ + plugins: [ + RubyPlugin(), + ], + + build: { + minify: false + }, + + resolve: { + dedupe: ['@primer/view-components', '@github/catalyst'] + }, + + optimizeDeps: { + include: ['@primer/view-components'], + esbuildOptions: { + keepNames: true + } + }, + + server: { + hmr: { + overlay: true + } + }, + + +}) diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..0b6575c --- /dev/null +++ b/yarn.lock @@ -0,0 +1,958 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@bufbuild/protobuf@^2.5.0": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-2.11.0.tgz#3ec3985c9074b23aea337957225fe15a0e845f8e" + integrity sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ== + +"@esbuild/aix-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" + integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== + +"@esbuild/android-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" + integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== + +"@esbuild/android-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" + integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== + +"@esbuild/android-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" + integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== + +"@esbuild/darwin-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz#79197898ec1ff745d21c071e1c7cc3c802f0c1fd" + integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== + +"@esbuild/darwin-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" + integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== + +"@esbuild/freebsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" + integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== + +"@esbuild/freebsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" + integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== + +"@esbuild/linux-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" + integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== + +"@esbuild/linux-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" + integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== + +"@esbuild/linux-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" + integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== + +"@esbuild/linux-loong64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" + integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== + +"@esbuild/linux-mips64el@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" + integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== + +"@esbuild/linux-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" + integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== + +"@esbuild/linux-riscv64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" + integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== + +"@esbuild/linux-s390x@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" + integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== + +"@esbuild/linux-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" + integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== + +"@esbuild/netbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" + integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== + +"@esbuild/netbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" + integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== + +"@esbuild/openbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" + integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== + +"@esbuild/openbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" + integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== + +"@esbuild/openharmony-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" + integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== + +"@esbuild/sunos-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" + integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== + +"@esbuild/win32-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" + integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== + +"@esbuild/win32-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" + integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== + +"@esbuild/win32-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" + integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== + +"@github/auto-check-element@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@github/auto-check-element/-/auto-check-element-6.0.0.tgz#4baa1750599532c8ab79877c69ab677a0a5c411c" + integrity sha512-87mHEywJEtlG/37zFrx4PUgDqczgtv9jrauW3IojNy9y+nALIAm6e2jnWpfgcqeMWSevzph2M6reJoHpuSjyWw== + dependencies: + "@github/mini-throttle" "^2.1.0" + +"@github/auto-complete-element@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@github/auto-complete-element/-/auto-complete-element-3.8.0.tgz#8be0480b8412ffb4fb71b54a68513c5e29693d86" + integrity sha512-rS2Uj38V1BsenLvrIswV5IXfiYH2/KUhz6inot+JXho/fFOO+01tsW1HxqSdIXqh5EDuoY0f/GQsztZcH22AXQ== + dependencies: + "@github/combobox-nav" "^2.1.7" + +"@github/catalyst@^1.6.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@github/catalyst/-/catalyst-1.7.0.tgz#12dd1097a66260f0dde1d1d1f5ebfe7d1f576a80" + integrity sha512-qOAxrDdRZz9+v4y2WoAfh11rpRY/x4FRofPNmJyZFzAjubtzE3sCa/tAycWWufmQGoYiwwzL/qJBBgyg7avxPw== + +"@github/clipboard-copy-element@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@github/clipboard-copy-element/-/clipboard-copy-element-1.3.0.tgz#d518d2aadb677c5c9560a54d7fd4b0d3abf00a9b" + integrity sha512-wyntkQkwoLbLo+Hqg2LIVMXDIzcvUb9bSDz+clX6nVJItwzh103rHxdXFRZD+DmxVbuEW5xSznYQXkz1jZT+xg== + +"@github/combobox-nav@^2.1.7": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@github/combobox-nav/-/combobox-nav-2.3.1.tgz#76da4c47f1e33af56392c5c9cf661a28d5b4168e" + integrity sha512-gwxPzLw8XKecy1nP63i9lOBritS3bWmxl02UX6G0TwMQZbMem1BCS1tEZgYd3mkrkiDrUMWaX+DbFCuDFo3K+A== + +"@github/details-menu-element@^1.0.12": + version "1.0.13" + resolved "https://registry.yarnpkg.com/@github/details-menu-element/-/details-menu-element-1.0.13.tgz#d62263077b16bc7edc386e7b23f0ce41af1301b4" + integrity sha512-gMkii86w/oUP5dq8yOWZn1sgbgtFj3AYETxxtpsqRggZktgd8te4+npAn4Hm+936c/lxmEzXqfjARL/CzGR4+w== + +"@github/image-crop-element@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@github/image-crop-element/-/image-crop-element-5.0.0.tgz#6ae2c31f1e7dc355c41c3140554fb76ca7a71ef7" + integrity sha512-Vgm2OwWAs1ESoib/t5sjxsAYo6YTOxxAjWDRxswX7qrqoyCejTZ3hshdo4Ep5e+Mz/GVTZC3rdMtg06dk/eT4g== + +"@github/include-fragment-element@^6.3.0": + version "6.4.1" + resolved "https://registry.yarnpkg.com/@github/include-fragment-element/-/include-fragment-element-6.4.1.tgz#fb20bfa9b0655a5d894fd40ec5faafcddc8db80c" + integrity sha512-ffgXc7qwBtY/rYcMkAjxZJlyOPFaeC9K1Oc+n7Edwt3BAHPokUSdMfDivb+/dGO+NU2n7l1/L4v5uQN+wBeV4g== + +"@github/mini-throttle@^2.1.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@github/mini-throttle/-/mini-throttle-2.1.1.tgz#c66db5c3b857aaf8c467de2528cd92f36f542ac5" + integrity sha512-KtOPaB+FiKJ6jcKm9UKyaM5fPURHGf+xcp+b4Mzoi81hOc6M1sIGpMZMAVbNzfa2lW5+RPGKq888Px0j76OZ/A== + +"@github/relative-time-element@^4.0.0": + version "4.5.1" + resolved "https://registry.yarnpkg.com/@github/relative-time-element/-/relative-time-element-4.5.1.tgz#66a9ba300b03982950d75f417f3f2ad27236bffb" + integrity sha512-uxCxCwe9vdwUDmRmM84tN0UERlj8MosLV44+r/VDj7DZUVUSTP4vyWlE9mRK6vHelOmT8DS3RMlaMrLlg1h1PQ== + +"@github/remote-input-element@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@github/remote-input-element/-/remote-input-element-0.4.0.tgz#76956e82342d5887b68f3fe7ce8c20ff1f55181b" + integrity sha512-apsMwsFW24F+w2wzT8oKoBi9lpm6GeFOmtuL+1YwDVmIiwixfHOD3MnEsEOv0RwmHsMdWmIjP9mxWyTWPKZHGg== + +"@github/tab-container-element@^3.1.2": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@github/tab-container-element/-/tab-container-element-3.4.0.tgz#0a167ed81c9f4a1c1c79f0d0a6a8c012c49a6147" + integrity sha512-Yx70pO8A0p7Stnm9knKkUNX8i4bjuwDYZarRkM8JH0Z+ffhpe++oNAPbzGI9GEcGugRHvKuSC6p4YOdoHtTniQ== + +"@hotwired/turbo@^8.0.22": + version "8.0.22" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.22.tgz#f6f8c0756f48ce83c31675998d55c37fdf1a6828" + integrity sha512-A7M8vBgsmZ8W55IOEhTN7o0++zaJTavyGa1DW1rt+/b4VTr8QUWC/zu8wxMmwqvczaOe1nI/MkMq4lm2NmiHPg== + +"@lit-labs/ssr-dom-shim@^1.2.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz#3166900c0d481f03d6d4133686e0febf760d521d" + integrity sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA== + +"@nodelib/fs.scandir@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" + integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== + dependencies: + "@nodelib/fs.stat" "2.0.5" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" + integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + +"@nodelib/fs.walk@^1.2.3": + version "1.2.8" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" + integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== + dependencies: + "@nodelib/fs.scandir" "2.1.5" + fastq "^1.6.0" + +"@oddbird/popover-polyfill@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@oddbird/popover-polyfill/-/popover-polyfill-0.5.2.tgz#9a142cae54b6e48824bec61e94e39541c9746b69" + integrity sha512-iFrvar5SOMtKFOSjYvs4z9UlLqDdJbMx0mgISLcPedv+g0ac5sgeETLGtipHCVIae6HJPclNEH5aCyD1RZaEHw== + +"@parcel/watcher-android-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564" + integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A== + +"@parcel/watcher-darwin-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz#88d3e720b59b1eceffce98dac46d7c40e8be5e8e" + integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA== + +"@parcel/watcher-darwin-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063" + integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg== + +"@parcel/watcher-freebsd-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53" + integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng== + +"@parcel/watcher-linux-arm-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a" + integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ== + +"@parcel/watcher-linux-arm-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152" + integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg== + +"@parcel/watcher-linux-arm64-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809" + integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA== + +"@parcel/watcher-linux-arm64-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4" + integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA== + +"@parcel/watcher-linux-x64-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639" + integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ== + +"@parcel/watcher-linux-x64-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2" + integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg== + +"@parcel/watcher-win32-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e" + integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q== + +"@parcel/watcher-win32-ia32@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d" + integrity sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g== + +"@parcel/watcher-win32-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d" + integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw== + +"@parcel/watcher@^2.4.1": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.6.tgz#3f932828c894f06d0ad9cfefade1756ecc6ef1f1" + integrity sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ== + dependencies: + detect-libc "^2.0.3" + is-glob "^4.0.3" + node-addon-api "^7.0.0" + picomatch "^4.0.3" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.5.6" + "@parcel/watcher-darwin-arm64" "2.5.6" + "@parcel/watcher-darwin-x64" "2.5.6" + "@parcel/watcher-freebsd-x64" "2.5.6" + "@parcel/watcher-linux-arm-glibc" "2.5.6" + "@parcel/watcher-linux-arm-musl" "2.5.6" + "@parcel/watcher-linux-arm64-glibc" "2.5.6" + "@parcel/watcher-linux-arm64-musl" "2.5.6" + "@parcel/watcher-linux-x64-glibc" "2.5.6" + "@parcel/watcher-linux-x64-musl" "2.5.6" + "@parcel/watcher-win32-arm64" "2.5.6" + "@parcel/watcher-win32-ia32" "2.5.6" + "@parcel/watcher-win32-x64" "2.5.6" + +"@primer/behaviors@^1.3.4": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@primer/behaviors/-/behaviors-1.10.0.tgz#9b4f52315b7d00c8afd2c5db2c56fe02d92660e9" + integrity sha512-+GaAqCJuoYVf0Sy67mJfhw7k17nrCnfanI4H6NFEyToDC1ghrOC9Yl7627WTWpqGg+1lPhjF7OHF7VClLz52oA== + +"@primer/css@^22.1.0": + version "22.1.0" + resolved "https://registry.yarnpkg.com/@primer/css/-/css-22.1.0.tgz#b85af5cf9c18b3f9e4aa298699e9137964c87687" + integrity sha512-Nwg9QaRiBeu0BU6h+Su0X07daihX1obiuqGRG8y+SexOnvWhN2J5n4OFAvGfQsit07Y7Q6gGoK+yVU5tb8CtDA== + +"@primer/live-region-element@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@primer/live-region-element/-/live-region-element-0.8.0.tgz#79f8a5824a7762d26eb86789962de54e5c295dee" + integrity sha512-DIp04IeZvZ+gwCUcBchIvRwJA2PikP/6hnFhcLKgwttcg5OYjBH9t0cZjLnv10Aq1jN0rAFFG+WPMd3bw4hXcA== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.2.1" + +"@primer/primitives@^11.3.2": + version "11.3.2" + resolved "https://registry.yarnpkg.com/@primer/primitives/-/primitives-11.3.2.tgz#2ea09ef48b7db9e66f3406553f453d8bdd665b34" + integrity sha512-/8EDh3MmF9cbmrLETFmIuNFIdvpSCkvBlx6zzD8AZ4dZ5UYExQzFj8QAtIrRtCFJ2ZmW5QrtrPR3+JVb8KEDpg== + +"@primer/view-components@^0.49.0": + version "0.49.0" + resolved "https://registry.yarnpkg.com/@primer/view-components/-/view-components-0.49.0.tgz#8b913ba790f92f4a55f46f1391d2700e2842c187" + integrity sha512-6JTIxvdofczvXDG82OjXzsumTqzC5UIU7EXQ79G4O2nEAxDreZ45x39jj3BHj+4wiO7BCdYCe9YsuD3AyvDPzQ== + dependencies: + "@github/auto-check-element" "^6.0.0" + "@github/auto-complete-element" "^3.8.0" + "@github/catalyst" "^1.6.0" + "@github/clipboard-copy-element" "^1.3.0" + "@github/details-menu-element" "^1.0.12" + "@github/image-crop-element" "^5.0.0" + "@github/include-fragment-element" "^6.3.0" + "@github/relative-time-element" "^4.0.0" + "@github/remote-input-element" "^0.4.0" + "@github/tab-container-element" "^3.1.2" + "@oddbird/popover-polyfill" "^0.5.2" + "@primer/behaviors" "^1.3.4" + "@primer/live-region-element" "^0.8.0" + +"@rails/ujs@^7.1.3-4": + version "7.1.3-4" + resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-7.1.3-4.tgz#1dddea99d5c042e8513973ea709b2cb7e840dc2d" + integrity sha512-z0ckI5jrAJfImcObjMT1RBz2IxH6I5q6ZTMFex6AfxSQKZuuL8JxAXvg2CvBuodGCxKvybFVolDyMHXlBLeYAA== + +"@rollup/rollup-android-arm-eabi@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98" + integrity sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA== + +"@rollup/rollup-android-arm64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz#1061ce0bfa6a6da361bda52a2949612769cd22ef" + integrity sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg== + +"@rollup/rollup-darwin-arm64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz#20d65f967566000d22ef6c9defb0f96d2f95ed79" + integrity sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg== + +"@rollup/rollup-darwin-x64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz#2a805303beb4cd44bfef993c39582cb0f1794f90" + integrity sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA== + +"@rollup/rollup-freebsd-arm64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz#7cf26a60d7245e9207a253ac07f11ddfcc47d622" + integrity sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g== + +"@rollup/rollup-freebsd-x64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz#2b1acc1e624b47f676f526df30bb4357ea21f9b6" + integrity sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA== + +"@rollup/rollup-linux-arm-gnueabihf@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz#1ba1ef444365a51687c7af2824b370791a1e3aaf" + integrity sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q== + +"@rollup/rollup-linux-arm-musleabihf@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz#e49863b683644bbbb9abc5b051c9b9d59774c3a0" + integrity sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA== + +"@rollup/rollup-linux-arm64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz#fda3bfd43d2390d2d99bc7d9617c2db2941da52b" + integrity sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw== + +"@rollup/rollup-linux-arm64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz#aea6199031404f80a0ccf33d5d3a63de53819da0" + integrity sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw== + +"@rollup/rollup-linux-loong64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz#f467333a5691f69a18295a7051e1cebb6815fdfe" + integrity sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q== + +"@rollup/rollup-linux-loong64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz#e46dffc29692caa743140636eb0d1d9a24ed0fc3" + integrity sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ== + +"@rollup/rollup-linux-ppc64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz#be5b4494047ccbaadf1542fe9ac45b7788e73968" + integrity sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ== + +"@rollup/rollup-linux-ppc64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz#b14ce2b0fe9c37fd0646ec3095087c1d64c791f4" + integrity sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA== + +"@rollup/rollup-linux-riscv64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz#b78357f88ee7a34f677b118714594e37a2362a8c" + integrity sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ== + +"@rollup/rollup-linux-riscv64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz#f44107ec0c30d691552c89eb3e4f287c33c56c3c" + integrity sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ== + +"@rollup/rollup-linux-s390x-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz#ddb1cf80fb21b376a45a4e93ffdbeb15205d38f3" + integrity sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg== + +"@rollup/rollup-linux-x64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz#0da46a644c87e1d8b13da5e2901037193caea8d3" + integrity sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A== + +"@rollup/rollup-linux-x64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz#e561c93b6a23114a308396806551c25e28d3e303" + integrity sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw== + +"@rollup/rollup-openbsd-x64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz#52490600775364a0476f26be7ddc416dfa11439b" + integrity sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw== + +"@rollup/rollup-openharmony-arm64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz#c25988aae57bd21fa7d0fcb014ef85ec8987ad2c" + integrity sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA== + +"@rollup/rollup-win32-arm64-msvc@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz#572a8cd78442441121f1a6b5ad686ab723c31ae4" + integrity sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ== + +"@rollup/rollup-win32-ia32-msvc@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz#431fa95c0be8377907fe4e7070aaa4016c7b7e3b" + integrity sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA== + +"@rollup/rollup-win32-x64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz#19db67feb9c5fe09b1358efd1d97c5f6b299d347" + integrity sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA== + +"@rollup/rollup-win32-x64-msvc@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz#6f38851da1123ac0380121108abd31ff21205c3d" + integrity sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ== + +"@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +chokidar@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +colorjs.io@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.5.2.tgz#63b20139b007591ebc3359932bef84628eb3fcef" + integrity sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw== + +debug@^4.3.4: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +esbuild@^0.25.0: + version "0.25.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5" + integrity sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.25.12" + "@esbuild/android-arm" "0.25.12" + "@esbuild/android-arm64" "0.25.12" + "@esbuild/android-x64" "0.25.12" + "@esbuild/darwin-arm64" "0.25.12" + "@esbuild/darwin-x64" "0.25.12" + "@esbuild/freebsd-arm64" "0.25.12" + "@esbuild/freebsd-x64" "0.25.12" + "@esbuild/linux-arm" "0.25.12" + "@esbuild/linux-arm64" "0.25.12" + "@esbuild/linux-ia32" "0.25.12" + "@esbuild/linux-loong64" "0.25.12" + "@esbuild/linux-mips64el" "0.25.12" + "@esbuild/linux-ppc64" "0.25.12" + "@esbuild/linux-riscv64" "0.25.12" + "@esbuild/linux-s390x" "0.25.12" + "@esbuild/linux-x64" "0.25.12" + "@esbuild/netbsd-arm64" "0.25.12" + "@esbuild/netbsd-x64" "0.25.12" + "@esbuild/openbsd-arm64" "0.25.12" + "@esbuild/openbsd-x64" "0.25.12" + "@esbuild/openharmony-arm64" "0.25.12" + "@esbuild/sunos-x64" "0.25.12" + "@esbuild/win32-arm64" "0.25.12" + "@esbuild/win32-ia32" "0.25.12" + "@esbuild/win32-x64" "0.25.12" + +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.8" + +fastq@^1.6.0: + version "1.20.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675" + integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== + dependencies: + reusify "^1.0.4" + +fdir@^6.4.4, fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +glob-parent@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +immutable@^5.0.2: + version "5.1.4" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.4.tgz#e3f8c1fe7b567d56cf26698f31918c241dae8c1f" + integrity sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.1, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +merge2@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" + integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== + +micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +postcss@^8.5.3: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +reusify@^1.0.4: + version "1.1.0" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== + +rollup@^4.34.9: + version "4.57.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853" + integrity sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.57.0" + "@rollup/rollup-android-arm64" "4.57.0" + "@rollup/rollup-darwin-arm64" "4.57.0" + "@rollup/rollup-darwin-x64" "4.57.0" + "@rollup/rollup-freebsd-arm64" "4.57.0" + "@rollup/rollup-freebsd-x64" "4.57.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.57.0" + "@rollup/rollup-linux-arm-musleabihf" "4.57.0" + "@rollup/rollup-linux-arm64-gnu" "4.57.0" + "@rollup/rollup-linux-arm64-musl" "4.57.0" + "@rollup/rollup-linux-loong64-gnu" "4.57.0" + "@rollup/rollup-linux-loong64-musl" "4.57.0" + "@rollup/rollup-linux-ppc64-gnu" "4.57.0" + "@rollup/rollup-linux-ppc64-musl" "4.57.0" + "@rollup/rollup-linux-riscv64-gnu" "4.57.0" + "@rollup/rollup-linux-riscv64-musl" "4.57.0" + "@rollup/rollup-linux-s390x-gnu" "4.57.0" + "@rollup/rollup-linux-x64-gnu" "4.57.0" + "@rollup/rollup-linux-x64-musl" "4.57.0" + "@rollup/rollup-openbsd-x64" "4.57.0" + "@rollup/rollup-openharmony-arm64" "4.57.0" + "@rollup/rollup-win32-arm64-msvc" "4.57.0" + "@rollup/rollup-win32-ia32-msvc" "4.57.0" + "@rollup/rollup-win32-x64-gnu" "4.57.0" + "@rollup/rollup-win32-x64-msvc" "4.57.0" + fsevents "~2.3.2" + +run-parallel@^1.1.9: + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" + +rxjs@^7.4.0: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== + dependencies: + tslib "^2.1.0" + +sass-embedded-all-unknown@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.3.tgz#a0cf18681cc0ec5f51101b8b89640df158fb8dc3" + integrity sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg== + dependencies: + sass "1.97.3" + +sass-embedded-android-arm64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.3.tgz#8093d124f0b671fd691a925805d8d6b0c9d08a44" + integrity sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA== + +sass-embedded-android-arm@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.3.tgz#256236b9c857f83ece13229d8704a44587a0a660" + integrity sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg== + +sass-embedded-android-riscv64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.3.tgz#c71cfef7a6a94cc043f74936ec62be89115c7a32" + integrity sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA== + +sass-embedded-android-x64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.3.tgz#07065245d9154d3353952bce5c30f87fbd7db59c" + integrity sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw== + +sass-embedded-darwin-arm64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.97.3.tgz#49887421ada0d00ba9e93012690e34d8aa132bc7" + integrity sha512-fuqMTqO4gbOmA/kC5b9y9xxNYw6zDEyfOtMgabS7Mz93wimSk2M1quQaTJnL98Mkcsl2j+7shNHxIS/qpcIDDA== + +sass-embedded-darwin-x64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.3.tgz#5f9aef81f5337f5e8e67cf0bd2d8514a7529326e" + integrity sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA== + +sass-embedded-linux-arm64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.3.tgz#fd08cb53040c25c78a5e418914a47bc74a72d169" + integrity sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg== + +sass-embedded-linux-arm@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.3.tgz#1fdd2e4e8d8f4b6144fb8a24e8e8bbb704696184" + integrity sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA== + +sass-embedded-linux-musl-arm64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.3.tgz#21cbc27b312ff2460d6c7c8ea093bce2a549fbc2" + integrity sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw== + +sass-embedded-linux-musl-arm@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.3.tgz#f6d3905031e44313d3fb742c683ddbf45bf12c64" + integrity sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg== + +sass-embedded-linux-musl-riscv64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.3.tgz#a42f9f4c51b42431e93287a6dc1df83e920bf658" + integrity sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA== + +sass-embedded-linux-musl-x64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.3.tgz#55b5bd5e56b0ece474ed52a890131fae4e020675" + integrity sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw== + +sass-embedded-linux-riscv64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.3.tgz#d6367150d1c389a1310cbda539d4b503da7ff5a3" + integrity sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA== + +sass-embedded-linux-x64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.3.tgz#cebfe65b052cbaae76c8e02feac8f85e90942224" + integrity sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg== + +sass-embedded-unknown-all@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.3.tgz#a4f82e5133e28de65034e67faf1e137790ac5ac6" + integrity sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q== + dependencies: + sass "1.97.3" + +sass-embedded-win32-arm64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.3.tgz#41c539cc8732a11b9b4c0da2309d1fc5564655c3" + integrity sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw== + +sass-embedded-win32-x64@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.3.tgz#e4e200e1e5a62ef075f962eb2d11d85dc58b762e" + integrity sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw== + +sass-embedded@^1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass-embedded/-/sass-embedded-1.97.3.tgz#1cab95995787d7e310f6272d58f80c67b5ead4ba" + integrity sha512-eKzFy13Nk+IRHhlAwP3sfuv+PzOrvzUkwJK2hdoCKYcWGSdmwFpeGpWmyewdw8EgBnsKaSBtgf/0b2K635ecSA== + dependencies: + "@bufbuild/protobuf" "^2.5.0" + colorjs.io "^0.5.0" + immutable "^5.0.2" + rxjs "^7.4.0" + supports-color "^8.1.1" + sync-child-process "^1.0.2" + varint "^6.0.0" + optionalDependencies: + sass-embedded-all-unknown "1.97.3" + sass-embedded-android-arm "1.97.3" + sass-embedded-android-arm64 "1.97.3" + sass-embedded-android-riscv64 "1.97.3" + sass-embedded-android-x64 "1.97.3" + sass-embedded-darwin-arm64 "1.97.3" + sass-embedded-darwin-x64 "1.97.3" + sass-embedded-linux-arm "1.97.3" + sass-embedded-linux-arm64 "1.97.3" + sass-embedded-linux-musl-arm "1.97.3" + sass-embedded-linux-musl-arm64 "1.97.3" + sass-embedded-linux-musl-riscv64 "1.97.3" + sass-embedded-linux-musl-x64 "1.97.3" + sass-embedded-linux-riscv64 "1.97.3" + sass-embedded-linux-x64 "1.97.3" + sass-embedded-unknown-all "1.97.3" + sass-embedded-win32-arm64 "1.97.3" + sass-embedded-win32-x64 "1.97.3" + +sass@1.97.3: + version "1.97.3" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2" + integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg== + dependencies: + chokidar "^4.0.0" + immutable "^5.0.2" + source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" + +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +sync-child-process@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/sync-child-process/-/sync-child-process-1.0.2.tgz#45e7c72e756d1243e80b547ea2e17957ab9e367f" + integrity sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA== + dependencies: + sync-message-port "^1.0.0" + +sync-message-port@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sync-message-port/-/sync-message-port-1.1.3.tgz#6055c565ee8c81d2f9ee5aae7db757e6d9088c0c" + integrity sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg== + +tinyglobby@^0.2.13: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tslib@^2.1.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +varint@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/varint/-/varint-6.0.0.tgz#9881eb0ce8feaea6512439d19ddf84bf551661d0" + integrity sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg== + +vite-plugin-ruby@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/vite-plugin-ruby/-/vite-plugin-ruby-5.1.1.tgz#ecd72591ddb90a23613051005bd70a6410945129" + integrity sha512-I1dXJq2ywdvTD2Cz5LYNcYLujqQ3eUxPoCjruRdfm2QBtHBY15NEeb6x5HuPM3T5S+y8S3p9fwRsieQQCjk0gg== + dependencies: + debug "^4.3.4" + fast-glob "^3.3.2" + +vite@^6.0.0: + version "6.4.1" + resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.1.tgz#afbe14518cdd6887e240a4b0221ab6d0ce733f96" + integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g== + dependencies: + esbuild "^0.25.0" + fdir "^6.4.4" + picomatch "^4.0.2" + postcss "^8.5.3" + rollup "^4.34.9" + tinyglobby "^0.2.13" + optionalDependencies: + fsevents "~2.3.3"