diff --git a/.ruby-version b/.ruby-version index f989260..fcdb2e1 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.4 +4.0.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d89413f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,242 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +WhyRuby.info is a Ruby advocacy community website built with Rails 8.1 (edge/main branch) and the Solid Stack (SQLite, SolidQueue, SolidCache, SolidCable). It features a universal content model supporting articles, links, and success stories with AI-generated summaries, GitHub OAuth authentication, and community-driven content moderation. + +## Development Commands + +### Server Management +```bash +# Start the development server (REQUIRED - do not use rails server directly) +bin/dev + +# Monitor logs in real-time +tail -f log/development.log +``` + +**IMPORTANT**: Always use `bin/dev` instead of `rails server`. This starts both the Rails server (port 3003) and Tailwind CSS watcher via Procfile.dev. + +### Database +```bash +# Create and setup database +rails db:create +rails db:migrate +rails db:seed + +# Database console +rails db + +# Promote user to admin +rails runner "User.find_by(username: 'github-username').update!(role: :admin)" +``` + +### Testing & Code Quality +```bash +# Run all tests +rails test + +# Run single test file +rails test test/models/post_test.rb + +# Run single test +rails test test/models/post_test.rb:line_number + +# Code style (auto-fixes and stages changes via lefthook) +bundle exec rubocop +bundle exec rubocop -A # Auto-correct issues +``` + +### Rails Console & Generators +```bash +# Console +rails console + +# Generate model with ULID primary key +rails generate model ModelName + +# Other generators +rails generate controller ControllerName +rails generate migration MigrationName +``` + +## Architecture & Key Patterns + +### Data Model Structure + +The application uses a **Universal Content Model** where the `Post` model handles three distinct content types via the `post_type` enum: + +- **Articles**: Original content with markdown (`content` field required, `url` must be blank) +- **Links**: External content (`url` required, `content` must be blank) +- **Success Stories**: Ruby success stories with SVG logo (`logo_svg` and `content` required, `url` must be blank) + +**Key Models:** +- `User`: GitHub OAuth authentication, role-based access (member/admin), trusted user system based on contribution counts (3+ published posts, 10+ published comments) +- `Post`: Universal content model with FriendlyId slugs, soft deletion via `archived` flag, counter caches for reports +- `Category`: Content organization with position ordering, special success story category flag +- `Tag`: HABTM relationship with posts +- `Comment`: User feedback on posts with published flag +- `Report`: Content moderation by trusted users, auto-hides after 3+ reports + +### Primary Keys & IDs + +**All tables use ULID instead of integer IDs:** +```ruby +create_table :table_name, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + # ... +end +``` + +This is configured via the `sqlite-ulid` gem and provides better distribution and sortability. + +### Authentication & Authorization + +- **Authentication**: GitHub OAuth only via Devise + OmniAuth +- **Roles**: `member` (default) and `admin` (enum in User model) +- **Trusted Users**: Automatically qualified when `published_posts_count >= 3` AND `published_comments_count >= 10` +- **Permissions**: Only trusted users can report content; only admins can access `/admin` (Avo panel) + +### Content Moderation Flow + +1. Trusted users can report inappropriate content +2. After 3+ reports, content is automatically: + - Set to `published: false` + - Flagged with `needs_admin_review: true` + - Admin notification sent via `NotifyAdminJob` +3. Admins review and restore or permanently archive via Avo panel + +### Background Jobs (SolidQueue) + +- `GenerateSummaryJob`: Creates AI summaries for new/updated posts using OpenAI or Anthropic APIs +- `GenerateSuccessStoryImageJob`: Generates OG images for success stories from SVG logos +- `NotifyAdminJob`: Sends notifications when content is auto-hidden +- `UpdateGithubDataJob`: Refreshes user GitHub data (repositories, stars, etc.) + +### Image Processing + +Posts support `featured_image` attachments via ActiveStorage. Images are processed into multiple variants (small, medium, large, og) and stored as separate blobs with metadata in `image_variants` JSON column. Processing uses the `ImageProcessor` service. + +### URL Routing Pattern + +The application uses a **catch-all routing strategy** for clean URLs: + +``` +/:category_slug # Category pages +/:category_slug/:post_slug # Post pages +``` + +**Important**: Category and post routes use constraints and are defined at the END of routes.rb to avoid conflicts with explicit routes like `/admin`, `/community`, `/legal/*`, etc. + +### Services Layer + +Service objects in `app/services/` handle complex operations: +- `GithubDataFetcher`: Fetches and updates user GitHub profile data and repositories on sign-in +- `ImageProcessor`: Processes featured images into multiple variants (small, medium, large, og) +- `SuccessStoryImageGenerator`: Generates OG images for success stories from SVG logos +- `SvgSanitizer`: Sanitizes SVG content to prevent XSS attacks + +### FriendlyId Implementation + +Both `User` and `Post` models use FriendlyId with history: +- `User`: Slugs from `username` +- `Post`: Slugs from `title` + +Both models implement `create_slug_history` to manually save old slugs when changed, ensuring historical URLs redirect properly. + +## Rails 8 Specific Patterns + +### Solid Stack Usage + +- **SolidQueue**: Default background job processor (no Redis needed) +- **SolidCache**: Database-backed caching +- **SolidCable**: WebSocket functionality via SQLite +- Configuration files: `config/queue.yml`, `config/cache.yml`, `config/cable.yml` + +### Modern Rails 8 Conventions + +- Use `params.expect()` for parameter handling instead of strong parameters (Rails 8.1 feature) +- Propshaft for asset pipeline (not Sprockets) +- Tailwind CSS 4 via `tailwindcss-rails` gem +- Hotwire (Turbo + Stimulus) for frontend interactivity + +### Migrations + +Always use ULID primary keys. Never use auto-increment integers: + +```ruby +create_table :posts, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.string :title, null: false + # ... +end +``` + +## Important Development Practices + +### Server & Logging + +- **ALWAYS** use `bin/dev` to start the server +- Check logs after every significant change +- Monitor `log/development.log` for errors and performance issues +- Review logs before considering any change complete + +### Code Style + +The project uses lefthook to automatically run RuboCop before commits: +- RuboCop runs with auto-fix (`-A`) on all files +- Fixed files are automatically staged +- Configuration in `.lefthook.yml` and `.rubocop.yml` + +### Testing + +- Framework: Minitest (Rails default) +- Fixtures in `test/fixtures/` +- Test structure: `test/models/`, `test/controllers/`, `test/system/` +- Run tests before committing + +## Credentials & Configuration + +### Development Credentials + +```bash +rails credentials:edit --environment development +``` + +Required credentials: +```yaml +github: + client_id: your_github_oauth_app_client_id + client_secret: your_github_oauth_app_client_secret + +openai: + api_key: your_openai_api_key # Optional - for AI summaries +``` + +### GitHub OAuth Setup + +1. Create OAuth App at: https://github.com/settings/developers +2. Set callback URL: `http://localhost:3000/users/auth/github/callback` +3. Add credentials to development credentials file + +### Deployment + +- Configured for Kamal 2 deployment (see `config/deploy.yml`) +- Litestream for SQLite backups to S3 (see `config/litestream.yml`) +- RorVsWild for performance monitoring (see `config/rorvswild.yml`) + +## Admin Panel + +- Admin panel powered by Avo (v3.2+) +- Route: `/admin` (only accessible to admin users) +- Configuration: `app/avo/` directory +- Litestream backup UI: `/litestream` (admin only) + +## Markdown Rendering + +- Redcarpet for markdown parsing +- Rouge for syntax highlighting +- Markdown content stored in `Post#content` field for articles and success stories +- Custom helper: `ApplicationHelper#render_markdown_with_syntax_highlighting` diff --git a/Dockerfile b/Dockerfile index c427f7d..97a7457 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=3.4.4 +ARG RUBY_VERSION=4.0.0 FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here diff --git a/Gemfile b/Gemfile index d146ebd..2e8ebd5 100644 --- a/Gemfile +++ b/Gemfile @@ -37,9 +37,6 @@ gem "kamal", require: false # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] gem "thruster", require: false -# Fetch metadata from URLs -gem "metainspector" - # Authentication gem "devise", "~> 4.9" gem "omniauth-github", "~> 2.0" diff --git a/Gemfile.lock b/Gemfile.lock index 6d1a828..087eec0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,31 +1,31 @@ GIT remote: https://github.com/rails/rails.git - revision: 2c3ea36fe98e36039636655d23ae210ee2670dff + revision: aacf8a305f304dd88535240d5a97318858c0afb6 branch: main specs: - actioncable (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actioncable (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionmailbox (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) mail (>= 2.8.0) - actionmailer (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - actionview (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionmailer (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.0.alpha) - actionview (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionpack (8.2.0.alpha) + actionview (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,65 +33,66 @@ GIT rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.0.alpha) + actiontext (8.2.0.alpha) action_text-trix (~> 2.1.15) - actionpack (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionpack (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionview (8.2.0.alpha) + activesupport (= 8.2.0.alpha) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activejob (8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.3.6) - activemodel (8.1.0.alpha) - activesupport (= 8.1.0.alpha) - activerecord (8.1.0.alpha) - activemodel (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activemodel (8.2.0.alpha) + activesupport (= 8.2.0.alpha) + activerecord (8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) timeout (>= 0.4.0) - activestorage (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activestorage (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) marcel (~> 1.0) - activesupport (8.1.0.alpha) + activesupport (8.2.0.alpha) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) + psych (>= 4) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - rails (8.1.0.alpha) - actioncable (= 8.1.0.alpha) - actionmailbox (= 8.1.0.alpha) - actionmailer (= 8.1.0.alpha) - actionpack (= 8.1.0.alpha) - actiontext (= 8.1.0.alpha) - actionview (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activemodel (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + rails (8.2.0.alpha) + actioncable (= 8.2.0.alpha) + actionmailbox (= 8.2.0.alpha) + actionmailer (= 8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actiontext (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) bundler (>= 1.15.0) - railties (= 8.1.0.alpha) - railties (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + railties (= 8.2.0.alpha) + railties (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -102,43 +103,41 @@ GIT GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.15) + action_text-trix (2.1.16) railties active_link_to (1.0.5) actionpack addressable - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) anthropic (1.6.0) connection_pool ast (2.4.3) - avo (3.23.0) + avo (3.27.0) actionview (>= 6.1) active_link_to activerecord (>= 6.1) activesupport (>= 6.1) addressable - avo-heroicons (>= 0.1.1) + avo-icons (>= 0.1.1) docile - inline_svg meta-tags - pagy (>= 7.0.0) + pagy (>= 7.0.0, < 43) prop_initializer (>= 0.2.0) turbo-rails (>= 2.0.0) turbo_power (>= 0.6.0) view_component (>= 3.7.0) zeitwerk (>= 2.6.12) - avo-heroicons (0.1.1) + avo-icons (0.1.1) + inline_svg base64 (0.3.0) - bcrypt (3.1.20) - bcrypt_pbkdf (1.1.1) - bcrypt_pbkdf (1.1.1-arm64-darwin) - benchmark (0.4.1) - bigdecimal (3.2.2) + bcrypt (3.1.21) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.18.6) + bootsnap (1.20.1) msgpack (~> 1.2) - brakeman (7.1.0) + brakeman (7.1.2) racc builder (3.3.0) capybara (3.40.0) @@ -150,11 +149,11 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - concurrent-ruby (1.3.5) - connection_pool (2.5.3) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crass (1.0.6) - date (3.4.1) - debug (1.11.0) + date (3.5.1) + debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) devise (4.9.4) @@ -164,49 +163,32 @@ GEM responders warden (~> 1.2.3) docile (1.4.1) - domain_name (0.6.20240107) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) - erb (5.0.2) + erb (6.0.1) erubi (1.13.1) - et-orbi (1.3.0) + et-orbi (1.4.0) tzinfo event_stream_parser (1.0.0) - faraday (2.13.4) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-cookie_jar (0.0.7) - faraday (>= 0.8.0) - http-cookie (~> 1.0.0) - faraday-encoding (0.0.6) - faraday - faraday-follow_redirects (0.3.0) - faraday (>= 1, < 3) - faraday-gzip (2.0.1) - faraday (>= 1.0) - zlib (~> 3.0) - faraday-http-cache (2.5.1) - faraday (>= 0.8) - faraday-multipart (1.1.1) + faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) - faraday-retry (2.3.2) - faraday (~> 2.0) - fastimage (2.4.0) - friendly_id (5.5.1) + faraday-net_http (3.4.2) + net-http (~> 0.5) + friendly_id (5.6.0) activerecord (>= 4.0.0) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + fugit (1.12.1) + et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.2.1) + globalid (1.3.0) activesupport (>= 6.1) - hashie (5.0.0) - http-cookie (1.0.8) - domain_name (~> 0.5) - i18n (1.14.7) + hashie (5.1.0) + logger + i18n (1.14.8) concurrent-ruby (~> 1.0) importmap-rails (2.2.2) actionpack (>= 6.0.0) @@ -215,18 +197,18 @@ GEM inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.1) - irb (1.15.2) + io-console (0.8.2) + irb (1.16.0) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.14.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) - json (2.13.2) + json (2.18.0) jwt (3.1.2) base64 - kamal (2.7.0) + kamal (2.10.1) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -250,7 +232,7 @@ GEM kaminari-core (= 1.2.2) kaminari-core (1.2.2) language_server-protocol (3.17.0.5) - lefthook (1.12.3) + lefthook (2.0.13) lint_roller (1.1.0) litestream (0.14.0-arm64-darwin) actionpack (>= 7.0) @@ -267,40 +249,29 @@ GEM railties (>= 7.0) sqlite3 logger (1.7.0) - loofah (2.24.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) matrix (0.4.3) - meta-tags (2.22.1) - actionpack (>= 6.0.0, < 8.1) - metainspector (5.16.0) - addressable (~> 2.8.4) - faraday (~> 2.5) - faraday-cookie_jar (~> 0.0) - faraday-encoding (~> 0.0) - faraday-follow_redirects (~> 0.3) - faraday-gzip (>= 0.1, < 3.0) - faraday-http-cache (~> 2.5) - faraday-retry (~> 2.0) - fastimage (~> 2.2) - nesty (~> 1.0) - nokogiri (~> 1.18.8) + meta-tags (2.22.2) + actionpack (>= 6.0.0, < 8.2) mini_mime (1.1.5) - minitest (5.25.5) + minitest (6.0.1) + prism (~> 1.5) msgpack (1.8.0) - multi_xml (0.7.2) - bigdecimal (~> 3.1) + multi_xml (0.8.0) + bigdecimal (>= 3.1, < 5) multipart-post (2.4.1) - nesty (1.0.2) - net-http (0.6.0) - uri - net-imap (0.5.9) + net-http (0.9.1) + uri (>= 0.11.1) + net-imap (0.6.2) date net-protocol net-pop (0.1.2) @@ -314,59 +285,62 @@ GEM net-smtp (0.5.1) net-protocol net-ssh (7.3.0) - nio4r (2.7.4) - nokogiri (1.18.9-arm64-darwin) + nio4r (2.7.5) + nokogiri (1.19.0-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-gnu) + nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) - oauth2 (2.0.12) + 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.8, < 3) - omniauth (2.1.3) + version_gem (~> 1.1, >= 1.1.9) + omniauth (2.1.4) hashie (>= 3.4.6) + logger rack (>= 2.2.3) rack-protection omniauth-github (2.0.1) omniauth (~> 2.0) omniauth-oauth2 (~> 1.8) - omniauth-oauth2 (1.8.0) - oauth2 (>= 1.4, < 3) + omniauth-oauth2 (1.9.0) + oauth2 (>= 2.0.2, < 3) omniauth (~> 2.0) omniauth-rails_csrf_protection (1.0.2) actionpack (>= 4.2) omniauth (~> 2.0) orm_adapter (0.5.0) ostruct (0.6.3) - pagy (9.3.5) + pagy (9.4.0) parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.0) ast (~> 2.4.1) racc - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.4.0) + prism (1.7.0) prop_initializer (0.2.0) zeitwerk (>= 2.6.18) - propshaft (1.2.1) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.2.6) + psych (5.3.1) date stringio - public_suffix (6.0.2) - puma (6.6.1) + public_suffix (7.0.0) + puma (7.1.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.0) - rack-protection (4.1.1) + rack (3.2.4) + rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) @@ -375,7 +349,7 @@ GEM rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) @@ -385,21 +359,22 @@ GEM 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) rainbow (3.1.1) - rake (13.3.0) - rdoc (6.14.2) + rake (13.3.1) + rdoc (7.0.3) erb psych (>= 4.0.0) + tsort redcarpet (3.6.1) - regexp_parser (2.11.1) - reline (0.6.2) + regexp_parser (2.11.3) + reline (0.6.3) io-console (~> 0.5) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) - rexml (3.4.1) - rorvswild (1.9.2) - rouge (4.6.0) - rubocop (1.79.2) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) + rexml (3.4.4) + rorvswild (1.10.1) + rouge (4.7.0) + rubocop (1.82.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -407,17 +382,17 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.46.0, < 2.0) + rubocop-ast (>= 1.48.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.46.0) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-performance (1.25.0) + prism (~> 1.7) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.33.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.2) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -427,14 +402,14 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-openai (8.2.0) + ruby-openai (8.3.0) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) faraday-multipart (>= 1) ruby-progressbar (1.13.0) - rubyzip (3.0.1) + rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.35.0) + selenium-webdriver (4.39.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -443,27 +418,28 @@ GEM snaky_hash (2.0.3) hashie (>= 0.1.0, < 6) version_gem (>= 1.1.8, < 3) - solid_cable (3.0.11) + solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_cache (1.0.7) + solid_cache (1.0.10) activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.2.1) + solid_queue (1.2.4) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) - fugit (~> 1.11.0) + fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) sqlite-ulid (0.2.1-arm64-darwin) sqlite-ulid (0.2.1-x86_64-linux) - sqlite3 (2.7.3-arm64-darwin) - sqlite3 (2.7.3-x86_64-linux-gnu) - sshkit (1.24.0) + sqlite3 (2.9.0-arm64-darwin) + sqlite3 (2.9.0-x86_64-linux-gnu) + sqlite3 (2.9.0-x86_64-linux-musl) + sshkit (1.25.0) base64 logger net-scp (>= 1.1.2) @@ -472,32 +448,34 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.7) - tailwindcss-rails (4.3.0) + stringio (3.2.0) + tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.1.11-arm64-darwin) - tailwindcss-ruby (4.1.11-x86_64-linux-gnu) + tailwindcss-ruby (4.1.18-arm64-darwin) + tailwindcss-ruby (4.1.18-x86_64-linux-gnu) + tailwindcss-ruby (4.1.18-x86_64-linux-musl) thor (1.4.0) - thruster (0.1.15-arm64-darwin) - thruster (0.1.15-x86_64-linux) - timeout (0.4.3) + thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-linux) + timeout (0.6.0) tsort (0.2.0) - turbo-rails (2.0.16) + turbo-rails (2.0.20) actionpack (>= 7.1.0) railties (>= 7.1.0) turbo_power (0.7.0) turbo-rails (>= 1.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) + 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.8) - view_component (4.0.1) - activesupport (>= 7.1.0, < 8.1) + version_gem (1.1.9) + view_component (4.1.1) + actionview (>= 7.1.0, < 8.2) + activesupport (>= 7.1.0, < 8.2) concurrent-ruby (~> 1) warden (1.2.9) rack (>= 2.0.9) @@ -513,12 +491,13 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.3) - zlib (3.2.1) + zeitwerk (2.7.4) PLATFORMS - arm64-darwin-24 + arm64-darwin-25 x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES anthropic (~> 1.6.0) @@ -536,7 +515,6 @@ DEPENDENCIES kaminari (~> 1.2) lefthook litestream (~> 0.14.0) - metainspector nokogiri (~> 1.16) omniauth-github (~> 2.0) omniauth-rails_csrf_protection (~> 1.0) @@ -560,5 +538,182 @@ DEPENDENCIES turbo-rails web-console +CHECKSUMS + action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a + actioncable (8.2.0.alpha) + actionmailbox (8.2.0.alpha) + actionmailer (8.2.0.alpha) + actionpack (8.2.0.alpha) + actiontext (8.2.0.alpha) + actionview (8.2.0.alpha) + active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba + activejob (8.2.0.alpha) + activemodel (8.2.0.alpha) + activerecord (8.2.0.alpha) + activestorage (8.2.0.alpha) + activesupport (8.2.0.alpha) + addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 + anthropic (1.6.0) sha256=61fa13d73f54d8174bf8b45cc058768e79e824ebc2aefc9417a2b94d9127ab75 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + avo (3.27.0) sha256=586f9be3848d7d190e241091332448b33bc41d1fe8fdc06a725baa2d544f6f15 + avo-icons (0.1.1) sha256=d9a23d6d47bb7f8f04163119352a66a436dc8accf53f15cd0c3b5fcaffed082c + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.20.1) sha256=7ad62cda65c5157bcca0acfcc0ee11fcbb83d7d7a8a72d52ccd85e6ffc130b93 + brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 + erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8 + faraday (2.14.0) sha256=8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + friendly_id (5.6.0) sha256=28e221cd53fbd21586321164c1c6fd0c9ba8dde13969cb2363679f44726bb0c3 + fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 + globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + hashie (5.1.0) sha256=c266471896f323c446ea8207f8ffac985d2718df0a0ba98651a3057096ca3870 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + importmap-rails (2.2.2) sha256=729f5b1092f832780829ade1d0b46c7e53d91c556f06da7254da2977e93fe614 + inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 + jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 + json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 + jwt (3.1.2) sha256=af6991f19a6bb4060d618d9add7a66f0eeb005ac0bc017cd01f63b42e122d535 + kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340 + kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e + kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909 + kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430 + kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lefthook (2.0.13) sha256=e6fc347a656b412f72bfc82b4491b9bf5f69937498c4ab1d29cd4bc8c0152912 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + litestream (0.14.0-arm64-darwin) sha256=507bbb7ee99b3398304c5ef4a9bae835761359ffc72850f25708477805313d07 + litestream (0.14.0-x86_64-linux) sha256=2844734b6d8e5c6009baf8d138d6f18367f770e9e4390fb70763433db587bed6 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + meta-tags (2.22.2) sha256=7fe78af4a92be12091f473cb84a21f6bddbd37f24c4413172df76cd14fff9e83 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb + msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + multi_xml (0.8.0) sha256=8d4adcd092f8e354db496109829ffd36969fdc8392cb5fde398ca800d9e6df73 + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 + nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c + nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 + oauth2 (2.0.18) sha256=bacf11e470dfb963f17348666d0a75c7b29ca65bc48fd47be9057cf91a403287 + omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c + omniauth-github (2.0.1) sha256=8ff8e70ac6d6db9d52485eef52cfa894938c941496e66b52b5e2773ade3ccad4 + omniauth-oauth2 (1.9.0) sha256=ed15f6d9d20991807ce114cc5b9c1453bce3645b64e51c68c90cff5ff153fee8 + omniauth-rails_csrf_protection (1.0.2) sha256=1170fd672aff092b9b7ebebc1453559f073ed001e3ce62a1df616e32f8dc5fe0 + orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103 + prop_initializer (0.2.0) sha256=bd27704d0df8c59c3baf0df5cf448eba2b140fb9934fb31b2e379b5c842d8820 + propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 + psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 + public_suffix (7.0.0) sha256=f7090b5beb0e56f9f10d79eed4d5fbe551b3b425da65877e075dad47a6a1b095 + puma (7.1.0) sha256=e45c10cb124f224d448c98db653a75499794edbecadc440ad616cf50f2fd49dd + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 + rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac + rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.2.0.alpha) + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 + railties (8.2.0.alpha) + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rdoc (7.0.3) sha256=dfe3d0981d19b7bba71d9dbaeb57c9f4e3a7a4103162148a559c4fc687ea81f9 + redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445 + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rorvswild (1.10.1) sha256=eb3f64611e48661f275747934961bf8585315702ee8913f220ccae3e65ac3f56 + rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739 + rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 + rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.34.2) sha256=10ff246ee48b25ffeabddc5fee86d159d690bb3c7b9105755a9c7508a11d6e22 + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-openai (8.3.0) sha256=566dc279c42f4afed68a7a363dce2e594078abfc36b4e043102020b9a387ca69 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.39.0) sha256=984a1e63d39472eaf286bac3c6f1822fa7eea6eed9c07a66ce7b3bc5417ba826 + snaky_hash (2.0.3) sha256=25a3d299566e8153fb02fa23fd9a9358845950f7a523ddbbe1fa1e0d79a6d456 + solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.2.4) sha256=bb60f9552a969ac377d87601b0ff6a088f5e6f20b0cbbe3844a59d022cac0e4b + sqlite-ulid (0.2.1-arm64-darwin) sha256=683aa064c0bb8a6e156e8ac875c25b7eb22cfeb7a9cebe4f1ed2e7f9841a4bd7 + sqlite-ulid (0.2.1-x86_64-linux) sha256=1d3df3a6db65927062192c8c21b7821fd1a1041570794bae519b2d82add8637d + sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3 + sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5 + sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90 + sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916 + tailwindcss-ruby (4.1.18-arm64-darwin) sha256=f940531d5a030c566d3d616004235bcd4c361abdd328f7d6c7e3a953a32e0155 + tailwindcss-ruby (4.1.18-x86_64-linux-gnu) sha256=e0a2220163246fe0126c5c5bafb95bc6206e7d21fce2a2878fd9c9a359137534 + tailwindcss-ruby (4.1.18-x86_64-linux-musl) sha256=d957cf545b09d2db7eb6267450cc1fc589e126524066537a0c4d5b99d701f4b2 + thor (1.4.0) sha256=8763e822ccb0f1d7bee88cde131b19a65606657b847cc7b7b4b82e772bcd8a3d + thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec + thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3 + timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.20) sha256=cbcbb4dd3ce59f6471c9f911b1655b2c721998cc8303959d982da347f374ea95 + turbo_power (0.7.0) sha256=ad95d147e0fa761d0023ad9ca00528c7b7ddf6bba8ca2e23755d5b21b290d967 + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + version_gem (1.1.9) sha256=0c1a0962ae543c84a00889bb018d9f14d8f8af6029d26b295d98774e3d2eb9a4 + view_component (4.1.1) sha256=179f63b0db1d1a8f6af635dd684456b2bcdf6b6f4da2ef276bbe0579c17b377e + warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 + web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b + BUNDLED WITH - 2.6.9 + 4.0.3 diff --git a/README.md b/README.md index 78f25fd..de92e53 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # WhyRuby.info - Ruby Advocacy Community Website -A community-driven Ruby advocacy website built with Ruby 3.4.4 and Rails 8.1 using the Solid Stack (SQLite, SolidQueue, SolidCache, SolidCable). +A community-driven Ruby advocacy website built with Ruby 4.0.0 and Rails 8.2 using the Solid Stack (SQLite, SolidQueue, SolidCache, SolidCable). ## Features @@ -23,7 +23,7 @@ A community-driven Ruby advocacy website built with Ruby 3.4.4 and Rails 8.1 usi ## Setup ### Prerequisites -- Ruby 3.4.4 +- Ruby 4.0.0 - SQLite 3 - Node.js (for JavaScript runtime) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index a9829f2..c37e7ba 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -154,12 +154,13 @@ def fetch_metadata end begin - page = MetaInspector.new(url) + fetcher = MetadataFetcher.new(url) + result = fetcher.fetch! metadata = { - title: page.best_title || page.title, - summary: page.best_description || page.description, - image_url: page.images.best || page.images.first + title: result[:title], + summary: result[:description], + image_url: result[:image_url] } render json: { success: true, metadata: metadata } diff --git a/app/jobs/generate_summary_job.rb b/app/jobs/generate_summary_job.rb index a1147e3..19c855f 100644 --- a/app/jobs/generate_summary_job.rb +++ b/app/jobs/generate_summary_job.rb @@ -78,32 +78,35 @@ def fetch_external_content(url) begin Rails.logger.info "Fetching content from: #{url}" - page = MetaInspector.new(url, + fetcher = MetadataFetcher.new(url, connection_timeout: 5, read_timeout: 5, retries: 1, allow_redirections: :safe ) + result = fetcher.fetch! + return nil if result.blank? || result[:parsed].blank? + # Try to get the main content content_parts = [] # Add title - content_parts << "Title: #{page.best_title}" if page.best_title.present? + content_parts << "Title: #{result[:title]}" if result[:title].present? # Add description - content_parts << "Description: #{page.best_description}" if page.best_description.present? + content_parts << "Description: #{result[:description]}" if result[:description].present? # Get the main text content - if page.parsed.present? + if result[:parsed].present? # Try to extract main content, removing navigation, ads, etc. - main_content = extract_main_content(page.parsed) + main_content = extract_main_content(result[:parsed]) content_parts << main_content if main_content.present? end # Fallback to meta description and raw text if needed if content_parts.length <= 2 - raw_text = page.parsed.css("body").text.squish rescue nil + raw_text = result[:parsed].css("body").text.squish rescue nil content_parts << raw_text if raw_text.present? end diff --git a/app/services/metadata_fetcher.rb b/app/services/metadata_fetcher.rb new file mode 100644 index 0000000..c227673 --- /dev/null +++ b/app/services/metadata_fetcher.rb @@ -0,0 +1,213 @@ +class MetadataFetcher + require "net/http" + require "nokogiri" + + MAX_REDIRECTS = 3 + DEFAULT_TIMEOUT = 10 + DEFAULT_RETRIES = 1 + + attr_reader :url, :options, :parsed + + def initialize(url, options = {}) + @url = url + @options = options + @connection_timeout = options[:connection_timeout] || DEFAULT_TIMEOUT + @read_timeout = options[:read_timeout] || DEFAULT_TIMEOUT + @retries = options[:retries] || DEFAULT_RETRIES + @allow_redirections = options[:allow_redirections] + @parsed = nil + end + + def fetch! + html = fetch_html_with_retries + return {} unless html + + @parsed = Nokogiri::HTML(html) + + { + title: best_title, + description: best_description, + image_url: best_image, + parsed: @parsed + } + rescue => e + Rails.logger.error "Failed to fetch metadata from #{@url}: #{e.message}" + {} + end + + def best_title + extract_meta(property: "og:title") || + extract_meta(name: "twitter:title") || + @parsed&.at_css("title")&.text&.strip || + @parsed&.at_css("h1")&.text&.strip + end + + def best_description + extract_meta(property: "og:description") || + extract_meta(name: "twitter:description") || + extract_meta(name: "description") || + extract_first_paragraph + end + + def best_image + og_image = extract_meta(property: "og:image") + return resolve_url(og_image) if og_image + + twitter_image = extract_meta(name: "twitter:image") + return resolve_url(twitter_image) if twitter_image + + largest_image = find_largest_image + return resolve_url(largest_image) if largest_image + + first_image = @parsed&.at_css("img")&.[]("src") + resolve_url(first_image) if first_image + end + + private + + def fetch_html_with_retries + attempts = 0 + begin + attempts += 1 + fetch_html(@url) + rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT => e + if attempts <= @retries + Rails.logger.warn "Timeout fetching #{@url}, retrying (#{attempts}/#{@retries}): #{e.message}" + retry + else + Rails.logger.error "Failed to fetch #{@url} after #{@retries} retries: #{e.message}" + nil + end + rescue => e + Rails.logger.error "Error fetching #{@url}: #{e.message}" + nil + end + end + + def fetch_html(url, redirect_count = 0) + uri = URI(url) + + # Validate URL scheme + unless %w[http https].include?(uri.scheme&.downcase) + Rails.logger.error "Invalid URL scheme: #{uri.scheme}" + return nil + end + + request = Net::HTTP::Get.new(uri) + request["User-Agent"] = "Ruby/#{RUBY_VERSION} (WhyRuby.info)" + request["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + + response = Net::HTTP.start( + uri.hostname, + uri.port, + use_ssl: uri.scheme == "https", + open_timeout: @connection_timeout, + read_timeout: @read_timeout + ) do |http| + http.request(request) + end + + case response + when Net::HTTPSuccess + response.body + when Net::HTTPRedirection + handle_redirect(response, uri, redirect_count) + else + Rails.logger.error "HTTP error fetching #{url}: #{response.code} - #{response.message}" + nil + end + end + + def handle_redirect(response, current_uri, redirect_count) + if redirect_count >= MAX_REDIRECTS + Rails.logger.error "Too many redirects (#{redirect_count}) for #{@url}" + return nil + end + + location = response["location"] + return nil unless location + + # Resolve relative redirects + redirect_uri = URI.join(current_uri.to_s, location) + + # Check if redirect is safe + if @allow_redirections == :safe && !safe_redirect?(current_uri, redirect_uri) + Rails.logger.error "Unsafe redirect from #{current_uri} to #{redirect_uri}" + return nil + end + + fetch_html(redirect_uri.to_s, redirect_count + 1) + end + + def safe_redirect?(current_uri, redirect_uri) + # Only allow HTTPS redirects or same-origin HTTP + redirect_uri.scheme == "https" || + (redirect_uri.scheme == "http" && redirect_uri.host == current_uri.host) + end + + def extract_meta(property: nil, name: nil) + if property + @parsed&.at_css("meta[property='#{property}']")&.[]("content")&.strip + elsif name + @parsed&.at_css("meta[name='#{name}']")&.[]("content")&.strip + end + end + + def extract_first_paragraph + paragraphs = @parsed&.css("p") + return nil unless paragraphs + + paragraphs.each do |p| + text = p.text.strip + # Return first paragraph with substantial content + return text if text.length > 50 + end + + nil + end + + def find_largest_image + images = @parsed&.css("img") + return nil unless images&.any? + + largest = nil + max_size = 0 + + images.each do |img| + width = img["width"].to_i + height = img["height"].to_i + + # Skip if dimensions not specified + next if width.zero? || height.zero? + + # Skip tiny images (likely icons/logos) + next if width < 200 || height < 200 + + # Skip images with weird aspect ratios + aspect_ratio = width.to_f / height + next if aspect_ratio < 0.33 || aspect_ratio > 3.0 + + size = width * height + if size > max_size + max_size = size + largest = img["src"] + end + end + + largest + end + + def resolve_url(path) + return nil if path.blank? + return path if path.start_with?("http://", "https://") + + # Resolve relative URLs + begin + base_uri = URI(@url) + URI.join(base_uri, path).to_s + rescue => e + Rails.logger.warn "Failed to resolve relative URL #{path}: #{e.message}" + path + end + end +end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 059753a..0326eff 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -314,7 +314,7 @@