DEPLOY/
DEPLOY/openbsd/
DEPLOY/openbsd/files/
DEPLOY/openbsd/files/rc.d/
DEPLOY/rails/
DEPLOY/rails/__shared/
DEPLOY/rails/__shared/layouts/
DEPLOY/rails/amber/
DEPLOY/rails/amber/app/
DEPLOY/rails/amber/app/app/
DEPLOY/rails/amber/app/app/assets/
DEPLOY/rails/amber/app/app/assets/builds/
DEPLOY/rails/amber/app/app/assets/images/
DEPLOY/rails/amber/app/app/assets/stylesheets/
DEPLOY/rails/amber/app/app/channels/
DEPLOY/rails/amber/app/app/channels/application_cable/
DEPLOY/rails/amber/app/app/controllers/
DEPLOY/rails/amber/app/app/controllers/concerns/
DEPLOY/rails/amber/app/app/helpers/
DEPLOY/rails/amber/app/app/javascript/
DEPLOY/rails/amber/app/app/javascript/controllers/
DEPLOY/rails/amber/app/app/jobs/
DEPLOY/rails/amber/app/app/mailers/
DEPLOY/rails/amber/app/app/models/
DEPLOY/rails/amber/app/app/models/concerns/
DEPLOY/rails/amber/app/app/services/
DEPLOY/rails/amber/app/app/views/
DEPLOY/rails/amber/app/app/views/ai/
DEPLOY/rails/amber/app/app/views/home/
DEPLOY/rails/amber/app/app/views/items/
DEPLOY/rails/amber/app/app/views/layouts/
DEPLOY/rails/amber/app/app/views/outfits/
DEPLOY/rails/amber/app/app/views/passwords/
DEPLOY/rails/amber/app/app/views/passwords_mailer/
DEPLOY/rails/amber/app/app/views/planned_outfits/
DEPLOY/rails/amber/app/app/views/posts/
DEPLOY/rails/amber/app/app/views/pwa/
DEPLOY/rails/amber/app/app/views/registrations/
DEPLOY/rails/amber/app/app/views/sessions/
DEPLOY/rails/amber/app/app/views/shared/
DEPLOY/rails/amber/app/app/views/users/
DEPLOY/rails/amber/app/bin/
DEPLOY/rails/amber/app/config/
DEPLOY/rails/amber/app/config/environments/
DEPLOY/rails/amber/app/config/initializers/
DEPLOY/rails/amber/app/config/locales/
DEPLOY/rails/amber/app/db/
DEPLOY/rails/amber/app/db/migrate/
DEPLOY/rails/amber/app/lib/
DEPLOY/rails/amber/app/lib/tasks/
DEPLOY/rails/amber/app/public/
DEPLOY/rails/amber/app/script/
DEPLOY/rails/amber/app/storage/
DEPLOY/rails/baibl/
DEPLOY/rails/baibl/app/
DEPLOY/rails/baibl/app/app/
DEPLOY/rails/baibl/app/app/assets/
DEPLOY/rails/baibl/app/app/assets/images/
DEPLOY/rails/baibl/app/app/assets/stylesheets/
DEPLOY/rails/baibl/app/app/controllers/
DEPLOY/rails/baibl/app/app/controllers/concerns/
DEPLOY/rails/baibl/app/app/helpers/
DEPLOY/rails/baibl/app/app/javascript/
DEPLOY/rails/baibl/app/app/javascript/controllers/
DEPLOY/rails/baibl/app/app/jobs/
DEPLOY/rails/baibl/app/app/mailers/
DEPLOY/rails/baibl/app/app/models/
DEPLOY/rails/baibl/app/app/models/concerns/
DEPLOY/rails/baibl/app/app/views/
DEPLOY/rails/baibl/app/app/views/bookmarks/
DEPLOY/rails/baibl/app/app/views/highlights/
DEPLOY/rails/baibl/app/app/views/layouts/
DEPLOY/rails/baibl/app/app/views/pwa/
DEPLOY/rails/baibl/app/app/views/scriptures/
DEPLOY/rails/baibl/app/bin/
DEPLOY/rails/baibl/app/config/
DEPLOY/rails/baibl/app/config/environments/
DEPLOY/rails/baibl/app/config/initializers/
DEPLOY/rails/baibl/app/config/locales/
DEPLOY/rails/baibl/app/db/
DEPLOY/rails/baibl/app/db/migrate/
DEPLOY/rails/baibl/app/lib/
DEPLOY/rails/baibl/app/lib/tasks/
DEPLOY/rails/baibl/app/public/
DEPLOY/rails/baibl/app/script/
DEPLOY/rails/baibl/app/storage/
DEPLOY/rails/blognet/
DEPLOY/rails/blognet/app/
DEPLOY/rails/blognet/app/app/
DEPLOY/rails/blognet/app/app/assets/
DEPLOY/rails/blognet/app/app/assets/images/
DEPLOY/rails/blognet/app/app/assets/stylesheets/
DEPLOY/rails/blognet/app/app/channels/
DEPLOY/rails/blognet/app/app/channels/application_cable/
DEPLOY/rails/blognet/app/app/controllers/
DEPLOY/rails/blognet/app/app/controllers/concerns/
DEPLOY/rails/blognet/app/app/helpers/
DEPLOY/rails/blognet/app/app/javascript/
DEPLOY/rails/blognet/app/app/javascript/controllers/
DEPLOY/rails/blognet/app/app/jobs/
DEPLOY/rails/blognet/app/app/mailers/
DEPLOY/rails/blognet/app/app/models/
DEPLOY/rails/blognet/app/app/models/concerns/
DEPLOY/rails/blognet/app/app/views/
DEPLOY/rails/blognet/app/app/views/active_storage/
DEPLOY/rails/blognet/app/app/views/active_storage/blobs/
DEPLOY/rails/blognet/app/app/views/blogs/
DEPLOY/rails/blognet/app/app/views/comments/
DEPLOY/rails/blognet/app/app/views/layouts/
DEPLOY/rails/blognet/app/app/views/layouts/action_text/
DEPLOY/rails/blognet/app/app/views/layouts/action_text/contents/
DEPLOY/rails/blognet/app/app/views/passwords/
DEPLOY/rails/blognet/app/app/views/passwords_mailer/
DEPLOY/rails/blognet/app/app/views/posts/
DEPLOY/rails/blognet/app/app/views/pwa/
DEPLOY/rails/blognet/app/app/views/sessions/
DEPLOY/rails/blognet/app/bin/
DEPLOY/rails/blognet/app/config/
DEPLOY/rails/blognet/app/config/environments/
DEPLOY/rails/blognet/app/config/initializers/
DEPLOY/rails/blognet/app/config/locales/
DEPLOY/rails/blognet/app/db/
DEPLOY/rails/blognet/app/db/migrate/
DEPLOY/rails/blognet/app/lib/
DEPLOY/rails/blognet/app/lib/tasks/
DEPLOY/rails/blognet/app/public/
DEPLOY/rails/blognet/app/script/
DEPLOY/rails/blognet/app/storage/
DEPLOY/rails/brgen/
DEPLOY/rails/brgen/app/
DEPLOY/rails/brgen/app/app/
DEPLOY/rails/brgen/app/app/assets/
DEPLOY/rails/brgen/app/app/assets/images/
DEPLOY/rails/brgen/app/app/assets/stylesheets/
DEPLOY/rails/brgen/app/app/channels/
DEPLOY/rails/brgen/app/app/channels/application_cable/
DEPLOY/rails/brgen/app/app/controllers/
DEPLOY/rails/brgen/app/app/controllers/concerns/
DEPLOY/rails/brgen/app/app/controllers/dating/
DEPLOY/rails/brgen/app/app/controllers/marketplace/
DEPLOY/rails/brgen/app/app/controllers/playlist/
DEPLOY/rails/brgen/app/app/controllers/takeaway/
DEPLOY/rails/brgen/app/app/controllers/tv/
DEPLOY/rails/brgen/app/app/helpers/
DEPLOY/rails/brgen/app/app/javascript/
DEPLOY/rails/brgen/app/app/javascript/controllers/
DEPLOY/rails/brgen/app/app/jobs/
DEPLOY/rails/brgen/app/app/mailers/
DEPLOY/rails/brgen/app/app/models/
DEPLOY/rails/brgen/app/app/models/concerns/
DEPLOY/rails/brgen/app/app/models/dating/
DEPLOY/rails/brgen/app/app/models/marketplace/
DEPLOY/rails/brgen/app/app/models/playlist/
DEPLOY/rails/brgen/app/app/models/takeaway/
DEPLOY/rails/brgen/app/app/models/tv/
DEPLOY/rails/brgen/app/app/services/
DEPLOY/rails/brgen/app/app/views/
DEPLOY/rails/brgen/app/app/views/comments/
DEPLOY/rails/brgen/app/app/views/communities/
DEPLOY/rails/brgen/app/app/views/conversations/
DEPLOY/rails/brgen/app/app/views/dating/
DEPLOY/rails/brgen/app/app/views/dating/home/
DEPLOY/rails/brgen/app/app/views/dating/matches/
DEPLOY/rails/brgen/app/app/views/dating/profiles/
DEPLOY/rails/brgen/app/app/views/home/
DEPLOY/rails/brgen/app/app/views/layouts/
DEPLOY/rails/brgen/app/app/views/marketplace/
DEPLOY/rails/brgen/app/app/views/marketplace/categories/
DEPLOY/rails/brgen/app/app/views/marketplace/listings/
DEPLOY/rails/brgen/app/app/views/messages/
DEPLOY/rails/brgen/app/app/views/passwords/
DEPLOY/rails/brgen/app/app/views/passwords_mailer/
DEPLOY/rails/brgen/app/app/views/playlist/
DEPLOY/rails/brgen/app/app/views/playlist/playlists/
DEPLOY/rails/brgen/app/app/views/playlist/tracks/
DEPLOY/rails/brgen/app/app/views/posts/
DEPLOY/rails/brgen/app/app/views/pwa/
DEPLOY/rails/brgen/app/app/views/sessions/
DEPLOY/rails/brgen/app/app/views/shared/
DEPLOY/rails/brgen/app/app/views/takeaway/
DEPLOY/rails/brgen/app/app/views/takeaway/menu_items/
DEPLOY/rails/brgen/app/app/views/takeaway/orders/
DEPLOY/rails/brgen/app/app/views/takeaway/restaurants/
DEPLOY/rails/brgen/app/app/views/tv/
DEPLOY/rails/brgen/app/app/views/tv/channels/
DEPLOY/rails/brgen/app/app/views/tv/home/
DEPLOY/rails/brgen/app/app/views/tv/videos/
DEPLOY/rails/brgen/app/app/views/typing_indicators/
DEPLOY/rails/brgen/app/app/views/votes/
DEPLOY/rails/brgen/app/bin/
DEPLOY/rails/brgen/app/config/
DEPLOY/rails/brgen/app/config/environments/
DEPLOY/rails/brgen/app/config/initializers/
DEPLOY/rails/brgen/app/config/locales/
DEPLOY/rails/brgen/app/db/
DEPLOY/rails/brgen/app/db/migrate/
DEPLOY/rails/brgen/app/lib/
DEPLOY/rails/brgen/app/lib/tasks/
DEPLOY/rails/brgen/app/public/
DEPLOY/rails/brgen/app/script/
DEPLOY/rails/brgen/app/storage/
DEPLOY/rails/brgen/app/test/
DEPLOY/rails/brgen/app/test/controllers/
DEPLOY/rails/brgen/app/test/fixtures/
DEPLOY/rails/brgen/app/test/fixtures/files/
DEPLOY/rails/brgen/app/test/helpers/
DEPLOY/rails/brgen/app/test/integration/
DEPLOY/rails/brgen/app/test/models/
DEPLOY/rails/brgen/subapps/
DEPLOY/rails/brgen/subapps/dating/
DEPLOY/rails/brgen/subapps/marketplace/
DEPLOY/rails/brgen/subapps/playlist/
DEPLOY/rails/brgen/subapps/takeaway/
DEPLOY/rails/brgen/subapps/tv/
DEPLOY/rails/bsdports/
DEPLOY/rails/bsdports/app/
DEPLOY/rails/bsdports/app/app/
DEPLOY/rails/bsdports/app/app/assets/
DEPLOY/rails/bsdports/app/app/assets/images/
DEPLOY/rails/bsdports/app/app/assets/stylesheets/
DEPLOY/rails/bsdports/app/app/controllers/
DEPLOY/rails/bsdports/app/app/controllers/concerns/
DEPLOY/rails/bsdports/app/app/helpers/
DEPLOY/rails/bsdports/app/app/javascript/
DEPLOY/rails/bsdports/app/app/javascript/controllers/
DEPLOY/rails/bsdports/app/app/jobs/
DEPLOY/rails/bsdports/app/app/mailers/
DEPLOY/rails/bsdports/app/app/models/
DEPLOY/rails/bsdports/app/app/models/concerns/
DEPLOY/rails/bsdports/app/app/views/
DEPLOY/rails/bsdports/app/app/views/categories/
DEPLOY/rails/bsdports/app/app/views/comments/
DEPLOY/rails/bsdports/app/app/views/layouts/
DEPLOY/rails/bsdports/app/app/views/ports/
DEPLOY/rails/bsdports/app/app/views/pwa/
DEPLOY/rails/bsdports/app/bin/
DEPLOY/rails/bsdports/app/config/
DEPLOY/rails/bsdports/app/config/environments/
DEPLOY/rails/bsdports/app/config/initializers/
DEPLOY/rails/bsdports/app/config/locales/
DEPLOY/rails/bsdports/app/db/
DEPLOY/rails/bsdports/app/db/migrate/
DEPLOY/rails/bsdports/app/lib/
DEPLOY/rails/bsdports/app/lib/tasks/
DEPLOY/rails/bsdports/app/public/
DEPLOY/rails/bsdports/app/script/
DEPLOY/rails/bsdports/app/storage/
DEPLOY/rails/hjerterom/
DEPLOY/rails/hjerterom/app/
DEPLOY/rails/hjerterom/app/app/
DEPLOY/rails/hjerterom/app/app/assets/
DEPLOY/rails/hjerterom/app/app/assets/images/
DEPLOY/rails/hjerterom/app/app/assets/stylesheets/
DEPLOY/rails/hjerterom/app/app/controllers/
DEPLOY/rails/hjerterom/app/app/controllers/concerns/
DEPLOY/rails/hjerterom/app/app/helpers/
DEPLOY/rails/hjerterom/app/app/javascript/
DEPLOY/rails/hjerterom/app/app/javascript/controllers/
DEPLOY/rails/hjerterom/app/app/jobs/
DEPLOY/rails/hjerterom/app/app/mailers/
DEPLOY/rails/hjerterom/app/app/models/
DEPLOY/rails/hjerterom/app/app/models/concerns/
DEPLOY/rails/hjerterom/app/app/views/
DEPLOY/rails/hjerterom/app/app/views/community/
DEPLOY/rails/hjerterom/app/app/views/food_listings/
DEPLOY/rails/hjerterom/app/app/views/home/
DEPLOY/rails/hjerterom/app/app/views/layouts/
DEPLOY/rails/hjerterom/app/app/views/pwa/
DEPLOY/rails/hjerterom/app/app/views/resources/
DEPLOY/rails/hjerterom/app/bin/
DEPLOY/rails/hjerterom/app/config/
DEPLOY/rails/hjerterom/app/config/environments/
DEPLOY/rails/hjerterom/app/config/initializers/
DEPLOY/rails/hjerterom/app/config/locales/
DEPLOY/rails/hjerterom/app/db/
DEPLOY/rails/hjerterom/app/db/migrate/
DEPLOY/rails/hjerterom/app/lib/
DEPLOY/rails/hjerterom/app/lib/tasks/
DEPLOY/rails/hjerterom/app/public/
DEPLOY/rails/hjerterom/app/script/
DEPLOY/rails/hjerterom/app/storage/
completions/
data/
data/claude/
data/prompts/
data/traces/
data/web/
exe/
lib/
lib/master/
lib/master/agent/
lib/master/autoloop/
lib/master/builder/
lib/master/cli/
lib/master/code_index/
lib/master/command_registry/
lib/master/council/
lib/master/introspection/
lib/master/memory/
lib/master/orders/
lib/master/persistence/
lib/master/reasoning/
lib/master/routing/
lib/master/scan/
lib/master/scan/rules/
lib/master/security/
lib/master/stages/
lib/master/swarm/
lib/master/swarm/workers/
lib/master/sweep/
lib/master/tools/
scripts/
skills/
skills/explain/
test/
test/support/
web/
web/app/
web/app/assets/
web/app/assets/images/
web/app/assets/stylesheets/
web/app/controllers/
web/app/controllers/concerns/
web/app/helpers/
web/app/models/
web/app/models/concerns/
web/app/views/
web/app/views/canvas/
web/app/views/chat/
web/app/views/layouts/
web/app/views/pwa/
web/bin/
web/config/
web/config/environments/
web/config/initializers/
web/config/locales/
web/db/
web/lib/
web/lib/tasks/
web/public/
web/public/assets/
web/public/dilla/
web/script/
web/storage/
CONVENTIONS.md
DEPLOY/README.md
DEPLOY/openbsd/README.md
DEPLOY/openbsd/files/httpd.conf
DEPLOY/openbsd/files/pf.stage1.conf
DEPLOY/openbsd/files/pf.stage2.conf
DEPLOY/openbsd/files/renew-certs.sh
DEPLOY/openbsd/files/smtpd.conf
DEPLOY/openbsd/openbsd.sh
DEPLOY/postpro.rb
DEPLOY/rails/@shared_functions.sh
DEPLOY/rails/README.md
DEPLOY/rails/__shared/@active_storage_and_imageprocessing.sh
DEPLOY/rails/__shared/@ai.sh
DEPLOY/rails/__shared/@airbnb_features.sh
DEPLOY/rails/__shared/@common.sh
DEPLOY/rails/__shared/@devise.sh
DEPLOY/rails/__shared/@features_base.sh
DEPLOY/rails/__shared/@instant_messaging.sh
DEPLOY/rails/__shared/@live_cam_streaming.sh
DEPLOY/rails/__shared/@live_streaming.sh
DEPLOY/rails/__shared/@messenger_features.sh
DEPLOY/rails/__shared/@postgresql.sh
DEPLOY/rails/__shared/@posts.sh
DEPLOY/rails/__shared/@pwa.sh
DEPLOY/rails/__shared/@rails_new.sh
DEPLOY/rails/__shared/@reddit_features.sh
DEPLOY/rails/__shared/@redis.sh
DEPLOY/rails/__shared/@twitter_features.sh
DEPLOY/rails/__shared/@yarn.sh
DEPLOY/rails/__shared/layouts/_flash.html.erb
DEPLOY/rails/__shared/layouts/_footer.html.erb
DEPLOY/rails/__shared/layouts/_meta.html.erb
DEPLOY/rails/__shared/layouts/_nav.html.erb
DEPLOY/rails/__shared/layouts/application.html.erb
DEPLOY/rails/__shared/layouts/visualizer.js
DEPLOY/rails/amber/@shared_functions.sh
DEPLOY/rails/amber/README.md
DEPLOY/rails/amber/amber.sh
DEPLOY/rails/amber/app/Dockerfile
DEPLOY/rails/amber/app/Gemfile
DEPLOY/rails/amber/app/README.md
DEPLOY/rails/amber/app/Rakefile
DEPLOY/rails/amber/app/app/channels/application_cable/connection.rb
DEPLOY/rails/amber/app/app/controllers/ai_controller.rb
DEPLOY/rails/amber/app/app/controllers/application_controller.rb
DEPLOY/rails/amber/app/app/controllers/concerns/authentication.rb
DEPLOY/rails/amber/app/app/controllers/follows_controller.rb
DEPLOY/rails/amber/app/app/controllers/home_controller.rb
DEPLOY/rails/amber/app/app/controllers/items_controller.rb
DEPLOY/rails/amber/app/app/controllers/outfits_controller.rb
DEPLOY/rails/amber/app/app/controllers/passwords_controller.rb
DEPLOY/rails/amber/app/app/controllers/planned_outfits_controller.rb
DEPLOY/rails/amber/app/app/controllers/posts_controller.rb
DEPLOY/rails/amber/app/app/controllers/registrations_controller.rb
DEPLOY/rails/amber/app/app/controllers/sessions_controller.rb
DEPLOY/rails/amber/app/app/controllers/users_controller.rb
DEPLOY/rails/amber/app/app/helpers/application_helper.rb
DEPLOY/rails/amber/app/app/javascript/application.js
DEPLOY/rails/amber/app/app/javascript/controllers/animated_number_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/application.js
DEPLOY/rails/amber/app/app/javascript/controllers/auto_submit_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/character_counter_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/clipboard_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/dialog_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/dropdown_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/filter_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/hello_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/index.js
DEPLOY/rails/amber/app/app/javascript/controllers/notification_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/sortable_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/textarea_autogrow_controller.js
DEPLOY/rails/amber/app/app/javascript/controllers/timeago_controller.js
DEPLOY/rails/amber/app/app/jobs/application_job.rb
DEPLOY/rails/amber/app/app/mailers/application_mailer.rb
DEPLOY/rails/amber/app/app/mailers/passwords_mailer.rb
DEPLOY/rails/amber/app/app/models/application_record.rb
DEPLOY/rails/amber/app/app/models/current.rb
DEPLOY/rails/amber/app/app/models/follow.rb
DEPLOY/rails/amber/app/app/models/item.rb
DEPLOY/rails/amber/app/app/models/outfit.rb
DEPLOY/rails/amber/app/app/models/outfit_item.rb
DEPLOY/rails/amber/app/app/models/planned_outfit.rb
DEPLOY/rails/amber/app/app/models/post.rb
DEPLOY/rails/amber/app/app/models/session.rb
DEPLOY/rails/amber/app/app/models/user.rb
DEPLOY/rails/amber/app/app/services/wardrobe_ai_service.rb
DEPLOY/rails/amber/app/app/services/weather_service.rb
DEPLOY/rails/amber/app/app/views/ai/_analysis.html.erb
DEPLOY/rails/amber/app/app/views/ai/_item_tags.html.erb
DEPLOY/rails/amber/app/app/views/ai/capsule.html.erb
DEPLOY/rails/amber/app/app/views/ai/color_palette.html.erb
DEPLOY/rails/amber/app/app/views/ai/declutter_guide.html.erb
DEPLOY/rails/amber/app/app/views/ai/mood_board.html.erb
DEPLOY/rails/amber/app/app/views/ai/occasion_map.html.erb
DEPLOY/rails/amber/app/app/views/ai/search.html.erb
DEPLOY/rails/amber/app/app/views/ai/suggest_outfits.html.erb
DEPLOY/rails/amber/app/app/views/home/index.html.erb
DEPLOY/rails/amber/app/app/views/items/_form.html.erb
DEPLOY/rails/amber/app/app/views/items/_item.html.erb
DEPLOY/rails/amber/app/app/views/items/edit.html.erb
DEPLOY/rails/amber/app/app/views/items/index.html.erb
DEPLOY/rails/amber/app/app/views/items/new.html.erb
DEPLOY/rails/amber/app/app/views/items/show.html.erb
DEPLOY/rails/amber/app/app/views/layouts/application.html.erb
DEPLOY/rails/amber/app/app/views/layouts/mailer.html.erb
DEPLOY/rails/amber/app/app/views/layouts/mailer.text.erb
DEPLOY/rails/amber/app/app/views/outfits/_form.html.erb
DEPLOY/rails/amber/app/app/views/outfits/_outfit.html.erb
DEPLOY/rails/amber/app/app/views/outfits/edit.html.erb
DEPLOY/rails/amber/app/app/views/outfits/index.html.erb
DEPLOY/rails/amber/app/app/views/outfits/new.html.erb
DEPLOY/rails/amber/app/app/views/outfits/show.html.erb
DEPLOY/rails/amber/app/app/views/passwords/edit.html.erb
DEPLOY/rails/amber/app/app/views/passwords/new.html.erb
DEPLOY/rails/amber/app/app/views/passwords_mailer/reset.html.erb
DEPLOY/rails/amber/app/app/views/passwords_mailer/reset.text.erb
DEPLOY/rails/amber/app/app/views/planned_outfits/index.html.erb
DEPLOY/rails/amber/app/app/views/posts/_post.html.erb
DEPLOY/rails/amber/app/app/views/posts/feed.html.erb
DEPLOY/rails/amber/app/app/views/posts/index.html.erb
DEPLOY/rails/amber/app/app/views/posts/new.html.erb
DEPLOY/rails/amber/app/app/views/posts/show.html.erb
DEPLOY/rails/amber/app/app/views/pwa/manifest.json.erb
DEPLOY/rails/amber/app/app/views/pwa/service-worker.js
DEPLOY/rails/amber/app/app/views/registrations/new.html.erb
DEPLOY/rails/amber/app/app/views/sessions/new.html.erb
DEPLOY/rails/amber/app/app/views/shared/_errors.html.erb
DEPLOY/rails/amber/app/app/views/shared/_flash.html.erb
DEPLOY/rails/amber/app/app/views/shared/_pagination.html.erb
DEPLOY/rails/amber/app/app/views/users/show.html.erb
DEPLOY/rails/amber/app/config/application.rb
DEPLOY/rails/amber/app/config/boot.rb
DEPLOY/rails/amber/app/config/bundler-audit.yml
DEPLOY/rails/amber/app/config/cable.yml
DEPLOY/rails/amber/app/config/cache.yml
DEPLOY/rails/amber/app/config/ci.rb
DEPLOY/rails/amber/app/config/database.yml
DEPLOY/rails/amber/app/config/deploy.yml
DEPLOY/rails/amber/app/config/environment.rb
DEPLOY/rails/amber/app/config/environments/development.rb
DEPLOY/rails/amber/app/config/environments/production.rb
DEPLOY/rails/amber/app/config/environments/test.rb
DEPLOY/rails/amber/app/config/falcon.rb
DEPLOY/rails/amber/app/config/importmap.rb
DEPLOY/rails/amber/app/config/initializers/assets.rb
DEPLOY/rails/amber/app/config/initializers/content_security_policy.rb
DEPLOY/rails/amber/app/config/initializers/filter_parameter_logging.rb
DEPLOY/rails/amber/app/config/initializers/inflections.rb
DEPLOY/rails/amber/app/config/initializers/pagy.rb
DEPLOY/rails/amber/app/config/initializers/requires.rb
DEPLOY/rails/amber/app/config/locales/en.yml
DEPLOY/rails/amber/app/config/puma.rb
DEPLOY/rails/amber/app/config/queue.yml
DEPLOY/rails/amber/app/config/recurring.yml
DEPLOY/rails/amber/app/config/routes.rb
DEPLOY/rails/amber/app/config/storage.yml
DEPLOY/rails/amber/app/db/cable_schema.rb
DEPLOY/rails/amber/app/db/cache_schema.rb
DEPLOY/rails/amber/app/db/migrate/20260504180350_create_users.rb
DEPLOY/rails/amber/app/db/migrate/20260504180352_create_sessions.rb
DEPLOY/rails/amber/app/db/migrate/20260504180357_create_active_storage_tables.active_storage.rb
DEPLOY/rails/amber/app/db/migrate/20260504180401_create_items.rb
DEPLOY/rails/amber/app/db/migrate/20260504180405_create_outfit_items.rb
DEPLOY/rails/amber/app/db/migrate/20260504180406_create_planned_outfits.rb
DEPLOY/rails/amber/app/db/migrate/20260504180410_add_extended_fields_to_items.rb
DEPLOY/rails/amber/app/db/migrate/20260504205505_create_outfits.rb
DEPLOY/rails/amber/app/db/migrate/20260504211952_create_follows.rb
DEPLOY/rails/amber/app/db/migrate/20260504212306_create_posts.rb
DEPLOY/rails/amber/app/db/queue_schema.rb
DEPLOY/rails/amber/app/db/schema.rb
DEPLOY/rails/amber/app/db/seeds.rb
DEPLOY/rails/amber/app/public/robots.txt
DEPLOY/rails/baibl/README.md
DEPLOY/rails/baibl/app/Dockerfile
DEPLOY/rails/baibl/app/Gemfile
DEPLOY/rails/baibl/app/README.md
DEPLOY/rails/baibl/app/Rakefile
DEPLOY/rails/baibl/app/app/controllers/application_controller.rb
DEPLOY/rails/baibl/app/app/controllers/bookmarks_controller.rb
DEPLOY/rails/baibl/app/app/controllers/concerns/authentication.rb
DEPLOY/rails/baibl/app/app/controllers/highlights_controller.rb
DEPLOY/rails/baibl/app/app/controllers/passwords_controller.rb
DEPLOY/rails/baibl/app/app/controllers/scriptures_controller.rb
DEPLOY/rails/baibl/app/app/controllers/sessions_controller.rb
DEPLOY/rails/baibl/app/app/helpers/application_helper.rb
DEPLOY/rails/baibl/app/app/javascript/application.js
DEPLOY/rails/baibl/app/app/javascript/controllers/animated_number_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/application.js
DEPLOY/rails/baibl/app/app/javascript/controllers/auto_submit_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/character_counter_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/clipboard_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/dialog_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/dropdown_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/hello_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/index.js
DEPLOY/rails/baibl/app/app/javascript/controllers/notification_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/sortable_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/textarea_autogrow_controller.js
DEPLOY/rails/baibl/app/app/javascript/controllers/timeago_controller.js
DEPLOY/rails/baibl/app/app/jobs/application_job.rb
DEPLOY/rails/baibl/app/app/mailers/application_mailer.rb
DEPLOY/rails/baibl/app/app/models/application_record.rb
DEPLOY/rails/baibl/app/app/models/book.rb
DEPLOY/rails/baibl/app/app/models/bookmark.rb
DEPLOY/rails/baibl/app/app/models/chapter.rb
DEPLOY/rails/baibl/app/app/models/current.rb
DEPLOY/rails/baibl/app/app/models/highlight.rb
DEPLOY/rails/baibl/app/app/models/reading_plan.rb
DEPLOY/rails/baibl/app/app/models/reading_plan_day.rb
DEPLOY/rails/baibl/app/app/models/session.rb
DEPLOY/rails/baibl/app/app/models/user.rb
DEPLOY/rails/baibl/app/app/models/verse.rb
DEPLOY/rails/baibl/app/app/views/bookmarks/index.html.erb
DEPLOY/rails/baibl/app/app/views/highlights/create.turbo_stream.erb
DEPLOY/rails/baibl/app/app/views/highlights/destroy.turbo_stream.erb
DEPLOY/rails/baibl/app/app/views/layouts/application.html.erb
DEPLOY/rails/baibl/app/app/views/layouts/mailer.html.erb
DEPLOY/rails/baibl/app/app/views/layouts/mailer.text.erb
DEPLOY/rails/baibl/app/app/views/pwa/manifest.json.erb
DEPLOY/rails/baibl/app/app/views/pwa/service-worker.js
DEPLOY/rails/baibl/app/app/views/scriptures/book.html.erb
DEPLOY/rails/baibl/app/app/views/scriptures/chapter.html.erb
DEPLOY/rails/baibl/app/app/views/scriptures/index.html.erb
DEPLOY/rails/baibl/app/app/views/scriptures/search.html.erb
DEPLOY/rails/baibl/app/config/application.rb
DEPLOY/rails/baibl/app/config/boot.rb
DEPLOY/rails/baibl/app/config/bundler-audit.yml
DEPLOY/rails/baibl/app/config/cable.yml
DEPLOY/rails/baibl/app/config/ci.rb
DEPLOY/rails/baibl/app/config/database.yml
DEPLOY/rails/baibl/app/config/deploy.yml
DEPLOY/rails/baibl/app/config/environment.rb
DEPLOY/rails/baibl/app/config/environments/development.rb
DEPLOY/rails/baibl/app/config/environments/production.rb
DEPLOY/rails/baibl/app/config/environments/test.rb
DEPLOY/rails/baibl/app/config/importmap.rb
DEPLOY/rails/baibl/app/config/initializers/assets.rb
DEPLOY/rails/baibl/app/config/initializers/content_security_policy.rb
DEPLOY/rails/baibl/app/config/initializers/filter_parameter_logging.rb
DEPLOY/rails/baibl/app/config/initializers/inflections.rb
DEPLOY/rails/baibl/app/config/locales/en.yml
DEPLOY/rails/baibl/app/config/puma.rb
DEPLOY/rails/baibl/app/config/routes.rb
DEPLOY/rails/baibl/app/config/storage.yml
DEPLOY/rails/baibl/app/db/migrate/20260501020807_create_users.rb
DEPLOY/rails/baibl/app/db/migrate/20260501020818_create_sessions.rb
DEPLOY/rails/baibl/app/db/migrate/20260507120001_create_books.rb
DEPLOY/rails/baibl/app/db/migrate/20260507120002_create_chapters.rb
DEPLOY/rails/baibl/app/db/migrate/20260507120003_create_verses.rb
DEPLOY/rails/baibl/app/db/migrate/20260507120004_create_highlights.rb
DEPLOY/rails/baibl/app/db/migrate/20260507120005_create_bookmarks.rb
DEPLOY/rails/baibl/app/db/migrate/20260507120006_create_reading_plans.rb
DEPLOY/rails/baibl/app/db/migrate/20260507120007_create_reading_plan_days.rb
DEPLOY/rails/baibl/app/db/seeds.rb
DEPLOY/rails/baibl/app/public/robots.txt
DEPLOY/rails/baibl/baibl.sh
DEPLOY/rails/blognet/README.md
DEPLOY/rails/blognet/app/Dockerfile
DEPLOY/rails/blognet/app/Gemfile
DEPLOY/rails/blognet/app/README.md
DEPLOY/rails/blognet/app/Rakefile
DEPLOY/rails/blognet/app/app/channels/application_cable/connection.rb
DEPLOY/rails/blognet/app/app/controllers/application_controller.rb
DEPLOY/rails/blognet/app/app/controllers/blogs_controller.rb
DEPLOY/rails/blognet/app/app/controllers/comments_controller.rb
DEPLOY/rails/blognet/app/app/controllers/concerns/authentication.rb
DEPLOY/rails/blognet/app/app/controllers/passwords_controller.rb
DEPLOY/rails/blognet/app/app/controllers/posts_controller.rb
DEPLOY/rails/blognet/app/app/controllers/sessions_controller.rb
DEPLOY/rails/blognet/app/app/helpers/application_helper.rb
DEPLOY/rails/blognet/app/app/javascript/application.js
DEPLOY/rails/blognet/app/app/javascript/controllers/animated_number_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/application.js
DEPLOY/rails/blognet/app/app/javascript/controllers/auto_submit_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/character_counter_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/clipboard_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/dialog_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/dropdown_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/hello_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/index.js
DEPLOY/rails/blognet/app/app/javascript/controllers/notification_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/sortable_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/textarea_autogrow_controller.js
DEPLOY/rails/blognet/app/app/javascript/controllers/timeago_controller.js
DEPLOY/rails/blognet/app/app/jobs/application_job.rb
DEPLOY/rails/blognet/app/app/mailers/application_mailer.rb
DEPLOY/rails/blognet/app/app/mailers/passwords_mailer.rb
DEPLOY/rails/blognet/app/app/models/application_record.rb
DEPLOY/rails/blognet/app/app/models/blog.rb
DEPLOY/rails/blognet/app/app/models/categorization.rb
DEPLOY/rails/blognet/app/app/models/category.rb
DEPLOY/rails/blognet/app/app/models/comment.rb
DEPLOY/rails/blognet/app/app/models/current.rb
DEPLOY/rails/blognet/app/app/models/post.rb
DEPLOY/rails/blognet/app/app/models/session.rb
DEPLOY/rails/blognet/app/app/models/tag.rb
DEPLOY/rails/blognet/app/app/models/tagging.rb
DEPLOY/rails/blognet/app/app/models/user.rb
DEPLOY/rails/blognet/app/app/views/active_storage/blobs/_blob.html.erb
DEPLOY/rails/blognet/app/app/views/blogs/_form.html.erb
DEPLOY/rails/blognet/app/app/views/blogs/edit.html.erb
DEPLOY/rails/blognet/app/app/views/blogs/index.html.erb
DEPLOY/rails/blognet/app/app/views/blogs/new.html.erb
DEPLOY/rails/blognet/app/app/views/blogs/show.html.erb
DEPLOY/rails/blognet/app/app/views/comments/_comment.html.erb
DEPLOY/rails/blognet/app/app/views/layouts/action_text/contents/_content.html.erb
DEPLOY/rails/blognet/app/app/views/layouts/application.html.erb
DEPLOY/rails/blognet/app/app/views/layouts/mailer.html.erb
DEPLOY/rails/blognet/app/app/views/layouts/mailer.text.erb
DEPLOY/rails/blognet/app/app/views/passwords/edit.html.erb
DEPLOY/rails/blognet/app/app/views/passwords/new.html.erb
DEPLOY/rails/blognet/app/app/views/passwords_mailer/reset.html.erb
DEPLOY/rails/blognet/app/app/views/passwords_mailer/reset.text.erb
DEPLOY/rails/blognet/app/app/views/posts/_form.html.erb
DEPLOY/rails/blognet/app/app/views/posts/edit.html.erb
DEPLOY/rails/blognet/app/app/views/posts/new.html.erb
DEPLOY/rails/blognet/app/app/views/posts/show.html.erb
DEPLOY/rails/blognet/app/app/views/pwa/manifest.json.erb
DEPLOY/rails/blognet/app/app/views/pwa/service-worker.js
DEPLOY/rails/blognet/app/app/views/sessions/new.html.erb
DEPLOY/rails/blognet/app/config/application.rb
DEPLOY/rails/blognet/app/config/boot.rb
DEPLOY/rails/blognet/app/config/bundler-audit.yml
DEPLOY/rails/blognet/app/config/cable.yml
DEPLOY/rails/blognet/app/config/cache.yml
DEPLOY/rails/blognet/app/config/ci.rb
DEPLOY/rails/blognet/app/config/database.yml
DEPLOY/rails/blognet/app/config/deploy.yml
DEPLOY/rails/blognet/app/config/environment.rb
DEPLOY/rails/blognet/app/config/environments/development.rb
DEPLOY/rails/blognet/app/config/environments/production.rb
DEPLOY/rails/blognet/app/config/environments/test.rb
DEPLOY/rails/blognet/app/config/importmap.rb
DEPLOY/rails/blognet/app/config/initializers/assets.rb
DEPLOY/rails/blognet/app/config/initializers/content_security_policy.rb
DEPLOY/rails/blognet/app/config/initializers/filter_parameter_logging.rb
DEPLOY/rails/blognet/app/config/initializers/inflections.rb
DEPLOY/rails/blognet/app/config/locales/en.yml
DEPLOY/rails/blognet/app/config/puma.rb
DEPLOY/rails/blognet/app/config/queue.yml
DEPLOY/rails/blognet/app/config/recurring.yml
DEPLOY/rails/blognet/app/config/routes.rb
DEPLOY/rails/blognet/app/config/storage.yml
DEPLOY/rails/blognet/app/db/cable_schema.rb
DEPLOY/rails/blognet/app/db/cache_schema.rb
DEPLOY/rails/blognet/app/db/migrate/20260501020807_create_users.rb
DEPLOY/rails/blognet/app/db/migrate/20260501020818_create_sessions.rb
DEPLOY/rails/blognet/app/db/migrate/20260501020848_create_active_storage_tables.active_storage.rb
DEPLOY/rails/blognet/app/db/migrate/20260501020920_create_action_text_tables.action_text.rb
DEPLOY/rails/blognet/app/db/migrate/20260507120001_create_blogs.rb
DEPLOY/rails/blognet/app/db/migrate/20260507120002_create_posts.rb
DEPLOY/rails/blognet/app/db/migrate/20260507120003_create_categories.rb
DEPLOY/rails/blognet/app/db/migrate/20260507120004_create_categorizations.rb
DEPLOY/rails/blognet/app/db/migrate/20260507120005_create_comments.rb
DEPLOY/rails/blognet/app/db/migrate/20260507120006_create_tags.rb
DEPLOY/rails/blognet/app/db/migrate/20260507120007_create_taggings.rb
DEPLOY/rails/blognet/app/db/queue_schema.rb
DEPLOY/rails/blognet/app/db/schema.rb
DEPLOY/rails/blognet/app/db/seeds.rb
DEPLOY/rails/blognet/app/public/robots.txt
DEPLOY/rails/blognet/blognet.sh
DEPLOY/rails/blognet/blognet_test.sh
DEPLOY/rails/brgen/README.md
DEPLOY/rails/brgen/README_takeaway.md
DEPLOY/rails/brgen/README_tv.md
DEPLOY/rails/brgen/app/Dockerfile
DEPLOY/rails/brgen/app/Gemfile
DEPLOY/rails/brgen/app/README.md
DEPLOY/rails/brgen/app/Rakefile
DEPLOY/rails/brgen/app/app/channels/application_cable/channel.rb
DEPLOY/rails/brgen/app/app/channels/application_cable/connection.rb
DEPLOY/rails/brgen/app/app/controllers/application_controller.rb
DEPLOY/rails/brgen/app/app/controllers/comments_controller.rb
DEPLOY/rails/brgen/app/app/controllers/communities_controller.rb
DEPLOY/rails/brgen/app/app/controllers/concerns/authentication.rb
DEPLOY/rails/brgen/app/app/controllers/conversations_controller.rb
DEPLOY/rails/brgen/app/app/controllers/dating/base_controller.rb
DEPLOY/rails/brgen/app/app/controllers/dating/dislikes_controller.rb
DEPLOY/rails/brgen/app/app/controllers/dating/home_controller.rb
DEPLOY/rails/brgen/app/app/controllers/dating/likes_controller.rb
DEPLOY/rails/brgen/app/app/controllers/dating/matches_controller.rb
DEPLOY/rails/brgen/app/app/controllers/dating/profiles_controller.rb
DEPLOY/rails/brgen/app/app/controllers/follows_controller.rb
DEPLOY/rails/brgen/app/app/controllers/home_controller.rb
DEPLOY/rails/brgen/app/app/controllers/marketplace/base_controller.rb
DEPLOY/rails/brgen/app/app/controllers/marketplace/categories_controller.rb
DEPLOY/rails/brgen/app/app/controllers/marketplace/listings_controller.rb
DEPLOY/rails/brgen/app/app/controllers/marketplace/orders_controller.rb
DEPLOY/rails/brgen/app/app/controllers/messages_controller.rb
DEPLOY/rails/brgen/app/app/controllers/passwords_controller.rb
DEPLOY/rails/brgen/app/app/controllers/playlist/base_controller.rb
DEPLOY/rails/brgen/app/app/controllers/playlist/listens_controller.rb
DEPLOY/rails/brgen/app/app/controllers/playlist/playlists_controller.rb
DEPLOY/rails/brgen/app/app/controllers/playlist/tracks_controller.rb
DEPLOY/rails/brgen/app/app/controllers/playlist_controller.rb
DEPLOY/rails/brgen/app/app/controllers/posts_controller.rb
DEPLOY/rails/brgen/app/app/controllers/sessions_controller.rb
DEPLOY/rails/brgen/app/app/controllers/takeaway/base_controller.rb
DEPLOY/rails/brgen/app/app/controllers/takeaway/menu_items_controller.rb
DEPLOY/rails/brgen/app/app/controllers/takeaway/orders_controller.rb
DEPLOY/rails/brgen/app/app/controllers/takeaway/restaurants_controller.rb
DEPLOY/rails/brgen/app/app/controllers/tv/base_controller.rb
DEPLOY/rails/brgen/app/app/controllers/tv/channels_controller.rb
DEPLOY/rails/brgen/app/app/controllers/tv/home_controller.rb
DEPLOY/rails/brgen/app/app/controllers/tv/videos_controller.rb
DEPLOY/rails/brgen/app/app/controllers/typing_indicators_controller.rb
DEPLOY/rails/brgen/app/app/controllers/votes_controller.rb
DEPLOY/rails/brgen/app/app/helpers/application_helper.rb
DEPLOY/rails/brgen/app/app/javascript/application.js
DEPLOY/rails/brgen/app/app/javascript/controllers/animated_number_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/application.js
DEPLOY/rails/brgen/app/app/javascript/controllers/auto_submit_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/character_counter_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/clipboard_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/dialog_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/dropdown_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/hello_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/index.js
DEPLOY/rails/brgen/app/app/javascript/controllers/notification_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/sortable_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/textarea_autogrow_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/timeago_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/typing_controller.js
DEPLOY/rails/brgen/app/app/javascript/controllers/typing_input_controller.js
DEPLOY/rails/brgen/app/app/jobs/application_job.rb
DEPLOY/rails/brgen/app/app/mailers/application_mailer.rb
DEPLOY/rails/brgen/app/app/mailers/passwords_mailer.rb
DEPLOY/rails/brgen/app/app/models/application_record.rb
DEPLOY/rails/brgen/app/app/models/comment.rb
DEPLOY/rails/brgen/app/app/models/community.rb
DEPLOY/rails/brgen/app/app/models/concerns/commentable.rb
DEPLOY/rails/brgen/app/app/models/concerns/mentionable.rb
DEPLOY/rails/brgen/app/app/models/concerns/taggable.rb
DEPLOY/rails/brgen/app/app/models/concerns/votable.rb
DEPLOY/rails/brgen/app/app/models/conversation.rb
DEPLOY/rails/brgen/app/app/models/conversation_participant.rb
DEPLOY/rails/brgen/app/app/models/current.rb
DEPLOY/rails/brgen/app/app/models/dating.rb
DEPLOY/rails/brgen/app/app/models/dating/dislike.rb
DEPLOY/rails/brgen/app/app/models/dating/like.rb
DEPLOY/rails/brgen/app/app/models/dating/match.rb
DEPLOY/rails/brgen/app/app/models/dating/profile.rb
DEPLOY/rails/brgen/app/app/models/follow.rb
DEPLOY/rails/brgen/app/app/models/hashtag.rb
DEPLOY/rails/brgen/app/app/models/marketplace.rb
DEPLOY/rails/brgen/app/app/models/marketplace/category.rb
DEPLOY/rails/brgen/app/app/models/marketplace/listing.rb
DEPLOY/rails/brgen/app/app/models/marketplace/order.rb
DEPLOY/rails/brgen/app/app/models/mention.rb
DEPLOY/rails/brgen/app/app/models/message.rb
DEPLOY/rails/brgen/app/app/models/message_receipt.rb
DEPLOY/rails/brgen/app/app/models/playlist.rb
DEPLOY/rails/brgen/app/app/models/playlist/listen.rb
DEPLOY/rails/brgen/app/app/models/playlist/playlist.rb
DEPLOY/rails/brgen/app/app/models/playlist/playlist_track.rb
DEPLOY/rails/brgen/app/app/models/playlist/track.rb
DEPLOY/rails/brgen/app/app/models/post.rb
DEPLOY/rails/brgen/app/app/models/reaction.rb
DEPLOY/rails/brgen/app/app/models/session.rb
DEPLOY/rails/brgen/app/app/models/stream.rb
DEPLOY/rails/brgen/app/app/models/tagging.rb
DEPLOY/rails/brgen/app/app/models/takeaway.rb
DEPLOY/rails/brgen/app/app/models/takeaway/menu_item.rb
DEPLOY/rails/brgen/app/app/models/takeaway/order.rb
DEPLOY/rails/brgen/app/app/models/takeaway/order_item.rb
DEPLOY/rails/brgen/app/app/models/takeaway/restaurant.rb
DEPLOY/rails/brgen/app/app/models/tv.rb
DEPLOY/rails/brgen/app/app/models/tv/broadcast.rb
DEPLOY/rails/brgen/app/app/models/tv/channel.rb
DEPLOY/rails/brgen/app/app/models/tv/subscription.rb
DEPLOY/rails/brgen/app/app/models/tv/video.rb
DEPLOY/rails/brgen/app/app/models/tv/view_event.rb
DEPLOY/rails/brgen/app/app/models/typing_indicator.rb
DEPLOY/rails/brgen/app/app/models/user.rb
DEPLOY/rails/brgen/app/app/models/vote.rb
DEPLOY/rails/brgen/app/app/services/scrape.rb
DEPLOY/rails/brgen/app/app/views/comments/_comment.html.erb
DEPLOY/rails/brgen/app/app/views/communities/index.html.erb
DEPLOY/rails/brgen/app/app/views/communities/new.html.erb
DEPLOY/rails/brgen/app/app/views/communities/show.html.erb
DEPLOY/rails/brgen/app/app/views/conversations/index.html.erb
DEPLOY/rails/brgen/app/app/views/conversations/show.html.erb
DEPLOY/rails/brgen/app/app/views/dating/home/index.html.erb
DEPLOY/rails/brgen/app/app/views/dating/matches/index.html.erb
DEPLOY/rails/brgen/app/app/views/dating/profiles/edit.html.erb
DEPLOY/rails/brgen/app/app/views/dating/profiles/new.html.erb
DEPLOY/rails/brgen/app/app/views/dating/profiles/show.html.erb
DEPLOY/rails/brgen/app/app/views/home/index.html.erb
DEPLOY/rails/brgen/app/app/views/layouts/application.html.erb
DEPLOY/rails/brgen/app/app/views/layouts/mailer.html.erb
DEPLOY/rails/brgen/app/app/views/layouts/mailer.text.erb
DEPLOY/rails/brgen/app/app/views/marketplace/categories/show.html.erb
DEPLOY/rails/brgen/app/app/views/marketplace/listings/edit.html.erb
DEPLOY/rails/brgen/app/app/views/marketplace/listings/index.html.erb
DEPLOY/rails/brgen/app/app/views/marketplace/listings/new.html.erb
DEPLOY/rails/brgen/app/app/views/marketplace/listings/show.html.erb
DEPLOY/rails/brgen/app/app/views/messages/_message.html.erb
DEPLOY/rails/brgen/app/app/views/messages/create.turbo_stream.erb
DEPLOY/rails/brgen/app/app/views/messages/new.html.erb
DEPLOY/rails/brgen/app/app/views/passwords/edit.html.erb
DEPLOY/rails/brgen/app/app/views/passwords/new.html.erb
DEPLOY/rails/brgen/app/app/views/passwords_mailer/reset.html.erb
DEPLOY/rails/brgen/app/app/views/passwords_mailer/reset.text.erb
DEPLOY/rails/brgen/app/app/views/playlist/index.html.erb
DEPLOY/rails/brgen/app/app/views/playlist/playlists/edit.html.erb
DEPLOY/rails/brgen/app/app/views/playlist/playlists/index.html.erb
DEPLOY/rails/brgen/app/app/views/playlist/playlists/new.html.erb
DEPLOY/rails/brgen/app/app/views/playlist/playlists/show.html.erb
DEPLOY/rails/brgen/app/app/views/posts/_post.html.erb
DEPLOY/rails/brgen/app/app/views/posts/index.html.erb
DEPLOY/rails/brgen/app/app/views/posts/new.html.erb
DEPLOY/rails/brgen/app/app/views/posts/show.html.erb
DEPLOY/rails/brgen/app/app/views/pwa/manifest.json.erb
DEPLOY/rails/brgen/app/app/views/pwa/service-worker.js
DEPLOY/rails/brgen/app/app/views/sessions/new.html.erb
DEPLOY/rails/brgen/app/app/views/shared/_vote.html.erb
DEPLOY/rails/brgen/app/app/views/takeaway/orders/index.html.erb
DEPLOY/rails/brgen/app/app/views/takeaway/orders/show.html.erb
DEPLOY/rails/brgen/app/app/views/takeaway/restaurants/edit.html.erb
DEPLOY/rails/brgen/app/app/views/takeaway/restaurants/index.html.erb
DEPLOY/rails/brgen/app/app/views/takeaway/restaurants/new.html.erb
DEPLOY/rails/brgen/app/app/views/takeaway/restaurants/show.html.erb
DEPLOY/rails/brgen/app/app/views/tv/channels/edit.html.erb
DEPLOY/rails/brgen/app/app/views/tv/channels/index.html.erb
DEPLOY/rails/brgen/app/app/views/tv/channels/new.html.erb
DEPLOY/rails/brgen/app/app/views/tv/channels/show.html.erb
DEPLOY/rails/brgen/app/app/views/tv/home/index.html.erb
DEPLOY/rails/brgen/app/app/views/tv/videos/_tv_video.html.erb
DEPLOY/rails/brgen/app/app/views/tv/videos/new.html.erb
DEPLOY/rails/brgen/app/app/views/tv/videos/show.html.erb
DEPLOY/rails/brgen/app/app/views/typing_indicators/_indicator.html.erb
DEPLOY/rails/brgen/app/app/views/votes/create.turbo_stream.erb
DEPLOY/rails/brgen/app/config/application.rb
DEPLOY/rails/brgen/app/config/boot.rb
DEPLOY/rails/brgen/app/config/bundler-audit.yml
DEPLOY/rails/brgen/app/config/cable.yml
DEPLOY/rails/brgen/app/config/cache.yml
DEPLOY/rails/brgen/app/config/ci.rb
DEPLOY/rails/brgen/app/config/database.yml
DEPLOY/rails/brgen/app/config/deploy.yml
DEPLOY/rails/brgen/app/config/environment.rb
DEPLOY/rails/brgen/app/config/environments/development.rb
DEPLOY/rails/brgen/app/config/environments/production.rb
DEPLOY/rails/brgen/app/config/environments/test.rb
DEPLOY/rails/brgen/app/config/falcon.rb
DEPLOY/rails/brgen/app/config/importmap.rb
DEPLOY/rails/brgen/app/config/initializers/assets.rb
DEPLOY/rails/brgen/app/config/initializers/content_security_policy.rb
DEPLOY/rails/brgen/app/config/initializers/filter_parameter_logging.rb
DEPLOY/rails/brgen/app/config/initializers/inflections.rb
DEPLOY/rails/brgen/app/config/locales/en.yml
DEPLOY/rails/brgen/app/config/puma.rb
DEPLOY/rails/brgen/app/config/queue.yml
DEPLOY/rails/brgen/app/config/recurring.yml
DEPLOY/rails/brgen/app/config/routes.rb
DEPLOY/rails/brgen/app/config/storage.yml
DEPLOY/rails/brgen/app/db/cable_schema.rb
DEPLOY/rails/brgen/app/db/cache_schema.rb
DEPLOY/rails/brgen/app/db/migrate/20260311162114_create_users.rb
DEPLOY/rails/brgen/app/db/migrate/20260311162121_create_sessions.rb
DEPLOY/rails/brgen/app/db/migrate/20260311162206_create_communities.rb
DEPLOY/rails/brgen/app/db/migrate/20260311162227_create_reactions.rb
DEPLOY/rails/brgen/app/db/migrate/20260311162235_create_streams.rb
DEPLOY/rails/brgen/app/db/migrate/20260311162345_create_posts.rb
DEPLOY/rails/brgen/app/db/migrate/20260311162350_create_comments.rb
DEPLOY/rails/brgen/app/db/migrate/20260311162355_add_fields_to_users.rb
DEPLOY/rails/brgen/app/db/migrate/20260311163039_create_votes.rb
DEPLOY/rails/brgen/app/db/migrate/20260311163634_create_follows.rb
DEPLOY/rails/brgen/app/db/migrate/20260311163641_create_hashtags.rb
DEPLOY/rails/brgen/app/db/migrate/20260311163648_create_taggings.rb
DEPLOY/rails/brgen/app/db/migrate/20260311163655_create_mentions.rb
DEPLOY/rails/brgen/app/db/migrate/20260311164112_create_conversations.rb
DEPLOY/rails/brgen/app/db/migrate/20260311164119_create_conversation_participants.rb
DEPLOY/rails/brgen/app/db/migrate/20260311164127_create_messages.rb
DEPLOY/rails/brgen/app/db/migrate/20260311164134_create_message_receipts.rb
DEPLOY/rails/brgen/app/db/migrate/20260311164141_create_typing_indicators.rb
DEPLOY/rails/brgen/app/db/migrate/20260311165000_add_guest_to_users.rb
DEPLOY/rails/brgen/app/db/migrate/20260311221744_add_user_description_to_communities.rb
DEPLOY/rails/brgen/app/db/migrate/20260505002649_create_tv_channels.rb
DEPLOY/rails/brgen/app/db/migrate/20260505002659_create_tv_videos.rb
DEPLOY/rails/brgen/app/db/migrate/20260505002711_create_tv_broadcasts.rb
DEPLOY/rails/brgen/app/db/migrate/20260505002719_create_tv_subscriptions.rb
DEPLOY/rails/brgen/app/db/migrate/20260505002729_create_tv_view_events.rb
DEPLOY/rails/brgen/app/db/migrate/20260505014447_create_dating_profiles.rb
DEPLOY/rails/brgen/app/db/migrate/20260505014452_create_dating_likes.rb
DEPLOY/rails/brgen/app/db/migrate/20260505014457_create_dating_dislikes.rb
DEPLOY/rails/brgen/app/db/migrate/20260505014503_create_dating_matches.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015400_create_playlist_playlists.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015406_create_playlist_tracks.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015411_create_playlist_playlist_tracks.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015416_create_playlist_listens.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015440_create_takeaway_restaurants.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015446_create_takeaway_menu_items.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015451_create_takeaway_orders.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015456_create_takeaway_order_items.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015518_create_marketplace_categories.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015523_create_marketplace_listings.rb
DEPLOY/rails/brgen/app/db/migrate/20260505015530_create_marketplace_orders.rb
DEPLOY/rails/brgen/app/db/queue_schema.rb
DEPLOY/rails/brgen/app/db/schema.rb
DEPLOY/rails/brgen/app/db/seeds.rb
DEPLOY/rails/brgen/app/public/robots.txt
DEPLOY/rails/brgen/app/test/test_helper.rb
DEPLOY/rails/brgen/brgen.sh
DEPLOY/rails/brgen/subapps/dating/README.md
DEPLOY/rails/brgen/subapps/marketplace/README.md
DEPLOY/rails/brgen/subapps/playlist/README.md
DEPLOY/rails/brgen/subapps/takeaway/README.md
DEPLOY/rails/brgen/subapps/tv/README.md
DEPLOY/rails/bsdports/README.md
DEPLOY/rails/bsdports/app/Dockerfile
DEPLOY/rails/bsdports/app/Gemfile
DEPLOY/rails/bsdports/app/README.md
DEPLOY/rails/bsdports/app/Rakefile
DEPLOY/rails/bsdports/app/app/controllers/application_controller.rb
DEPLOY/rails/bsdports/app/app/controllers/categories_controller.rb
DEPLOY/rails/bsdports/app/app/controllers/comments_controller.rb
DEPLOY/rails/bsdports/app/app/controllers/concerns/authentication.rb
DEPLOY/rails/bsdports/app/app/controllers/passwords_controller.rb
DEPLOY/rails/bsdports/app/app/controllers/ports_controller.rb
DEPLOY/rails/bsdports/app/app/controllers/sessions_controller.rb
DEPLOY/rails/bsdports/app/app/helpers/application_helper.rb
DEPLOY/rails/bsdports/app/app/javascript/application.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/animated_number_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/application.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/auto_submit_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/character_counter_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/clipboard_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/dialog_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/dropdown_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/hello_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/index.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/notification_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/sortable_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/textarea_autogrow_controller.js
DEPLOY/rails/bsdports/app/app/javascript/controllers/timeago_controller.js
DEPLOY/rails/bsdports/app/app/jobs/application_job.rb
DEPLOY/rails/bsdports/app/app/mailers/application_mailer.rb
DEPLOY/rails/bsdports/app/app/models/application_record.rb
DEPLOY/rails/bsdports/app/app/models/category.rb
DEPLOY/rails/bsdports/app/app/models/comment.rb
DEPLOY/rails/bsdports/app/app/models/current.rb
DEPLOY/rails/bsdports/app/app/models/dependency.rb
DEPLOY/rails/bsdports/app/app/models/port.rb
DEPLOY/rails/bsdports/app/app/models/port_update.rb
DEPLOY/rails/bsdports/app/app/models/session.rb
DEPLOY/rails/bsdports/app/app/models/user.rb
DEPLOY/rails/bsdports/app/app/models/watch.rb
DEPLOY/rails/bsdports/app/app/views/categories/index.html.erb
DEPLOY/rails/bsdports/app/app/views/categories/show.html.erb
DEPLOY/rails/bsdports/app/app/views/comments/_comment.html.erb
DEPLOY/rails/bsdports/app/app/views/layouts/application.html.erb
DEPLOY/rails/bsdports/app/app/views/layouts/mailer.html.erb
DEPLOY/rails/bsdports/app/app/views/layouts/mailer.text.erb
DEPLOY/rails/bsdports/app/app/views/ports/index.html.erb
DEPLOY/rails/bsdports/app/app/views/ports/show.html.erb
DEPLOY/rails/bsdports/app/app/views/pwa/manifest.json.erb
DEPLOY/rails/bsdports/app/app/views/pwa/service-worker.js
DEPLOY/rails/bsdports/app/config/application.rb
DEPLOY/rails/bsdports/app/config/boot.rb
DEPLOY/rails/bsdports/app/config/bundler-audit.yml
DEPLOY/rails/bsdports/app/config/cable.yml
DEPLOY/rails/bsdports/app/config/ci.rb
DEPLOY/rails/bsdports/app/config/database.yml
DEPLOY/rails/bsdports/app/config/deploy.yml
DEPLOY/rails/bsdports/app/config/environment.rb
DEPLOY/rails/bsdports/app/config/environments/development.rb
DEPLOY/rails/bsdports/app/config/environments/production.rb
DEPLOY/rails/bsdports/app/config/environments/test.rb
DEPLOY/rails/bsdports/app/config/importmap.rb
DEPLOY/rails/bsdports/app/config/initializers/assets.rb
DEPLOY/rails/bsdports/app/config/initializers/content_security_policy.rb
DEPLOY/rails/bsdports/app/config/initializers/filter_parameter_logging.rb
DEPLOY/rails/bsdports/app/config/initializers/inflections.rb
DEPLOY/rails/bsdports/app/config/locales/en.yml
DEPLOY/rails/bsdports/app/config/puma.rb
DEPLOY/rails/bsdports/app/config/routes.rb
DEPLOY/rails/bsdports/app/config/storage.yml
DEPLOY/rails/bsdports/app/db/migrate/20260501020807_create_users.rb
DEPLOY/rails/bsdports/app/db/migrate/20260501020818_create_sessions.rb
DEPLOY/rails/bsdports/app/db/migrate/20260507120001_create_categories.rb
DEPLOY/rails/bsdports/app/db/migrate/20260507120002_create_ports.rb
DEPLOY/rails/bsdports/app/db/migrate/20260507120003_create_dependencies.rb
DEPLOY/rails/bsdports/app/db/migrate/20260507120004_create_port_updates.rb
DEPLOY/rails/bsdports/app/db/migrate/20260507120005_create_watches.rb
DEPLOY/rails/bsdports/app/db/migrate/20260507120006_create_comments.rb
DEPLOY/rails/bsdports/app/db/seeds.rb
DEPLOY/rails/bsdports/app/public/robots.txt
DEPLOY/rails/bsdports/bsdports.sh
DEPLOY/rails/bsdports/bsdports_test.sh
DEPLOY/rails/check_ports.sh
DEPLOY/rails/demo.sh
DEPLOY/rails/hjerterom/README.md
DEPLOY/rails/hjerterom/app/Dockerfile
DEPLOY/rails/hjerterom/app/Gemfile
DEPLOY/rails/hjerterom/app/README.md
DEPLOY/rails/hjerterom/app/Rakefile
DEPLOY/rails/hjerterom/app/app/controllers/application_controller.rb
DEPLOY/rails/hjerterom/app/app/controllers/community_controller.rb
DEPLOY/rails/hjerterom/app/app/controllers/concerns/authentication.rb
DEPLOY/rails/hjerterom/app/app/controllers/food_listings_controller.rb
DEPLOY/rails/hjerterom/app/app/controllers/food_requests_controller.rb
DEPLOY/rails/hjerterom/app/app/controllers/home_controller.rb
DEPLOY/rails/hjerterom/app/app/controllers/passwords_controller.rb
DEPLOY/rails/hjerterom/app/app/controllers/resources_controller.rb
DEPLOY/rails/hjerterom/app/app/controllers/sessions_controller.rb
DEPLOY/rails/hjerterom/app/app/helpers/application_helper.rb
DEPLOY/rails/hjerterom/app/app/javascript/application.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/animated_number_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/application.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/auto_submit_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/character_counter_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/clipboard_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/dialog_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/dropdown_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/hello_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/index.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/notification_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/sortable_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/textarea_autogrow_controller.js
DEPLOY/rails/hjerterom/app/app/javascript/controllers/timeago_controller.js
DEPLOY/rails/hjerterom/app/app/jobs/application_job.rb
DEPLOY/rails/hjerterom/app/app/mailers/application_mailer.rb
DEPLOY/rails/hjerterom/app/app/models/application_record.rb
DEPLOY/rails/hjerterom/app/app/models/category.rb
DEPLOY/rails/hjerterom/app/app/models/comment.rb
DEPLOY/rails/hjerterom/app/app/models/crisis.rb
DEPLOY/rails/hjerterom/app/app/models/current.rb
DEPLOY/rails/hjerterom/app/app/models/food_listing.rb
DEPLOY/rails/hjerterom/app/app/models/food_request.rb
DEPLOY/rails/hjerterom/app/app/models/post.rb
DEPLOY/rails/hjerterom/app/app/models/resource.rb
DEPLOY/rails/hjerterom/app/app/models/session.rb
DEPLOY/rails/hjerterom/app/app/models/support_request.rb
DEPLOY/rails/hjerterom/app/app/models/user.rb
DEPLOY/rails/hjerterom/app/app/views/community/index.html.erb
DEPLOY/rails/hjerterom/app/app/views/community/new.html.erb
DEPLOY/rails/hjerterom/app/app/views/community/show.html.erb
DEPLOY/rails/hjerterom/app/app/views/food_listings/_form.html.erb
DEPLOY/rails/hjerterom/app/app/views/food_listings/edit.html.erb
DEPLOY/rails/hjerterom/app/app/views/food_listings/index.html.erb
DEPLOY/rails/hjerterom/app/app/views/food_listings/new.html.erb
DEPLOY/rails/hjerterom/app/app/views/food_listings/show.html.erb
DEPLOY/rails/hjerterom/app/app/views/home/index.html.erb
DEPLOY/rails/hjerterom/app/app/views/layouts/application.html.erb
DEPLOY/rails/hjerterom/app/app/views/layouts/mailer.html.erb
DEPLOY/rails/hjerterom/app/app/views/layouts/mailer.text.erb
DEPLOY/rails/hjerterom/app/app/views/pwa/manifest.json.erb
DEPLOY/rails/hjerterom/app/app/views/pwa/service-worker.js
DEPLOY/rails/hjerterom/app/app/views/resources/_form.html.erb
DEPLOY/rails/hjerterom/app/app/views/resources/edit.html.erb
DEPLOY/rails/hjerterom/app/app/views/resources/index.html.erb
DEPLOY/rails/hjerterom/app/app/views/resources/new.html.erb
DEPLOY/rails/hjerterom/app/app/views/resources/show.html.erb
DEPLOY/rails/hjerterom/app/config/application.rb
DEPLOY/rails/hjerterom/app/config/boot.rb
DEPLOY/rails/hjerterom/app/config/bundler-audit.yml
DEPLOY/rails/hjerterom/app/config/cable.yml
DEPLOY/rails/hjerterom/app/config/ci.rb
DEPLOY/rails/hjerterom/app/config/database.yml
DEPLOY/rails/hjerterom/app/config/deploy.yml
DEPLOY/rails/hjerterom/app/config/environment.rb
DEPLOY/rails/hjerterom/app/config/environments/development.rb
DEPLOY/rails/hjerterom/app/config/environments/production.rb
DEPLOY/rails/hjerterom/app/config/environments/test.rb
DEPLOY/rails/hjerterom/app/config/importmap.rb
DEPLOY/rails/hjerterom/app/config/initializers/assets.rb
DEPLOY/rails/hjerterom/app/config/initializers/content_security_policy.rb
DEPLOY/rails/hjerterom/app/config/initializers/filter_parameter_logging.rb
DEPLOY/rails/hjerterom/app/config/initializers/inflections.rb
DEPLOY/rails/hjerterom/app/config/locales/en.yml
DEPLOY/rails/hjerterom/app/config/puma.rb
DEPLOY/rails/hjerterom/app/config/routes.rb
DEPLOY/rails/hjerterom/app/config/storage.yml
DEPLOY/rails/hjerterom/app/db/migrate/20260501020807_create_users.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260501020818_create_sessions.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260507120001_create_categories.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260507120002_create_resources.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260507120003_create_crises.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260507120004_create_food_listings.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260507120005_create_food_requests.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260507120006_create_posts.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260507120007_create_comments.rb
DEPLOY/rails/hjerterom/app/db/migrate/20260507120008_create_support_requests.rb
DEPLOY/rails/hjerterom/app/db/seeds.rb
DEPLOY/rails/hjerterom/app/public/robots.txt
DEPLOY/rails/hjerterom/hjerterom.sh
DEPLOY/rails/modernize_zsh.sh
DEPLOY/rails/rich_editor_system.sh
DEPLOY/repligen.rb
Gemfile
README.md
Rakefile
data/agent_taxonomy.yml
data/audit_signature.yml
data/budget.yml
data/canvas.yml
data/canvas_routes.yml
data/cdp_browser.yml
data/claude/MEMORY.md
data/claude/feedback_autofix.md
data/claude/feedback_autoproceed.md
data/claude/feedback_comments_reassess.md
data/claude/feedback_decisive_signals.md
data/claude/feedback_device_limits.md
data/claude/feedback_diverged_branch_sync.md
data/claude/feedback_git_commits.md
data/claude/feedback_html_css_style.md
data/claude/feedback_importance_order.md
data/claude/feedback_lint_beautify.md
data/claude/feedback_master_zsh_discipline.md
data/claude/feedback_meta_framing.md
data/claude/feedback_no_consecutive_whitespace.md
data/claude/feedback_no_new_files.md
data/claude/feedback_no_permission_questions.md
data/claude/feedback_no_python.md
data/claude/feedback_no_sed.md
data/claude/feedback_no_shell_piping.md
data/claude/feedback_proper_casing.md
data/claude/feedback_readme_autoupdate.md
data/claude/feedback_restart_rails.md
data/claude/feedback_run_through_master_triad.md
data/claude/feedback_strunk_white.md
data/claude/feedback_style.md
data/claude/feedback_voice_terse_unix.md
data/claude/project_defrag_plan_2026_05.md
data/claude/project_falcon_em_subprocess.md
data/claude/project_master.md
data/claude/project_master_dual_gemfile.md
data/claude/project_master_seven_module_refactor.md
data/claude/project_master_yml_json_authority.md
data/claude/reference_grok_ui_cli_patterns.md
data/claude/reference_opencrabs.md
data/claude/user_architect_aesthetics.md
data/closings.yml
data/compression.yml
data/council.yml
data/exemplars.yml
data/heartbeat.yml
data/infer_patterns.yml
data/injection_patterns.yml
data/lexical_rules.yml
data/manifest.yml
data/mcp_servers.yml
data/models.yml
data/openbsd.yml
data/patterns.yml
data/personas.yml
data/pipeline.yml
data/platform.yml
data/playbooks.yml
data/prompt_vault.yml
data/prompts/mode_direct.yml
data/prompts/mode_react.yml
data/prompts/mode_rewoo.yml
data/refusal_templates.yml
data/ruby_style.yml
data/rules.yml
data/session_recovery.yml
data/social.yml
data/soul.yml
data/standing_orders.yml
data/sweep_prompts.yml
data/templates.yml
data/tools.yml
data/transient_errors.yml
data/voice_channels.yml
data/why_command.yml
data/workflow.yml
lib/master.rb
lib/master/agent.rb
lib/master/agent/llm_dispatcher.rb
lib/master/agent_pool.rb
lib/master/audit_log.rb
lib/master/autoloop.rb
lib/master/autoloop/fix_evaluator.rb
lib/master/axioms.rb
lib/master/bedrock_stub.rb
lib/master/builder.rb
lib/master/builder/infra_helpers.rb
lib/master/circuit_breaker.rb
lib/master/circuit_breaker_registry.rb
lib/master/cli.rb
lib/master/cli/signals.rb
lib/master/code_index.rb
lib/master/code_index/symbol_visitor.rb
lib/master/command_registry.rb
lib/master/command_registry/agent_commands.rb
lib/master/command_registry/memory_commands.rb
lib/master/command_registry/service_commands.rb
lib/master/config.rb
lib/master/context_window.rb
lib/master/council/deliberation.rb
lib/master/council/ideation.rb
lib/master/council/personas.rb
lib/master/decision_engine.rb
lib/master/diag.rb
lib/master/diff_stager.rb
lib/master/embeddings.rb
lib/master/event_bus.rb
lib/master/gateway.rb
lib/master/git_operations.rb
lib/master/governor.rb
lib/master/heartbeat.rb
lib/master/homeostat.rb
lib/master/hot_reload.rb
lib/master/introspection/self_map.rb
lib/master/learnings.rb
lib/master/learnings_pattern_lib.rb
lib/master/logging.rb
lib/master/mcp_coordinator.rb
lib/master/memory.rb
lib/master/memory/search.rb
lib/master/metrics.rb
lib/master/orders/architecture_audit.rb
lib/master/orders/autocommit.rb
lib/master/orders/base.rb
lib/master/orders/registry.rb
lib/master/orders/restart_master.rb
lib/master/orient.rb
lib/master/persistence/sqlite_findings.rb
lib/master/persistence/sqlite_memory.rb
lib/master/personality.rb
lib/master/phase_gates.rb
lib/master/pipeline.rb
lib/master/pipeline_dag.rb
lib/master/pledge.rb
lib/master/reasoning/modes.rb
lib/master/reflexion.rb
lib/master/renderer.rb
lib/master/repo_map.rb
lib/master/result.rb
lib/master/ring_buffer.rb
lib/master/routing/model_router.rb
lib/master/ruby_llm_patch.rb
lib/master/scan/rule.rb
lib/master/scan/rules/adversarial_rule.rb
lib/master/scan/rules/anti_pattern_rule.rb
lib/master/scan/rules/arity_rule.rb
lib/master/scan/rules/axiom_coverage_rule.rb
lib/master/scan/rules/bare_rescue_rule.rb
lib/master/scan/rules/co_change_coupling_rule.rb
lib/master/scan/rules/comment_drift_rule.rb
lib/master/scan/rules/comment_quality_rule.rb
lib/master/scan/rules/cqs_rule.rb
lib/master/scan/rules/dead_assign_rule.rb
lib/master/scan/rules/dead_code_rule.rb
lib/master/scan/rules/duplicate_code_rule.rb
lib/master/scan/rules/explicit_rule.rb
lib/master/scan/rules/file_layout_rule.rb
lib/master/scan/rules/file_silhouette_rule.rb
lib/master/scan/rules/god_class_rule.rb
lib/master/scan/rules/i18n_hardcoded_string_rule.rb
lib/master/scan/rules/immutable_rule.rb
lib/master/scan/rules/interconnect_rule.rb
lib/master/scan/rules/lexical_rule.rb
lib/master/scan/rules/long_method_rule.rb
lib/master/scan/rules/mass_assignment_risk_rule.rb
lib/master/scan/rules/memoize_falsy_bug_rule.rb
lib/master/scan/rules/n_plus_one_rule.rb
lib/master/scan/rules/naming_rule.rb
lib/master/scan/rules/naming_silhouette_rule.rb
lib/master/scan/rules/nesting_depth_rule.rb
lib/master/scan/rules/nielsen_rule.rb
lib/master/scan/rules/opportunity_rule.rb
lib/master/scan/rules/pola_rule.rb
lib/master/scan/rules/prune_rule.rb
lib/master/scan/rules/reek_rule.rb
lib/master/scan/rules/rubocop_rule.rb
lib/master/scan/rules/self_explaining_rule.rb
lib/master/scan/rules/semantic_opportunity_rule.rb
lib/master/scan/rules/semantic_rule.rb
lib/master/scan/rules/srp_rule.rb
lib/master/scan/rules/strict_loading_missing_rule.rb
lib/master/scan/rules/structure_rule.rb
lib/master/scan/rules/table_lexical_rule.rb
lib/master/scan/rules/tell_dont_ask_rule.rb
lib/master/scan/rules/terse_rule.rb
lib/master/scan/rules/thread_safety_rule.rb
lib/master/scan/rules/threshold_drift_rule.rb
lib/master/scan/rules/todo_debt_rule.rb
lib/master/scan/rules/universal_rule.rb
lib/master/scan/rules/vertical_rhythm_rule.rb
lib/master/scan/rules/yaml_quality_rule.rb
lib/master/scan/scanner.rb
lib/master/security/injection_guard.rb
lib/master/security/permissions.rb
lib/master/semantic_cache.rb
lib/master/session.rb
lib/master/skills.rb
lib/master/soul.rb
lib/master/speech.rb
lib/master/stages/council.rb
lib/master/stages/deliberate.rb
lib/master/stages/execute.rb
lib/master/stages/guard.rb
lib/master/stages/infer.rb
lib/master/stages/intake.rb
lib/master/stages/lint.rb
lib/master/stages/memo.rb
lib/master/stages/prune.rb
lib/master/stages/render.rb
lib/master/stages/route.rb
lib/master/standing_orders.rb
lib/master/swarm/coordinator.rb
lib/master/swarm/worker.rb
lib/master/swarm/workers/analyst.rb
lib/master/swarm/workers/coder.rb
lib/master/swarm/workers/researcher.rb
lib/master/swarm/workers/reviewer.rb
lib/master/sweep.rb
lib/master/sweep/convergence.rb
lib/master/sweep/rewriter.rb
lib/master/sweep/techniques.rb
lib/master/telemetry.rb
lib/master/text_hygiene.rb
lib/master/tools/ask_llm.rb
lib/master/tools/ast_edit.rb
lib/master/tools/atomic_write.rb
lib/master/tools/base.rb
lib/master/tools/batch_replace.rb
lib/master/tools/clean.rb
lib/master/tools/feedback_record.rb
lib/master/tools/git_context.rb
lib/master/tools/list_dir.rb
lib/master/tools/llm.rb
lib/master/tools/path_guard.rb
lib/master/tools/postpro.rb
lib/master/tools/read_file.rb
lib/master/tools/repligen.rb
lib/master/tools/search_files.rb
lib/master/tools/search_knowledge.rb
lib/master/tools/shell.rb
lib/master/tools/str_replace.rb
lib/master/tools/symbol_lookup.rb
lib/master/tools/tree.rb
lib/master/tools/web_fetch.rb
lib/master/tools/web_search.rb
lib/master/tools/write_file.rb
lib/master/trace.rb
lib/master/triggers.rb
lib/master/undo.rb
lib/master/unwrap_error.rb
lib/master/why_explainer.rb
master.gemspec
scripts/openbsd_preflight.zsh
skills/explain/SKILL.md
test/support/master_container.rb
test/test_agent.rb
test/test_axioms.rb
test/test_browser.rb
test/test_cli.rb
test/test_experience.rb
test/test_helper.rb
test/test_master_container.rb
test/test_pipeline.rb
test/test_prune.rb
test/test_result.rb
test/test_ring_buffer.rb
test/test_speech.rb
test/test_web_http.rb
test/test_web_ui.rb
web/Gemfile
web/README.md
web/Rakefile
web/app/controllers/application_controller.rb
web/app/controllers/canvas_controller.rb
web/app/controllers/chat_controller.rb
web/app/controllers/events_controller.rb
web/app/controllers/health_controller.rb
web/app/helpers/application_helper.rb
web/app/models/application_record.rb
web/app/views/canvas/show.html.erb
web/app/views/chat/index.html.erb
web/app/views/layouts/application.html.erb
web/app/views/pwa/manifest.json.erb
web/config/application.rb
web/config/boot.rb
web/config/ci.rb
web/config/database.yml
web/config/environment.rb
web/config/environments/development.rb
web/config/environments/production.rb
web/config/environments/test.rb
web/config/initializers/assets.rb
web/config/initializers/content_security_policy.rb
web/config/initializers/filter_parameter_logging.rb
web/config/initializers/inflections.rb
web/config/initializers/master_container.rb
web/config/initializers/new_framework_defaults_8_0.rb
web/config/locales/en.yml
web/config/puma.rb
web/config/routes.rb
web/db/seeds.rb
web/public/assets/rails-ujs-20eaf715.js
web/public/assets/rails-ujs.esm-e925103b.js
web/public/robots.txt
# MASTER — Conventions for External LLMs
Context injection for any LLM reviewing or editing MASTER. Read before touching code.
## Identity
MASTER is a constitutional AI coding agent written in Ruby 3.3+ on OpenBSD 7.8. It replaces Claude Code CLI for its operator. It is general-purpose and language-agnostic. Every change leaves the system in a working, deployable state.
## Golden rule
`PRESERVE_THEN_IMPROVE_NEVER_BREAK`. Read before write. Patch minimally. Understand before touching — Chesterton's Fence.
## Anti-simulation
Never state intent without evidence. Forbidden hedges — `will`, `would`, `could`, `might`. Require:
- File read → content with SHA-256
- Modification → unified diff
- Completion → command output
## Communication — two registers, do not mix
- **MASTER's own log/event lines** (boot banner, scheduler ticks, tool events, dmesg-style status): structured, terse, lowercase, kernel-ish — `master@host ready`, `boot0: 26ms`, `model0 at openrouter`. The OpenBSD-dmesg boot banner is sacred — never strip it.
- **Conversational replies to the operator**: plain English, proper casing, full sentences. No dmesg style here. No headlines, no empty bullets, no filler, no sycophancy, no hedging. Outcome first, evidence next, implementation last.
- **Commits and log lines** stay active, concrete, terse — Strunk & White, omit needless words.
## No ASCII line art
Never use these as decorations in any output (comments, log lines, CLI text, chat replies, commit messages):
- `===`, `----` (banner lines, section dividers)
- `•`, `|`, `›`, `‹` (bullet/separator characters)
- `[ok]`, `[err]`, `[skip]` brackets — use bare prefixes `ok:`, `err:`, `skip:`, `warn:` instead
In Markdown documents, plain `---` for an `<hr>` and table separators are fine — they carry meaning. Banner art does not.
## Code rules (enforced by scan)
- **Read before write** — every affected file before any edit.
- **No bare rescue** — always `rescue SpecificError => e`. Inline `expr rescue nil` is fine when nil is intentional.
- **Named constants** — extract literals with `.freeze`.
- **No magic numbers** — thresholds belong in `data/rules.yml` under `thresholds:`.
- **No abbreviations** — `index` not `idx`, `signature` not `sig`, `temporary_path` not `tmp`.
- **No regex when string methods suffice** — `start_with?`, `include?`, `end_with?`.
- **Outsource to gems** — if it exists and works, use it.
- **Endless methods** — single-expression methods use `def foo = expr`.
- **Result monad** — check with `respond_to?(:ok?)`, not `is_a?(Result)`. Unwrap with `.value!` only after `.ok?` is true; on an `Err` it raises.
- **No flag arguments** — a boolean that selects behavior is two methods in one.
- **Guard clauses first** — `return Result.ok(ctx) unless condition` before main logic.
- **Dependency injection** — never instantiate collaborators inside a method.
- **CQS** — queries return, commands mutate. Not both.
## Thresholds
- File — 300 lines max, warn at 200
- Method — 10 lines ideal, 7 warn
- Class — 6 public methods, 3 ivars, 200 lines
- Params — 3 positional max; keyword args for 3+
- Nesting — 2 levels max inside a method
## Ruby style
- `# frozen_string_literal: true` on every `.rb`
- Double-quoted strings always; single only inside regex or `'\1'` backrefs
- One-line comments. No YARD blocks, no section separators
- Comments explain WHY, never WHAT
- `snake_case` throughout
- Zeitwerk autoloading — file name matches class name
Bugs to avoid:
- `Dir.chdir` — process-wide, thread-unsafe. Use `File.expand_path`.
- `Prism.parse(src, freeze: true)` — `freeze:` dropped in 3.4. Use `Prism.parse(src)`.
- `next if` inside `flat_map` — returns `nil`. Use `next [] if`.
- Backtick shell with interpolation — use `Open3.capture2e(*%w[cmd], arg)`.
## Zsh / shell
Banned in zsh and SSH: `sed`, `awk`, `tr`, `grep`, `cut`, `head`, `tail`, `find`, `wc`, `sudo`, `perl`, `ruby`, `dd`, `xargs`. Use zsh builtins, parameter expansion, `doas` for privilege, Ruby scripts for complex logic.
Read files over SSH with `cat path` — read the whole file once. Do not stitch `grep` + `head` fragments; reasoning from full context beats reasoning from snippets. For local zsh array work use `lines=("${(@f)$(<file)}")`.
## Architecture
Pipeline: `Intake → Infer → Route → Guard → Execute → [Council ‖ Lint] → Prune → Memo → Render`. Council and Lint run concurrently under a 30s deadline via `ParallelGroup`. Rollback on `axiom_violation` or `validation`: `git reset --hard HEAD`. Scan rules auto-register via the `Rule.inherited` callback — every file under `scan/rules/` must subclass `Rule` or it goes silently unrun. Rules with no constructor args set `def auto_build? = true` to opt into the registry's zero-arg construction path. `axiom_coverage_rule` walks `scan/rules/*.rb` with a Prism `SuperclassFinder` and flags any file whose top-level class does not inherit from `Rule`, so silent registry drift is caught at scan time. All rules ship with `@auto_fix = true` and participate in sweep. Sweep runs rubocop autocorrect first, then escalates to LLM rewrite under the corruption guards.
Council deliberation samples a focus question per persona per turn from `data/council_questions.yml` (8 categories — assumptions, failure_modes, attacker, edge_cases, degradation, ops_maint, economics, clarity). Architect → assumptions, Skeptic → failure_modes, Security → attacker, User → edge_cases, Pragmatist → economics, Mentor → clarity. Unmapped personas pass through with no question.
Observability: `Master::Telemetry` is a soft-optional OpenTelemetry tracer that emits JSONL spans to `.master/traces.log`. Wraps `EventBus#publish`, `Metrics#append`, `AuditLog#append`, and `Heartbeat#execute_job`. Bootstrap fires in `Master.boot` between Pledge stage1 and stage2.
Key files — `data/soul.yml` (golden rule, tiers, persona), `data/rules.yml` (structural rules, thresholds, depths), `data/ruby_style.yml` (style and bugs), `data/workflow.yml` (READ_BEFORE_WRITE, scan principles), `data/standing_orders.yml` (current FSM state).
## Running scans
Standard: `eval "$(grep '^export' ~/.zshrc)" && cd ~/pub4/MASTER && echo "/scan lib/" | bundle exec ruby exe/master`. Deep: `/scan deep lib/`. Autofix sweep: `/autoloop 20`. Do not use external agents when MASTER can scan itself.
## Protection tiers
ABSOLUTE aborts the pipeline. PROTECTED emits a warning and continues. NEGOTIABLE allows if explicitly permitted. FLEXIBLE negotiates at runtime. ABSOLUTE sections in `data/soul.yml` require `/override` to amend.
## Environment
VPS: `dev@brgen.no` · `185.52.176.18` · OpenBSD 7.8 · passwordless `doas`. SSH: `sshpass -p 'h00te10tu' ssh -o StrictHostKeyChecking=no dev@185.52.176.18 'cmd'`. Non-interactive SSH must not source `.zshrc` — load env only: `eval "$(grep '^export' ~/.zshrc)"`.
Edit VPS files by direct edit + `scp` — write the new file content locally, scp it up. Reserve `~/pub4/tmp/patch.rb` for genuinely script-shaped edits where a patch script is the right tool. Never use `ruby -i` with heredoc — empties the file on script error.
After every scp under `MASTER/web/`, immediately `doas rcctl restart master` so Falcon picks up the change. Falcon does not hot-reload in production; without the restart the deployed app keeps serving the prior bytecode.
## Web auth tiers
`?token=...` matches the value in `~/pub4/.master/config.yml` and grants full tool access. No token = visitor — chat works, but `Thread.current[:master_visitor]` is set so `Master::Agent::LlmDispatch#build_llm_tools` filters tools to the visitor allow-list (currently `AskLlm`, `WebSearch`). The CLI REPL bypasses this entirely and always has full access.
## Slash commands
`/scan [profile] [path]`, `/sweep`, `/autoloop [N]`, `/council on|off`, `/swarm <role> <task>`, `/explain`, `/crit <file|text>`, `/ideate <prompt>`, `/topic`, `/rsi [stats]`, `/model [list|<id>]`, `/why <law|scan_rule|anti_pattern|style.key>`, `/diag [drives|breaker|rules|ring]`, `/snapshot`, `/tts`, `/profile`, `/heartbeat`, `/orders`, `/soul`, `/dmesg`. `/why` resolves locally first via `WhyExplainer`; the LLM answer fires only on a miss. `/diag` composes a state digest (drives, circuit-breaker, registered rule count, dmesg ring tail).# DEPLOY
Deploy scripts for all pub4 services on OpenBSD 7.8.
## Layout
DEPLOY/ openbsd/ Full VPS stack (pf, relayd, httpd, smtpd, nsd, masterweb) rails/ Rails app deploy scripts per project
## OpenBSD
Two-stage deploy — run from tmux:
```zsh
tmux new-session -d -s deploy "doas zsh DEPLOY/openbsd/openbsd.sh 2>&1 | tee /tmp/deploy.log"
Stage 1: DNS checks, TLS certs (acme-client), pkg_add. Stage 2: app installs, relayd config, rc.d services.
Resume interrupted run: doas zsh openbsd.sh --resume
Each subdirectory contains a deploy script for one app:
rails/
amber/ amber.sh
baibl/ baibl.sh
blognet/ blognet.sh
brgen/ brgen*.sh
bsdports/ bsdports.sh
hjerterom/ hjerterom.sh
privcam/ privcam.sh
__shared/ Common utilities and feature modules
## `DEPLOY/openbsd/README.md`
```markdown
# OpenBSD Deploy
Full VPS stack deploy for OpenBSD 7.8 at 185.52.176.18.
## Run
```zsh
cd ~/pub4/DEPLOY/openbsd
tmux new-session -d -s deploy "doas zsh openbsd.sh 2>&1 | tee /tmp/deploy.log"
tmux attach -t deploy
Resume after interruption: doas zsh openbsd.sh --resume
Stage 1 (DNS + TLS + packages):
- DNS validation for brgen.no, ai.brgen.no
- acme-client TLS certificates
- pkg_add: ruby, postgresql, redis, node
Stage 2 (services):
- pf firewall rules (ports 22, 25, 80, 443, 3000, 4430, 8080–8086)
- relayd TLS termination (443 → 53187, 4430 → 53187)
- httpd static file server
- smtpd mail server
- nsd authoritative DNS
- master rc.d service (127.0.0.1:53187)
- Rails apps under /home/dev/rails/
After deploy:
doas rcctl check master
doas pfctl -s rules
curl -sk https://ai.brgen.no:4430/chat/metrics
## `DEPLOY/openbsd/files/httpd.conf`
```text
# HTTP: ACME challenges + HTTP→HTTPS redirect (httpd.conf(5))
server "*" {
listen on 0.0.0.0 port 80
location "/.well-known/acme-challenge/*" {
root "/acme"
request strip 2
}
location * {
block return 301 "https://$HTTP_HOST$REQUEST_URI"
}
}
# Minimal PF for DNS in Stage 1 (pf.conf(5))
ext_if="vio0"
brgen_ip="$BRGEN_IP"
hyp_ip="$HYP_IP"
set skip on lo
pass in on \$ext_if inet proto { tcp, udp } to \$brgen_ip port 53
pass out on \$ext_if inet proto udp to \$hyp_ip port 53
# PF for DNS, HTTP/HTTPS, SSH, SMTP (pf.conf(5))
ext_if="vio0"
brgen_ip="$BRGEN_IP"
hyp_ip="$HYP_IP"
set skip on lo
set block-policy return
set loginterface \$ext_if
set reassemble yes
set limit { states 10000, frags 5000 }
block log all
scrub in all
table <bruteforce> persist
block quick from <bruteforce>
pass out quick on \$ext_if all
pass in on \$ext_if inet proto tcp to \$ext_if port 22 keep state \\
(max-src-conn 15, max-src-conn-rate 5/3, overload <bruteforce> flush global)
pass in on \$ext_if inet proto { tcp, udp } to \$brgen_ip port 53 log
pass in on \$ext_if inet proto tcp to \$brgen_ip port { 22, 25, 80, 443, 3000, 8080, 8082, 8084, 8086 } log
pass out on \$ext_if inet proto tcp to any port 25
#!/bin/ksh
# Certificate renewal script
generate_tlsa_record() {
typeset domain=$1
typeset cert=/etc/ssl/$domain.fullchain.pem
typeset zonefile=/var/nsd/zones/master/$domain.zone
typeset zsk=/var/nsd/zones/master/K$domain.+013+zsk.key
typeset ksk=/var/nsd/zones/master/K$domain.+013+ksk.key
[[ ! -f $cert ]] && return 1
typeset tlsa_record=$(openssl x509 -noout -pubkey -in "$cert" | \
openssl pkey -pubin -outform der 2>/dev/null | \
openssl dgst -sha256 2>/dev/null); tlsa_record=${tlsa_record##* }
[[ -z $tlsa_record ]] && return 1
typeset -a lines
lines=("${(@f)$(<$zonefile)}")
lines=("${(@)lines:#_443._tcp.$domain. IN TLSA*}")
print -rl -- $lines > "$zonefile"
print -r -- "_443._tcp.$domain. IN TLSA 3 1 1 $tlsa_record" >> "$zonefile"
ldns-signzone -n -p -s $(dd if=/dev/random bs=16 count=1 2>/dev/null | sha1 -q) "$zonefile" "$zsk" "$ksk"
nsd-control reload
}
ALL_DOMAINS=(
brgen.no longyearbyn.no oshlo.no stvanger.no trmso.no trndheim.no
reykjavk.is kbenhvn.dk gtebrg.se mlmoe.se stholm.se hlsinki.fi
brmingham.uk cardff.uk edinbrgh.uk glasgw.uk lndon.uk lverpool.uk
mnchester.uk amstrdam.nl rottrdam.nl utrcht.nl brssels.be zrich.ch
lchtenstein.li frankfrt.de brdeaux.fr mrseille.fr mlan.it lisbon.pt
wrsawa.pl gdnsk.pl austn.us chcago.us denvr.us dllas.us dnver.us
dtroit.us houstn.us lsangeles.com mnnesota.com newyrk.us prtland.com
wshingtondc.com pub.healthcare pub.attorney freehelp.legal
bsdports.org bsddocs.org discordb.org foodielicio.us
stacyspassion.com antibettingblog.com anticasinoblog.com
antigamblingblog.com foball.no
)
for domain in ${ALL_DOMAINS[@]}; do
if acme-client -v -f /etc/acme-client.conf "$domain"; then
echo "Renewed: $domain"
generate_tlsa_record "$domain"
fi
done
/usr/sbin/rcctl reload relayd# OpenSMTPD for outbound email (smtpd.conf(5))
table aliases file:/etc/mail/aliases
pki mail.pub.attorney cert "/etc/ssl/smtp.crt"
pki mail.pub.attorney key "/etc/ssl/private/smtp.key"
listen on $BRGEN_IP port 25 tls pki mail.pub.attorney
action "outbound" relay
match from local for any action "outbound"
#!/usr/bin/env zsh
# Configures OpenBSD 7.8 for NSD & DNSSEC, Ruby on Rails, PF firewall, and minimal OpenSMTPD.
# Usage: doas zsh openbsd.sh [--help | --resume]
#
# VERIFIED AGAINST: OpenBSD 7.8 manual pages (2026-01-06)
# - All configuration syntax validated against man.openbsd.org
# - smtpd.conf updated to OpenBSD 7.8 syntax (PKI-based TLS)
# - relayd.conf includes TLS keypair directives
# - pf.conf uses proper macro definitions
# - rc.d scripts follow proper rc.d(8) format
# - PostgreSQL and Redis removed (use SQLite or external DB)
# - Modern Zsh and OpenBSD security best practices applied
# - Inspired by structured thinking principles (unvalidated)
# - NOTE: pledge/unveil not applicable (C syscalls, not shell features)
# - Privilege control via doas(1), idempotent operations, atomic config writes
set +e # Don't use errexit - handle errors explicitly
setopt no_unset nullglob local_traps
zmodload zsh/regex
# Temporary files tracking
typeset -a TMPFILES
# Trap handlers for cleanup and errors
cleanup() {
typeset exit_code=$?
for tmpfile in "${TMPFILES[@]}"; do
[[ -n $tmpfile && -f $tmpfile ]] && rm -f "$tmpfile"
done
return $exit_code
}
error_handler() {
typeset exit_code=$1
typeset line_num=$2
log ERROR "Script failed with exit code $exit_code at line $line_num"
cleanup
exit $exit_code
}
trap 'cleanup' EXIT
trap 'error_handler $? $LINENO' INT TERM
# ERR trap: log unexpected exits
trap 'log ERROR "Script exited unexpectedly at line $LINENO with status $?"' ERR
# Convenience wrappers matching task spec
log_info() { log INFO "$@" }
log_error() { log ERROR "$@" }
# Step completion tracking
is_step_completed() {
[[ -f "${STATE_FILE}.steps" ]] && [[ $(<"${STATE_FILE}.steps") == *"$1"* ]]
}
mark_step_completed() {
print -r -- "$1" >> "${STATE_FILE}.steps"
}
# Backup function for data integrity
backup_directory() {
typeset target_dir=$1
typeset backup_name=${2:-${target_dir:t}}
typeset backup_dir=/var/backups/openbsd_setup
typeset timestamp=$EPOCHSECONDS
typeset backup_file="$backup_dir/${backup_name}-${timestamp}.tar.gz"
[[ ! -d $backup_dir ]] && mkdir -p "$backup_dir"
if [[ -d $target_dir ]]; then
log INFO "Backing up $target_dir to $backup_file"
transaction_log "BACKUP" "$target_dir" "START"
if tar -czf "$backup_file" -C "${target_dir:h}" "${target_dir:t}" 2>/dev/null; then
transaction_log "BACKUP" "$target_dir" "SUCCESS" "$backup_file"
log INFO "Backup created: $backup_file"
# Keep only last 10 backups
typeset -a _bfiles; _bfiles=("$backup_dir"/${backup_name}-*.tar.gz(N)); typeset backup_count=${#_bfiles}
if (( backup_count > 10 )); then
typeset -a _sorted_bfiles; _sorted_bfiles=("$backup_dir"/${backup_name}-*.tar.gz(NOm)); for _f in "${_sorted_bfiles[@]:10}"; do rm -f "$_f"; done
log INFO "Pruned old backups, keeping last 10"
fi
echo "$backup_file"
return 0
else
transaction_log "BACKUP" "$target_dir" "FAILURE"
log ERROR "Backup failed for $target_dir"
return 1
fi
else
log WARN "Directory $target_dir does not exist, skipping backup"
return 0
fi
}
# Transaction logging for audit trail
transaction_log() {
typeset operation=$1
typeset target=$2
typeset op_status=$3
typeset metadata=${4:-}
typeset logfile=/var/log/openbsd_transactions.log
print -r -- "[$(date +'%Y-%m-%d %H:%M:%S')] [$operation] $target | Status: $op_status | $metadata" >> "$logfile"
}
# Logging function
log() {
typeset level=$1
shift
print -r -- "[$(date +'%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a /var/log/openbsd_setup.log >&2
}
# Template helpers — render files/* with $var expansion, install to dest
install_template() {
typeset src=${SCRIPT_DIR}/$1 dst=$2
[[ -f $src ]] || { log ERROR "Missing template: $src"; exit 1 }
typeset content; content=$(<"$src")
eval "cat > \"$dst\" <<INSTALL_TEMPLATE_EOF
$content
INSTALL_TEMPLATE_EOF"
}
append_template() {
typeset src=${SCRIPT_DIR}/$1 dst=$2
[[ -f $src ]] || { log ERROR "Missing template: $src"; exit 1 }
typeset content; content=$(<"$src")
eval "cat >> \"$dst\" <<APPEND_TEMPLATE_EOF
$content
APPEND_TEMPLATE_EOF"
}
install_static() {
typeset src=${SCRIPT_DIR}/$1 dst=$2
[[ -f $src ]] || { log ERROR "Missing file: $src"; exit 1 }
cp "$src" "$dst"
}
# Configuration settings (constants per master.yml p04: explicit over implicit)
typeset -r BRGEN_IP="185.52.176.18" # Primary server IP (updated for this VPS)
typeset -r HYP_IP="194.63.248.53" # ns.hyp.net, external secondary
typeset -r LOCALHOST="127.0.0.1" # Localhost constant
typeset -r EMAIL_ADDRESS="bergen@pub.attorney" # Email address for OpenSMTPD
typeset -r STATE_FILE="./openbsd_setup_state" # Runtime state file
SCRIPT_DIR=${0:a:h}
typeset -a PUBLIC_RESOLVERS=(8.8.8.8 1.1.1.1 9.9.9.9) # Public DNS resolvers
typeset -A APP_PORTS # Rails app port mappings
typeset -A FAILED_CERTS # Failed certificate tracking
# Validate IP addresses with proper octet checking
validate_ip() {
typeset ip=$1
[[ $ip =~ ^([0-9]{1,3}.){3}[0-9]{1,3}$ ]] || return 1
typeset IFS=.
typeset -a octets
octets=(${(s:.:)ip})
for octet in $octets; do
(( octet > 255 )) && return 1
done
return 0
}
validate_ip "$BRGEN_IP" || { log ERROR "Invalid BRGEN_IP: $BRGEN_IP"; exit 1; }
validate_ip "$HYP_IP" || { log ERROR "Invalid HYP_IP: $HYP_IP"; exit 1; }
# Rails applications
ALL_APPS=(
brgen:brgen.no
amber:amber.brgen.no
bsdports:bsdports.org
baibl:baibl.no
)
# Non-Rails services (name:subdomain.domain:port)
SERVICES=(
)
# Domain list for DNS
ALL_DOMAINS=(
brgen.no:markedsplass,playlist,dating,tv,takeaway,maps,ai
longyearbyn.no:markedsplass,playlist,dating,tv,takeaway,maps
oshlo.no:markedsplass,playlist,dating,tv,takeaway,maps
stvanger.no:markedsplass,playlist,dating,tv,takeaway,maps
trmso.no:markedsplass,playlist,dating,tv,takeaway,maps
trndheim.no:markedsplass,playlist,dating,tv,takeaway,maps
reykjavk.is:markadur,playlist,dating,tv,takeaway,maps
kbenhvn.dk:markedsplads,playlist,dating,tv,takeaway,maps
gtebrg.se:marknadsplats,playlist,dating,tv,takeaway,maps
mlmoe.se:marknadsplats,playlist,dating,tv,takeaway,maps
stholm.se:marknadsplats,playlist,dating,tv,takeaway,maps
hlsinki.fi:markkinapaikka,playlist,dating,tv,takeaway,maps
brmingham.uk:marketplace,playlist,dating,tv,takeaway,maps
cardff.uk:marketplace,playlist,dating,tv,takeaway,maps
edinbrgh.uk:marketplace,playlist,dating,tv,takeaway,maps
glasgw.uk:marketplace,playlist,dating,tv,takeaway,maps
lndon.uk:marketplace,playlist,dating,tv,takeaway,maps
lverpool.uk:marketplace,playlist,dating,tv,takeaway,maps
mnchester.uk:marketplace,playlist,dating,tv,takeaway,maps
amstrdam.nl:marktplaats,playlist,dating,tv,takeaway,maps
rottrdam.nl:marktplaats,playlist,dating,tv,takeaway,maps
utrcht.nl:marktplaats,playlist,dating,tv,takeaway,maps
brssels.be:marche,playlist,dating,tv,takeaway,maps
zrich.ch:marktplatz,playlist,dating,tv,takeaway,maps
lchtenstein.li:marktplatz,playlist,dating,tv,takeaway,maps
frankfrt.de:marktplatz,playlist,dating,tv,takeaway,maps
brdeaux.fr:marche,playlist,dating,tv,takeaway,maps
mrseille.fr:marche,playlist,dating,tv,takeaway,maps
mlan.it:mercato,playlist,dating,tv,takeaway,maps
lisbon.pt:mercado,playlist,dating,tv,takeaway,maps
wrsawa.pl:marktplatz,playlist,dating,tv,takeaway,maps
gdnsk.pl:marktplatz,playlist,dating,tv,takeaway,maps
austn.us:marketplace,playlist,dating,tv,takeaway,maps
chcago.us:marketplace,playlist,dating,tv,takeaway,maps
denvr.us:marketplace,playlist,dating,tv,takeaway,maps
dllas.us:marketplace,playlist,dating,tv,takeaway,maps
dnver.us:marketplace,playlist,dating,tv,takeaway,maps
dtroit.us:marketplace,playlist,dating,tv,takeaway,maps
houstn.us:marketplace,playlist,dating,tv,takeaway,maps
lsangeles.com:marketplace,playlist,dating,tv,takeaway,maps
mnnesota.com:marketplace,playlist,dating,tv,takeaway,maps
newyrk.us:marketplace,playlist,dating,tv,takeaway,maps
prtland.com:marketplace,playlist,dating,tv,takeaway,maps
wshingtondc.com:marketplace,playlist,dating,tv,takeaway,maps
pub.healthcare
pub.attorney
freehelp.legal
bsdports.org
bsddocs.org
discordb.org
foodielicio.us
stacyspassion.com
antibettingblog.com
anticasinoblog.com
antigamblingblog.com
foball.no
amber.brgen.no
baibl.no
)
# Zsh completion function
_openbsd_sh() {
_arguments \
'--help[Show usage information]' \
'--resume[Resume with Stage 2]'
}
# Utility functions
generate_random_port() {
# Generate random port (10000–60000), ensuring it’s free
typeset port
while :; do
port=$((RANDOM % 50000 + 10000))
typeset _netstat_out; _netstat_out=$(/usr/bin/netstat -an); [[ $_netstat_out != *".$port "* ]] && echo $port && break
done
... 823 lines truncated (1223 total)#!/usr/bin/env ruby
# frozen_string_literal: true
require "logger"
require "json"
require "time"
require "fileutils"
require "rbconfig"
module PostproBootstrap
LOG_PREFIX = "[postpro]".freeze
def self.dmesg(msg)
puts "#{LOG_PREFIX} #{msg}"
end
def self.startup_banner
dmesg "boot ruby=#{RUBY_VERSION} os=#{RbConfig::CONFIG['host_os']}"
end
def self.ensure_gems
{ vips: ensure_vips, tty: ensure_tty_prompt }
end
def self.ensure_vips
require "vips"
true
rescue LoadError
dmesg "WARN ruby-vips missing, installing..."
if system("gem install ruby-vips --no-document")
require "vips"
dmesg "OK ruby-vips installed"
true
else
dmesg "WARN ruby-vips install failed, probing libvips"
probe_and_install_libvips
false
end
rescue StandardError => e
dmesg "WARN ruby-vips unavailable: #{e.message}"
false
end
def self.ensure_tty_prompt
require "tty-prompt"
true
rescue LoadError
dmesg "WARN tty-prompt missing, installing..."
if system("gem install tty-prompt --no-document")
require "tty-prompt"
dmesg "OK tty-prompt installed"
true
else
dmesg "WARN tty-prompt install failed"
false
end
rescue StandardError => e
dmesg "WARN tty-prompt unavailable: #{e.message}"
false
end
def self.probe_and_install_libvips
dmesg "probing libvips..."
return true if system("pkg-config --exists vips")
os = RbConfig::CONFIG["host_os"]
case os
when /darwin/
if system("which brew > /dev/null 2>&1")
dmesg "brew install vips"
system("brew install vips")
else
dmesg "ERROR brew not found"
end
when /linux/
install_cmd = if system("which apt > /dev/null 2>&1")
"sudo apt update && sudo apt install -y libvips-dev"
elsif system("which dnf > /dev/null 2>&1")
"sudo dnf install -y vips-devel"
elsif system("which yum > /dev/null 2>&1")
"sudo yum install -y vips-devel"
elsif system("which apk > /dev/null 2>&1")
"sudo apk add vips-dev"
elsif system("which pacman > /dev/null 2>&1")
"sudo pacman -S --noconfirm libvips"
end
if install_cmd
dmesg install_cmd
system(install_cmd)
else
dmesg "ERROR unsupported package manager"
end
when /openbsd/
if system("which pkg_add > /dev/null 2>&1")
dmesg "pkg_add vips"
system("doas pkg_add vips")
else
dmesg "ERROR pkg_add missing"
end
else
dmesg "ERROR unsupported OS: #{os}"
end
if system("pkg-config --exists vips")
dmesg "OK libvips installed"
true
else
dmesg "ERROR libvips installation failed"
false
end
end
def self.load_camera_profiles(dir)
return {} unless Dir.exist?(dir)
profiles = {}
Dir.glob(File.join(dir, "*.json")).each do |f|
begin
data = JSON.parse(File.read(f))
vendor = data["vendor"]
profiles[vendor] = data["profiles"] if vendor && data["profiles"]
rescue StandardError => e
dmesg "WARN profile #{File.basename(f)}: #{e.message}"
end
end
dmesg "camera_profiles=#{profiles.keys.join(',')}"
profiles
end
def self.load_master_config
return {} unless File.exist?("master.json")
begin
raw = File.read("master.json")
json = JSON.parse(raw.gsub(%r{^.*//.*$}, ""))
json.dig("config", "multimedia", "postpro") || {}
rescue StandardError => e
dmesg "WARN master.json: #{e.message}"
{}
end
end
def self.run
startup_banner
gems = ensure_gems
unless gems[:vips]
dmesg "FATAL libvips missing"
abort <<~MSG
Postpro.rb requires libvips.
Install manually:
macOS: brew install vips
Debian/Ubuntu: sudo apt install libvips-dev
OpenBSD: doas pkg_add vips
MSG
end
{
gems: gems,
camera_profiles: load_camera_profiles("multimedia/camera_profiles"),
config: load_master_config
}
end
end
BOOTSTRAP = PostproBootstrap.run
LOGGER = Logger.new("postpro.log", "daily")
LOGGER.level = Logger::DEBUG
CLI_LOGGER = Logger.new($stdout)
CLI_LOGGER.level = Logger::INFO
PROMPT = if BOOTSTRAP[:gems][:tty]
require "tty-prompt"
TTY::Prompt.new
end
require "vips" if BOOTSTRAP[:gems][:vips]
REPLIGEN_PRESENT = File.exist?("repligen.rb")
CAMERA_PROFILES = BOOTSTRAP[:camera_profiles]
CONFIG = BOOTSTRAP[:config]
STOCKS = {
kodak_portra: { grain: 15, gamma: 0.65, rolloff: 0.88, lift: 0.05, matrix: [1.05, -0.02, -0.03, 0.02, 0.98, 0.00, 0.01, -0.05, 1.04] },
kodak_vision3: { grain: 20, gamma: 0.65, rolloff: 0.85, lift: 0.08, matrix: [1.08, -0.05, -0.03, 0.03, 0.95, 0.02, 0.02, -0.08, 1.06] },
fuji_velvia: { grain: 8, gamma: 0.75, rolloff: 0.92, lift: 0.03, matrix: [1.12, -0.08, -0.04, 0.05, 1.05, -0.02, 0.01, -0.12, 1.11] },
tri_x: { grain: 25, gamma: 0.70, rolloff: 0.80, lift: 0.12, matrix: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] }
}.freeze
PRESETS = {
portrait: { fx: %w[skin_protect film_curve highlight_roll micro_contrast grain color_temp base_tint], stock: :kodak_portra, temp: 5200, intensity: 0.8 },
landscape: { fx: %w[film_curve color_separate highlight_roll micro_contrast grain vintage_lens], stock: :fuji_velvia, temp: 5800, intensity: 0.9 },
street: { fx: %w[film_curve shadow_lift micro_contrast vintage_lens grain], stock: :tri_x, temp: 5600, intensity: 1.0 },
blockbuster:{ fx: %w[teal_orange grain bloom_pro highlight_roll micro_contrast], stock: :kodak_vision3, temp: 4800, intensity: 1.2 }
}.freeze
def safe_cast(img, fmt = "uchar")
img.cast(fmt)
rescue StandardError => e
LOGGER.error "Cast failed: #{e.message}"
img
end
def rgb_bands(img, bands = 3)
return img if img.bands == bands
img.bands < bands ? img.bandjoin([img] * (bands - img.bands)) : img.extract_band(0, n: bands)
end
def load_image(path)
return nil unless File.file?(path) && File.readable?(path)
img = Vips::Image.new_from_file(path, access: :sequential)
img = img.colourspace("srgb") if img.bands < 3
rgb_bands(img)
rescue StandardError => e
LOGGER.error "Load #{path}: #{e.message}"
nil
end
def get_camera_profile(img)
return nil if CAMERA_PROFILES.empty?
make = img.get("exif-ifd0-Make")&.strip&.downcase
model = img.get("exif-ifd0-Model")&.strip&.downcase
return nil unless make && model
CAMERA_PROFILES.each { |_, p| return p[model] if p[model] }
CAMERA_PROFILES.each { |brand, p| return p.values.first if make.include?(brand) || brand.include?(make) }
nil
rescue StandardError => e
LOGGER.debug "EXIF error: #{e.message}"
nil
end
def apply_camera_profile(img, profile)
return img unless profile && profile["color_matrix"]
matrix = profile["color_matrix"]
return img unless matrix.size == 9
result = img.recomb([
[matrix[0], matrix[1], matrix[2]],
[matrix[3], matrix[4], matrix[5]],
[matrix[6], matrix[7], matrix[8]]
])
if profile["saturation"]
hsv = result.colourspace("hsv")
h, s, v = hsv.bandsplit
s = s.linear([profile["saturation"]], [0])
result = Vips::Image.bandjoin([h, s, v]).colourspace("srgb")
end
result = result.linear([1.0 + profile["vibrance"].to_f * 0.1], [0]) if profile["vibrance"]
result = base_tint(result, profile["base_tint"], 0.1) if profile["base_tint"]
safe_cast(result)
rescue StandardError => e
LOGGER.error "Camera profile: #{e.message}"
img
end
def color_temp(img, kelvin, intensity = 1.0)
factor = kelvin / 5500.0
r, g, b = if factor < 1.0
[1.0, factor**0.5, factor**2]
else
[factor**-0.3, 1.0, 1.0 + (factor - 1.0) * 0.5]
end
safe_cast img.linear([
1.0 + (r - 1.0) * intensity,
1.0 + (g - 1.0) * intensity,
1.0 + (b - 1.0) * intensity
], [0, 0, 0])
end
def skin_protect(img, intensity = 1.0)
hsv = img.colourspace("hsv")
h, s, _ = hsv.bandsplit
mask = (h > 25.5) & (h < 63.75) & (s > 51) & (s < 153)
protection = mask.cast("float") / 255.0 * (1.0 - intensity * 0.7)
safe_cast img * (1.0 - protection) + img * protection
end
def film_curve(img, stock = :kodak_portra, intensity = 1.0)
data = STOCKS[stock] || STOCKS[:kodak_portra]
shadows = img.linear([1.0], [data[:lift] * 255 * intensity])
highlights = shadows.pow(data[:gamma]).pow(data[:rolloff])
safe_cast img * (1 - intensity) + highlights * intensity
end
def highlight_roll(img, threshold = 200, intensity = 1.0)
mask = img > threshold
rolled = threshold + (img - threshold) * 0.3 ** 0.7
safe_cast img * (1 - intensity) + (mask.ifthenelse(rolled, img)) * intensity
end
def shadow_lift(img, lift = 0.15, preserve = true)
gray = img.colourspace("grey16").cast("float") / 255.0
mask = preserve ? ((1.0 - gray).pow(2.0)) * 0.8 : (1.0 - gray) * lift
safe_cast img.linear([1.0, 1.0, 1.0], [mask * 255 * lift])
end
def micro_contrast(img, radius = 5, intensity = 0.3)
blurred = img.gaussblur(radius)
high_pass = img - blurred
safe_cast img + high_pass * intensity
end
def color_separate(img, intensity = 0.6)
r, g, b = img.bandssplit
r = (r - g * 0.08 * intensity - b * 0.05 * intensity).max(0)
g = (g - r * 0.06 * intensity - b * 0.10 * intensity).max(0)
b = (b - r * 0.04 * intensity - g * 0.07 * intensity).max(0)
safe_cast img * (1 - intensity) + Vips::Image.bandjoin([r, g, b]) * intensity
end
def grain(img, iso = 400, stock = :kodak_portra, intensity = 0.4)
data = STOCKS[stock]
sigma = data[:grain] * Math.sqrt(iso / 100.0) * intensity
noise = Vips::Image.gaussnoise(img.width, img.height, sigma: sigma)
bright = img.colourspace("grey16").cast("float") / 255.0
strength = (1.2 - bright).max(0.3) * intensity
safe_cast img + rgb_bands(noise * strength) * 0.25
end
def base_tint(img, color = [252, 248, 240], intensity = 0.08)
overlay = Vips::Image.black(img.width, img.height, bands: 3) + color
ov = overlay.cast("float") / 255.0
im = img.cast("float") / 255.0
blended = im.ifthenelse(ov < 0.5, 2 * im * ov, 1 - 2 * (1 - im) * (1 - ov)) * 255
safe_cast img * (1 - intensity) + blended * intensity
end
def vintage_lens(img, type = "zeiss", intensity = 0.7)
case type
when "zeiss"
micro_contrast(img, 3, 0.4 * intensity)
when "leica"
glow = img.gaussblur(20).linear([0.3 * intensity], [0])
safe_cast img + glow
when "helios"
sharp = img.sharpen(mask: [[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
safe_cast img * (1 - intensity * 0.3) + sharp * (intensity * 0.3)
else
img
end
end
def teal_orange(img, intensity = 1.0)
r, g, b = skin_protect(img, 0.8).bandsplit
r = r.linear([1 + 0.25 * intensity], [8 * intensity])
g = g.linear([1 - 0.08 * intensity], [0])
b = b.linear([1 + 0.35 * intensity], [0])
safe_cast Vips::Image.bandjoin([r, g, b])
end
def bloom_pro(img, intensity = 1.0)
bright = img.linear([2.0 * intensity], [0])
combined = (bright.gaussblur(8 * intensity) + bright.gaussblur(16 * intensity) * 0.5) * 0.2
safe_cast img + combined
end
def preset(img, name)
cfg = PRESETS[name.to_sym]
return img unless cfg
result = img
cfg[:fx].each do |fx|
result = case fx
when "skin_protect" then skin_protect(result, cfg[:intensity])
when "film_curve" then film_curve(result, cfg[:stock], cfg[:intensity])
when "highlight_roll" then highlight_roll(result, 200, cfg[:intensity] * 0.7)
when "shadow_lift" then shadow_lift(result, 0.2, false)
when "micro_contrast" then micro_contrast(result, 6, cfg[:intensity] * 0.4)
when "grain" then grain(result, 400, cfg[:stock], cfg[:intensity] * 0.4)
when "color_temp" then color_temp(result, cfg[:temp], cfg[:intensity] * 0.6)
when "base_tint" then base_tint(result, [255, 250, 245], 0.08)
when "color_separate" then color_separate(result, cfg[:intensity] * 0.6)
when "vintage_lens" then vintage_lens(result, "zeiss", cfg[:intensity] * 0.8)
when "teal_orange" then teal_orange(result, cfg[:intensity])
when "bloom_pro" then bloom_pro(result, cfg[:intensity])
else result
end
end
result
end
def grain_basic(img, intensity)
noise = Vips::Image.gaussnoise(img.width, img.height, sigma: 25 * intensity)
safe_cast img + rgb_bands(noise) * 0.2
end
def leaks_basic(img, intensity)
overlay = Vips::Image.black(img.width, img.height, bands: 3)
rand(2..5).times do
x = rand(img.width)
y = rand(img.height)
radius = img.width / rand(2..4)
color = [255 * intensity, 180 * intensity, 80 * intensity]
overlay = overlay.draw_circle(color, x, y, radius, fill: true)
end
safe_cast img + overlay.gaussblur(15 * intensity) * 0.3
end
... 221 lines truncated (621 total)#!/usr/bin/env zsh
# @shared_functions.sh — shared helpers for DEPLOY/rails/* scripts
# Source this file; do not execute directly.
# Requires: zsh, ruby34, bundle, rails, doas
set -euo pipefail
PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
if command -v doas >/dev/null 2>&1; then
_PRIV=doas
else
_PRIV=sudo
fi
: "${APP_PORT:=3000}"
log() { print -P "%F{cyan}==>%f $*"; }
log_ok() { print -P "%F{green}ok%f $*"; }
log_warn() { print -P "%F{yellow}WARN%f $*" >&2; }
log_err() { print -P "%F{red}ERR%f $*" >&2; }
need_cmd() {
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || { log_err "Required: $cmd"; exit 1; }
log_ok "$cmd found"
done
}
already_done() {
local sentinel=$1
[[ -f $sentinel ]] && { log_warn "Already set up ($sentinel exists). Skipping."; return 0; }
return 1
}
create_rails_app() {
local app_dir=$1
local app_name=${app_dir:t:h}
mkdir -p "${app_dir:h}"
if [[ ! -f "${app_dir}/config/application.rb" ]]; then
log "Creating Rails 8 app at $app_dir"
rails new "$app_dir" \
--database=sqlite3 \
--asset-pipeline=propshaft \
--javascript=importmap \
--skip-git \
--skip-test \
--skip-bundle
# Bootstrap gems from amber to avoid OOM on fresh bundle install
local bundle_home="/home/${app_dir:h:t}/.bundle"
if [[ ! -d "${bundle_home}/gems" ]]; then
log "Bootstrapping gems from amber"
mkdir -p "${bundle_home}"
cp -r /home/amber/.bundle/gems "${bundle_home}/"
cp -r /home/amber/.bundle/cache "${bundle_home}/" 2>/dev/null || true
fi
mkdir -p "${app_dir}/.bundle"
print "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" > "${app_dir}/.bundle/config"
cp /home/amber/app/Gemfile.lock "${app_dir}/Gemfile.lock"
fi
cd "$app_dir"
log_ok "Working in: $app_dir"
}
add_gem() {
local gem=$1 ver=${2:-}
if ! grep -q "\"${gem}\"" Gemfile 2>/dev/null; then
if [[ -n $ver ]]; then
print "gem \"${gem}\", \"${ver}\"" >> Gemfile
else
print "gem \"${gem}\"" >> Gemfile
fi
log_ok "gem ${gem} added"
else
log_ok "gem ${gem} already present"
fi
}
bundle_install() {
bundle check 2>/dev/null && { log_ok "bundle ok (no install needed)"; return 0; }
log "bundle install"
bundle install --jobs=2 2>&1 | tail -5
log_ok "bundle install done"
}
add_gem_group() {
local groups=$1; shift
local -a gems=("$@")
if ! grep -q "gem \"${gems[1]}\"" Gemfile 2>/dev/null; then
{
print "group :${groups//,/, :} do"
for g in "${gems[@]}"; do print " gem \"$g\""; done
print "end"
} >> Gemfile
fi
}
install_solid_stack() {
log "Installing Solid Cache / Queue / Cable"
add_gem solid_cache
add_gem solid_queue
add_gem solid_cable
bin/rails solid_cache:install 2>/dev/null || true
bin/rails solid_queue:install 2>/dev/null || true
bin/rails solid_cable:install 2>/dev/null || true
log_ok "Solid stack installed"
}
install_auth() {
if [[ ! -f app/models/session.rb ]]; then
log "Generating Rails 8 authentication"
bin/rails generate authentication
bin/rails db:migrate
else
log_ok "Authentication already generated"
fi
}
install_active_storage() {
if [[ -z $(print db/migrate/*create_active_storage*(N)) ]]; then
log "Installing Active Storage"
bin/rails active_storage:install
bin/rails db:migrate
else
log_ok "Active Storage already installed"
fi
}
install_action_text() {
if [[ -z $(print db/migrate/*create_action_text*(N)) ]]; then
log "Installing Action Text"
bin/rails action_text:install
bin/rails db:migrate
else
log_ok "Action Text already installed"
fi
}
db_setup() {
log "Setting up database"
RAILS_ENV=production bin/rails db:create db:migrate
log_ok "Database ready"
}
db_migrate() {
RAILS_ENV=${RAILS_ENV:-production} bin/rails db:migrate
log_ok "Migrations complete"
}
configure_production() {
local cfg=config/environments/production.rb
grep -q 'force_ssl' "$cfg" || print ' config.force_ssl = true' >> "$cfg"
grep -q 'solid_cache' "$cfg" || print ' config.cache_store = :solid_cache_store' >> "$cfg"
log_ok "Production config updated"
}
install_security_tools() {
add_gem_group "development,test" brakeman rubocop-rails-omakase
log_ok "Security tools added"
}
install_dartsass() {
add_gem dartsass-rails
bin/rails dartsass:install 2>/dev/null || true
log_ok "Dart Sass installed"
}
write_base_scss() {
mkdir -p app/assets/stylesheets
rm -f app/assets/stylesheets/application.css
cat > app/assets/stylesheets/application.scss << 'SCSS'
// ==================== VARIABLES ====================
:root {
// Colors
--color-black: #000;
--color-white: #fff;
--color-extra-light-grey: #f0f0f0;
// Spacing
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
// Typography
--font-size-base: 14px;
--line-height-base: 1.5;
}
// ==================== RESET & BASE ====================
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: var(--font-size-base);
line-height: var(--line-height-base);
color: var(--color-black);
background-color: var(--color-white);
display: flex;
flex-direction: column;
}
img { max-width: 100%; display: block; }
a {
color: #4285f4;
text-decoration: none;
cursor: pointer;
&:hover { text-decoration: underline; }
&:focus { outline: 2px solid #4285f4; outline-offset: 2px; }
}
// ==================== NAV ====================
nav {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-extra-light-grey);
a { color: inherit; }
a:hover { text-decoration: underline; }
.brand { font-weight: 700; margin-right: auto; }
}
// ==================== MAIN ====================
main {
flex: 1;
display: grid;
grid-template-columns: 1fr;
gap: var(--space-md);
padding: var(--space-md);
}
// ==================== FLASH ====================
.flash {
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-extra-light-grey);
&--error, &--alert { color: #c00; }
&--notice { color: #060; }
}
// ==================== RESPONSIVE ====================
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: var(--space-md);
padding: var(--space-sm);
&__tabs {
gap: var(--space-sm);
flex-wrap: wrap;
justify-content: center;
}
}
}
@media (max-width: 480px) {
html, body { font-size: 12px; }
.header__tabs { gap: var(--space-xs); }
.header__tab { padding: var(--space-xs) var(--space-sm); font-size: 0.9em; }
}
SCSS
log_ok "application.scss written"
}
write_base_css() { write_base_scss; }
write_layout() { write_full_layout "$@"; }
install_rcd() {
local svc=$1 app_dir=$2 port=$3 user=$4
local rcd="/etc/rc.d/${svc}"
[[ -f $rcd ]] && { log_ok "rc.d/${svc} already exists"; return 0; }
local secret
secret=$(ruby34 -e 'require "securerandom"; print SecureRandom.hex(64)')
$_PRIV tee "$rcd" > /dev/null << EOS
#!/bin/ksh
daemon="/usr/local/bin/bundle"
daemon_flags="exec env RAILS_ENV=production SECRET_KEY_BASE=${secret} HOME=/home/${user} falcon serve --bind http://127.0.0.1:${port}"
daemon_user="${user}"
daemon_execdir="${app_dir}"
daemon_timeout="60"
. /etc/rc.d/rc.subr
pexp="ruby.*${port}"
rc_bg=YES
rc_reload=NO
rc_cmd \$1
EOS
$_PRIV chmod 755 "$rcd"
$_PRIV rcctl enable "$svc"
# App must be owned by the service user so it can write storage/log/tmp
$_PRIV chown -R "${user}:${user}" "${app_dir}"
log_ok "rc.d/${svc} installed (falcon on :${port})"
}
relayd_add_relay() {
local host=$1 port=$2
local table="${host%%.*}"
local conf=/etc/relayd.conf
grep -q "table <${table}>" "$conf" 2>/dev/null && { log_ok "relayd <${table}> exists"; return 0; }
$_PRIV tee -a "$conf" > /dev/null << EOS
table <${table}> { 127.0.0.1 }
relay "${table}_http" {
listen on 0.0.0.0 port 80
forward to <${table}> port ${port} check tcp
}
EOS
log_ok "relayd table <${table}> -> :${port} added"
}
write_falcon_config() {
local port=${1:-3000}
add_gem falcon
cat > config/falcon.rb << FALCON
#!/usr/bin/env -S falcon host
# frozen_string_literal: true
load :rack, :supervisor
hostname = File.basename(__dir__)
port = ENV.fetch("PORT", ${port}).to_i
rack hostname do
endpoint Async::HTTP::Endpoint.parse("http://0.0.0.0:\#{port}")
end
FALCON
log_ok "Falcon config written (:${port})"
}
install_thruster() {
add_gem thruster
log_ok "Thruster added"
}
# ── Stimulus + Importmap ────────────────────────────────────────────────────
setup_stimulus() {
log "Setting up Stimulus"
bin/importmap pin @hotwired/stimulus --download 2>/dev/null || true
mkdir -p app/javascript/controllers
cat > app/javascript/controllers/application.js << 'JS'
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }
JS
cat > app/javascript/controllers/index.js << 'JS'
import { application } from "./application"
// controllers are auto-imported via eagerLoadControllersFrom in application.js
// or listed here explicitly:
JS
cat >> app/javascript/application.js << 'JS'
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
JS
log_ok "Stimulus ready"
}
write_stimulus_controller() {
local name=$1
mkdir -p app/javascript/controllers
cat > "app/javascript/controllers/${name}_controller.js"
log_ok "Stimulus ${name}_controller.js written"
}
# ── Pagy ───────────────────────────────────────────────────────────────────
setup_pagy() {
add_gem pagy
# Pagy 9+ (v43+): no initializer needed; Backend is now Pagy::Method
ruby34 -e "
src = File.read('app/controllers/application_controller.rb')
unless src.include?('Pagy::Method')
src.sub!(/class ApplicationController.*\n/, \"\\\\0 include Pagy::Method\n\")
File.write('app/controllers/application_controller.rb', src)
end
"
log_ok "Pagy configured"
}
# ── Shared partials ─────────────────────────────────────────────────────────
write_shared_partials() {
mkdir -p app/views/shared
cat > app/views/shared/_flash.html.erb << 'ERB'
<% flash.each do |type, msg| %>
<div class="flash flash--<%= type %>"><%= msg %></div>
... 424 lines truncated (824 total)rails/
├─ amber/ # Amber‑related deployment scripts
│ └─ amber.sh
├─ baibl/ # Baibl service scripts
│ └─ baibl.sh
├─ blognet/ # Blognet deployment utilities
│ └─ blognet.sh
├─ brgen/ # Brgen family of scripts (brgen*.sh)
│ └─ brgen*.sh
├─ bsdports/ # BSD‑Ports integration scripts
│ └─ bsdports.sh
├─ hjerterom/ # Hjerterom service scripts
│ └─ hjerterom.sh
├─ privcam/ # PrivCam deployment helpers
│ └─ privcam.sh
├─ __shared/ # Shared resources used by all scripts
│ ├─ @common.sh # Core utilities (e.g., `get_app_port`, feature loading)
│ ├─ @*_features.sh # Feature modules (messaging, reddit, airbnb, …)
│ ├─ layouts/* # Reusable partials & static assets
│ └─ @shared_functions.sh # Logging, environment handling, common helpers
├─ __common_patterns.css # Global CSS patterns shared across deployments
├─ check_ports.sh # Validate service ports against `master.json`
├─ modernize_zsh.sh # Migrate legacy Zsh patterns to the new style
├─ voting_system.sh # Scripts for deploying the voting subsystem
└─ rich_editor_system.sh # Tools for installing and configuring the rich‑text editor#!/usr/bin/env zsh
bin/rails active_storage:install
bin/rails generate migration add_avatar_to_users avatar:attachment
bin/rails db:migrate
cat <<EOF > app/models/user.rb
class User < ApplicationRecord
has_one_attached :avatar
end
EOF
yarn add @rails/activestorage image_processing
bundle add image_processing
cat <<EOF > app/controllers/users_controller.rb
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
if @user.update(user_params)
redirect_to @user, notice: "User was successfully updated."
else
render :edit
end
end
private
def user_params
params.require(:user).permit(:avatar)
end
end
EOFcd "$BASE_DIR"
doas pkg_add llvm
bundle add langchainrb
bundle add langchainrb_rails
bundle add weaviate-ruby
bundle add replicate-ruby
bundle add replicate-rails
bundle install
#!/usr/bin/env zsh
# __shared/@airbnb_features.sh — Airbnb-style rental models for Rails 8
# Source from app installers. Do not execute directly.
set -euo pipefail
setup_airbnb_models() {
log "Setting up Airbnb rental models"
generate_model Listing \
title:string description:text \
price_per_night:decimal max_guests:integer \
location:string latitude:float longitude:float \
user:references
generate_model Booking \
listing:references user:references \
check_in:date check_out:date \
guests_count:integer total_price:decimal \
status:string
generate_model Review \
listing:references user:references \
rating:integer content:text \
cleanliness:integer accuracy:integer \
communication:integer location:integer value:integer
generate_model Availability \
listing:references date:date available:boolean price_override:decimal
generate_model Amenity name:string category:string icon:string
generate_model ListingAmenity listing:references amenity:references
log_ok "Airbnb models ready"
}
write_airbnb_model_logic() {
cat > app/models/listing.rb << 'RUBY'
class Listing < ApplicationRecord
belongs_to :user
has_many :bookings, dependent: :destroy
has_many :reviews, dependent: :destroy
has_many :availabilities, dependent: :destroy
has_many :listing_amenities, dependent: :destroy
has_many :amenities, through: :listing_amenities
has_many_attached :photos
validates :title, :price_per_night, :max_guests, :location, presence: true
validates :price_per_night, numericality: { greater_than: 0 }
validates :max_guests, numericality: { only_integer: true, greater_than: 0 }
scope :available_between, ->(check_in, check_out) {
where.not(id: Booking.confirmed.select(:listing_id)
.where("check_in < ? AND check_out > ?", check_out, check_in))
}
def average_rating
reviews.average(:rating)&.round(1) || 0
end
def available_on?(date)
availabilities.find_by(date: date)&.available != false
end
end
RUBY
cat > app/models/booking.rb << 'RUBY'
class Booking < ApplicationRecord
belongs_to :listing
belongs_to :user
STATUSES = %w[pending confirmed cancelled completed].freeze
validates :check_in, :check_out, :guests_count, :total_price, :status, presence: true
validates :guests_count, numericality: { only_integer: true, greater_than: 0 }
validates :total_price, numericality: { greater_than_or_equal_to: 0 }
validates :status, inclusion: { in: STATUSES }
validate :check_out_after_check_in
validate :guests_within_capacity
scope :confirmed, -> { where(status: "confirmed") }
scope :upcoming, -> { where("check_in >= ?", Date.today).order(:check_in) }
before_validation :calculate_total_price, on: :create
private
def check_out_after_check_in
return if check_in.blank? || check_out.blank?
errors.add(:check_out, "must be after check-in") if check_out <= check_in
end
def guests_within_capacity
return unless listing && guests_count
if guests_count > listing.max_guests
errors.add(:guests_count, "exceeds listing capacity")
end
end
def calculate_total_price
return unless listing && check_in && check_out
nights = (check_out - check_in).to_i
self.total_price = nights * listing.price_per_night
end
end
RUBY
cat > app/models/review.rb << 'RUBY'
class Review < ApplicationRecord
belongs_to :listing
belongs_to :user
RATING_FIELDS = %i[rating cleanliness accuracy communication location value].freeze
validates :content, presence: true, length: { maximum: 2000 }
RATING_FIELDS.each do |field|
validates field, numericality: { in: 1..5 }, allow_nil: true
end
validates :rating, presence: true
after_create_commit :broadcast_to_listing
private
def broadcast_to_listing
broadcast_append_to listing, target: "reviews"
end
end
RUBY
log_ok "Airbnb model logic written"
}#!/usr/bin/env zsh
# __shared/@common.sh — common Rails 8 installer helpers
# Source from app installers. Do not execute directly.
set -euo pipefail
SCRIPT_DIR=${0:a:h}
log() { print -P "%F{cyan}[%D{%H:%M:%S}]%f $*"; }
warn() { print -P "%F{yellow}WARN%f $*" >&2; }
err() { print -P "%F{red}ERR%f $*" >&2; }
# Idempotent model generation: skip if model file exists
gen_model() {
local name=$1; shift
local path="app/models/${name:l}.rb"
if [[ -f $path ]]; then
log_ok "model $name already exists"
return 0
fi
bin/rails generate model "$name" "$@" --no-test-framework
bin/rails db:migrate
}
# Idempotent scaffold generation
gen_scaffold() {
local name=$1; shift
local path="app/models/${name:l}.rb"
if [[ -f $path ]]; then
log_ok "scaffold $name already exists"
return 0
fi
bin/rails generate scaffold "$name" "$@" --no-test-framework
bin/rails db:migrate
}
# Write file only if it does not exist
write_once() {
local path=$1
[[ -f $path ]] && { log_ok "$path exists"; return 0; }
mkdir -p "${path:h}"
cat > "$path"
log_ok "wrote $path"
}
# Overwrite file unconditionally
write_file() {
local path=$1
mkdir -p "${path:h}"
cat > "$path"
log_ok "wrote $path"
}cd "$BASE_DIR"
# -- SET UP DEVISE FOR USER AUTHENTICATION --
bundle add devise
bundle install
bin/rails generate devise:install
bin/rails generate devise User
bin/rails db:migrate
commit_to_git "Added Devise and hooked it up to User model."
# -- SET UP OMNIAUTH FOR USER AUTHENTICATION --
bundle add omniauth-openid-connect
bundle add omniauth-google-oauth2
bundle add omniauth-snapchat
bundle install
mkdir -p app/controllers/users
cat <<EOF > app/controllers/users/omniauth_callbacks_controller.rb
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def vipps
@user = User.from_omniauth(request.env["omniauth.auth"])
if @user.persisted?
sign_in_and_redirect @user, event: :authentication
set_flash_message(:notice, :success, kind: "Vipps") if is_navigational_format?
else
session["devise.vipps_data"] = request.env["omniauth.auth"].except("extra")
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\\n")
end
end
def google_oauth2
@user = User.from_omniauth(request.env["omniauth.auth"])
if @user.persisted?
sign_in_and_redirect @user, event: :authentication
set_flash_message(:notice, :success, kind: "Google") if is_navigational_format?
else
session["devise.google_data"] = request.env["omniauth.auth"].except("extra")
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\\n")
end
end
def snapchat
@user = User.from_omniauth(request.env["omniauth.auth"])
if @user.persisted?
sign_in_and_redirect @user, event: :authentication
set_flash_message(:notice, :success, kind: "Snapchat") if is_navigational_format?
else
session["devise.snapchat_data"] = request.env["omniauth.auth"].except("extra")
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\\n")
end
end
end
EOF
mkdir -p app/models
cat <<EOF > app/models/user.rb
class User < ApplicationRecord
devise :omniauthable, omniauth_providers: %i[vipps google_oauth2 snapchat]
def self.from_omniauth(auth)
where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
user.email = auth.info.email
user.password = Devise.friendly_token[0, 20]
user.name = auth.info.name
end
end
end
EOF
commit_to_git "Set up OmniAuth for Vipps, Google, and Snapchat."#!/usr/bin/env zsh
# __shared/@features_base.sh — Rails 8 resource generation helpers
# Source from app installers. Do not execute directly.
set -euo pipefail
# Generate a model+controller+views (scaffold) if model absent
generate_resource() {
local name=$1; shift
local model_file="app/models/${name:l}.rb"
if [[ -f $model_file ]]; then
log_ok "resource $name already present"
return 0
fi
log "Generating scaffold: $name $*"
bin/rails generate scaffold "$name" "$@" --no-test-framework
bin/rails db:migrate
log_ok "resource $name done"
}
# Generate a plain model if absent
generate_model() {
local name=$1; shift
local model_file="app/models/${name:l}.rb"
if [[ -f $model_file ]]; then
log_ok "model $name already present"
return 0
fi
log "Generating model: $name $*"
bin/rails generate model "$name" "$@" --no-test-framework
bin/rails db:migrate
log_ok "model $name done"
}
# Write a Stimulus controller if absent
generate_stimulus() {
local name=$1 # snake_case, e.g. posts_vote
local path="app/javascript/controllers/${name}_controller.js"
[[ -f $path ]] && { log_ok "stimulus $name exists"; return 0; }
mkdir -p app/javascript/controllers
cat > "$path" << JS
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {}
}
JS
log_ok "Stimulus $name written"
}
# Append route block to routes.rb if pattern absent
ensure_route() {
local pattern=$1
local block=$2
grep -q "$pattern" config/routes.rb 2>/dev/null && return 0
# Insert before final 'end'
ruby34 -i -e '
lines = $stdin.readlines
idx = lines.rindex { |l| l.strip == "end" }
lines.insert(idx, ARGV[0] + "\n") if idx
print lines.join
' "$block" config/routes.rb
log_ok "route added: $pattern"
}#!/usr/bin/env zsh
cd "$(dirname "$0")"
# Generate models, controllers, and views for instant messaging
bin/rails generate model Message sender:references recipient:references body:text read:boolean
bin/rails generate controller Messages create show index destroy
echo "resources :messages, only: [:create, :show, :index, :destroy]" >> config/routes.rb
# Update Message model
cat <<EOF > app/models/message.rb
class Message < ApplicationRecord
belongs_to :sender, class_name: "User"
belongs_to :recipient, class_name: "User"
validates :body, presence: true
end
EOF
# Update MessagesController
cat <<EOF > app/controllers/messages_controller.rb
class MessagesController < ApplicationController
before_action :authenticate_user!
def index
@messages = Message.where(sender: current_user).or(Message.where(recipient: current_user))
end
def show
@message = Message.find(params[:id])
@message.update(read: true) if @message.recipient == current_user
end
def create
@message = Message.new(message_params)
@message.sender = current_user
if @message.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to messages_path, notice: t("message_sent") }
end
else
render :new
end
end
def destroy
@message = Message.find(params[:id])
@message.destroy
respond_to do |format|
format.turbo_stream
format.html { redirect_to messages_path, notice: t("message_deleted") }
end
end
private
def message_params
params.require(:message).permit(:recipient_id, :body)
end
end
EOF
# Create views for messages
mkdir -p app/views/messages
cat <<EOF > app/views/messages/index.html.erb
<%= tag.h1 t("messages") %>
<%= tag.ul do %>
<% @messages.each do |message| %>
<%= tag.li do %>
<%= link_to message.body.truncate(20), message %>
<%= message.read ? t("read") : t("unread") %>
<% end %>
<% end %>
<% end %>
<%= turbo_stream_from "messages" %>
EOF
cat <<EOF > app/views/messages/show.html.erb
<%= tag.h1 t("message") %>
<p><strong><%= t("from") %>:</strong> <%= @message.sender.email %></p>
<p><strong><%= t("to") %>:</strong> <%= @message.recipient.email %></p>
<p><strong><%= t("body") %>:</strong> <%= @message.body %></p>
<p><strong><%= t("read") %>:</strong> <%= @message.read ? t("yes") : t("no") %></p>
<%= link_to t("back"), messages_path %>
EOF
cat <<EOF > app/views/messages/_form.html.erb
<%= form_with(model: @message, local: true) do |form| %>
<%= form.label :recipient_id %>
<%= form.collection_select :recipient_id, User.all, :id, :email, prompt: t("select_recipient") %>
<%= form.label :body %>
<%= form.text_area :body %>
<%= form.submit %>
<% end %>
EOF
cat <<EOF > app/views/messages/new.html.erb
<%= tag.h1 t("new_message") %>
<%= render "form", message: @message %>
<%= link_to t("back"), messages_path %>
EOF
# Turbo Streams for creating and destroying messages
cat <<EOF > app/views/messages/create.turbo_stream.erb
<%= turbo_stream.append "messages" do %>
<%= render @message %>
<% end %>
EOF
cat <<EOF > app/views/messages/destroy.turbo_stream.erb
<%= turbo_stream.remove dom_id(@message) %>
EOF
bin/rails db:migrate
commit_to_git "Set up instant messaging functionality"#!/bin/zsh
# Add dependencies
yarn add video.js
# Generate models, controllers, and views for live streaming
bin/rails generate model Stream title:string description:text user:references
bin/rails generate controller Streams index show new create destroy
# Add routes for streams (append to routes.rb)
echo "resources :streams, only: [:index, :show, :new, :create, :destroy]" >> config/routes.rb
# Create the Streams controller
cat <<EOF > app/controllers/streams_controller.rb
class StreamsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_stream, only: [:show, :destroy]
def index
@streams = Stream.all
end
def show
end
def new
@stream = current_user.streams.build
end
def create
@stream = current_user.streams.build(stream_params)
if @stream.save
redirect_to @stream, notice: "Stream created successfully"
else
render :new
end
end
def destroy
@stream.destroy
redirect_to streams_path, notice: "Stream deleted successfully"
end
private
def set_stream
@stream = Stream.find(params[:id])
end
def stream_params
params.require(:stream).permit(:title, :description)
end
end
EOF
# Create the Stream model
cat <<EOF > app/models/stream.rb
class Stream < ApplicationRecord
belongs_to :user
validates :title, presence: true
end
EOF
# Create views for streams
mkdir -p app/views/streams
cat <<EOF > app/views/streams/index.html.erb
<%= tag.h1 "Streams" %>
<%= tag.ul do %>
<% @streams.each do |stream| %>
<%= tag.li do %>
<%= link_to stream.title, stream %>
<p><%= stream.description %></p>
<% end %>
<% end %>
<% end %>
EOF
cat <<EOF > app/views/streams/show.html.erb
<%= tag.h1 @stream.title %>
<p><%= @stream.description %></p>
<div id="live-stream" data-controller="stream" data-stream-id="<%= @stream.id %>">
<video id="live-stream-video" controls autoplay></video>
</div>
<%= link_to "Back", streams_path %>
EOF
cat <<EOF > app/views/streams/_form.html.erb
<%= form_with(model: @stream, local: true) do |form| %>
<div class="field">
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div class="field">
<%= form.label :description %>
<%= form.text_area :description %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
EOF
cat <<EOF > app/views/streams/new.html.erb
<%= tag.h1 "New Stream" %>
<%= render "form", stream: @stream %>
<%= link_to "Back", streams_path %>
EOF
cat <<EOF > app/views/streams/edit.html.erb
<%= tag.h1 "Edit Stream" %>
<%= render "form", stream: @stream %>
<%= link_to "Back", streams_path %>
EOF
# Create Stimulus controller for live streaming
mkdir -p app/javascript/controllers
cat <<EOF > app/javascript/controllers/stream_controller.js
import { Controller } from "stimulus"
import { createConsumer } from "@rails/actioncable"
export default class extends Controller {
static targets = ["video"]
connect() {
this.channel = createConsumer().subscriptions.create(
{ channel: "StreamChannel", stream_id: this.data.get("id") },
{
received: data => this.#received(data)
}
)
}
#received(data) {
if (data.action === "play") {
this.videoTarget.src = data.url
}
}
}
EOF
# Create StreamChannel
cat <<EOF > app/channels/stream_channel.rb
class StreamChannel < ApplicationCable::Channel
def subscribed
stream_from "stream_\#{params[:stream_id]}"
end
end
EOF
# Create broadcast job
cat <<EOF > app/jobs/stream_broadcast_job.rb
class StreamBroadcastJob < ApplicationJob
queue_as :default
def perform(stream, url)
ActionCable.server.broadcast "stream_\#{stream.id}", action: "play", url: url
end
end
EOF
# Run migrations
bin/rails db:migrate
commit_to_git "Set up live cam streaming for $APP"#!/usr/bin/env zsh
cd "$(dirname "$0")"
# Add dependencies
yarn add video.js @hotwired/turbo-rails stimulus
# Generate models, controllers, and views for live streaming
bin/rails generate model Stream title:string description:text user:references
bin/rails generate controller Streams index show new create destroy
echo "resources :streams, only: [:index, :show, :new, :create, :destroy]" >> config/routes.rb
# Update Stream model
cat <<EOF > app/models/stream.rb
class Stream < ApplicationRecord
belongs_to :user
validates :title, presence: true
end
EOF
# Update StreamsController
cat <<EOF > app/controllers/streams_controller.rb
class StreamsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_stream, only: [:show, :destroy]
def index
@streams = Stream.all
end
def show
end
def new
@stream = current_user.streams.build
end
def create
@stream = current_user.streams.build(stream_params)
if @stream.save
redirect_to @stream, notice: t("stream_created")
else
render :new
end
end
def destroy
@stream.destroy
redirect_to streams_path, notice: t("stream_deleted")
end
private
def set_stream
@stream = Stream.find(params[:id])
end
def stream_params
params.require(:stream).permit(:title, :description)
end
end
EOF
# Create views for streams
mkdir -p app/views/streams
cat <<EOF > app/views/streams/index.html.erb
<%= tag.h1 t("streams") %>
<%= tag.ul do %>
<% @streams.each do |stream| %>
<%= tag.li do %>
<%= link_to stream.title, stream %>
<%= tag.p stream.description %>
<% end %>
<% end %>
<% end %>
<%= link_to t("new_stream"), new_stream_path %>
EOF
cat <<EOF > app/views/streams/show.html.erb
<%= tag.h1 @stream.title %>
<%= tag.p @stream.description %>
<%= link_to t("back"), streams_path %>
EOF
cat <<EOF > app/views/streams/_form.html.erb
<%= form_with(model: @stream, local: true) do |form| %>
<%= form.label :title %>
<%= form.text_field :title %>
<%= form.label :description %>
<%= form.text_area :description %>
<%= form.submit %>
<% end %>
EOF
cat <<EOF > app/views/streams/new.html.erb
<%= tag.h1 t("new_stream") %>
<%= render "form", stream: @stream %>
<%= link_to t("back"), streams_path %>
EOF
bin/rails db:migrate
commit_to_git "Set up live streaming functionality"#!/usr/bin/env zsh
# __shared/@messenger_features.sh — Messenger-style chat models for Rails 8
# Source from app installers. Do not execute directly.
set -euo pipefail
setup_messenger_models() {
log "Setting up messenger models"
generate_model Conversation \
name:string conversation_type:string \
last_message_at:datetime
generate_model ConversationParticipant \
conversation:references user:references \
last_read_at:datetime muted:boolean:'default[false]'
generate_model Message \
conversation:references user:references \
content:text message_type:string \
read_at:datetime edited_at:datetime \
deleted_at:datetime
log_ok "Messenger models ready"
}
write_messenger_model_logic() {
cat > app/models/conversation.rb << 'RUBY'
class Conversation < ApplicationRecord
has_many :conversation_participants, dependent: :destroy
has_many :participants, through: :conversation_participants, source: :user
has_many :messages, dependent: :destroy
validates :conversation_type, inclusion: { in: %w[direct group] }
scope :for_user, ->(user) {
joins(:conversation_participants).where(conversation_participants: { user: user })
}
def self.direct_between(user1, user2)
joins(:conversation_participants)
.where(conversation_type: "direct")
.where(conversation_participants: { user: user1 })
.joins(:conversation_participants)
.where(conversation_participants: { user: user2 })
.first
end
def unread_count_for(user)
participant = conversation_participants.find_by(user: user)
return 0 unless participant
messages.where("created_at > ?", participant.last_read_at || Time.at(0)).count
end
def mark_read_by!(user)
conversation_participants.find_by(user: user)&.update!(last_read_at: Time.current)
end
end
RUBY
cat > app/models/message.rb << 'RUBY'
class Message < ApplicationRecord
belongs_to :conversation
belongs_to :user
has_many_attached :attachments
validates :content, presence: true, unless: :has_attachments?
validates :message_type, inclusion: { in: %w[text image file voice] }
default_scope -> { where(deleted_at: nil).order(:created_at) }
after_create_commit :update_conversation_timestamp
after_create_commit :broadcast_to_conversation
def soft_delete!
update!(deleted_at: Time.current, content: "Message deleted")
end
def edited?
edited_at.present?
end
private
def has_attachments?
attachments.any?
end
def update_conversation_timestamp
conversation.update_column(:last_message_at, Time.current)
end
def broadcast_to_conversation
broadcast_append_to [conversation, "messages"],
partial: "messages/message",
locals: { message: self }
end
end
RUBY
cat > app/controllers/conversations_controller.rb << 'RUBY'
class ConversationsController < ApplicationController
before_action :require_authentication
def index
@conversations = Conversation.for_user(Current.user)
.includes(:participants, :messages)
.order(last_message_at: :desc)
end
def show
@conversation = Conversation.for_user(Current.user).find(params[:id])
@conversation.mark_read_by!(Current.user)
@messages = @conversation.messages.includes(:user).limit(50)
@message = Message.new
end
def create
recipient = User.find(params[:recipient_id])
@conversation = Conversation.direct_between(Current.user, recipient) ||
Conversation.create!(conversation_type: "direct")
@conversation.participants << Current.user unless @conversation.participants.include?(Current.user)
@conversation.participants << recipient unless @conversation.participants.include?(recipient)
redirect_to @conversation
end
end
RUBY
cat > app/controllers/messages_controller.rb << 'RUBY'
class MessagesController < ApplicationController
before_action :require_authentication
before_action :set_conversation
def create
@message = @conversation.messages.build(message_params.merge(user: Current.user))
if @message.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @conversation }
end
else
render :new, status: :unprocessable_entity
end
end
def destroy
@message = @conversation.messages.find(params[:id])
@message.soft_delete! if @message.user == Current.user
respond_to do |format|
format.turbo_stream
format.html { redirect_to @conversation }
end
end
private
def set_conversation
@conversation = Conversation.for_user(Current.user).find(params[:conversation_id])
end
def message_params
params.require(:message).permit(:content, :message_type, attachments: [])
end
end
RUBY
log_ok "Messenger logic written"
}if ! command_exists psql; then
echo "PostgreSQL is not installed. Installing..."
doas pkg_add -U postgresql-server || { echo "Failed to install PostgreSQL."; exit 1; }
doas rcctl enable postgresql
doas rcctl start postgresql
fi
# Set up PostgreSQL roles and databases
createuser -s "${APP}" 2>/dev/null || echo "Role ${APP} already exists."
createdb "${APP}_development" 2>/dev/null || echo "Database ${APP}_development already exists."
createdb "${APP}_test" 2>/dev/null || echo "Database ${APP}_test already exists."
createdb "${APP}_production" 2>/dev/null || echo "Database ${APP}_production already exists."
cat <<EOF > config/database.yml
default: &default
adapter: postgresql
encoding: unicode
username: ${APP}
password: password
host: localhost
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
development:
<<: *default
database: ${APP}_development
test:
<<: *default
database: ${APP}_test
production:
<<: *default
database: ${APP}_production
EOF
echo "PostgreSQL setup complete."
cd "$BASE_DIR"
echo "Generating Posts, Communities, and Comments..."
bundle add friendly_id
bundle install
bin/rails generate model Community name:string description:text
bin/rails generate model Post title:string content:text user:references community:references
bin/rails generate model Comment content:text user:references post:references
bin/rails db:migrate
# Community model
cat <<EOF > app/models/community.rb
class Community < ApplicationRecord
has_many :posts, class_name: "Post"
validates :name, presence: true
extend FriendlyId
friendly_id :name, use: :slugged
end
EOF
# Post model
cat <<EOF > app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
belongs_to :community, class_name: "Community"
has_many :comments, class_name: "Comment", dependent: :destroy
has_many :post_visibilities
has_many :visible_users, through: :post_visibilities, source: :user
has_many :reactions, as: :reactable, dependent: :destroy
validates :title, :content, presence: true
extend FriendlyId
friendly_id :title, use: :slugged
after_create :set_expiry
after_update_commit { broadcast_replace_to "posts" }
def visible_to?(user)
self.visible_users.include?(user)
end
def set_expiry
ExpiryJob.set(wait_until: self.expiry_time).perform_later(self.id) if self.expiry_time.present?
end
end
EOF
# Comment model
cat <<EOF > app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post, class_name: "Post"
belongs_to :user
validates :content, presence: true
extend FriendlyId
friendly_id :content, use: :slugged
end
EOF
# PostsController
cat <<EOF > app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!
def create
@post = current_user.posts.new(post_params)
if @post.save
params[:post][:visible_user_ids].each do |user_id|
@post.post_visibilities.create(user_id: user_id)
end
@post.visible_users.each do |user|
Notification.create(user: user, post: @post, message: "You have a new private post")
end
respond_to do |format|
format.html { redirect_to @post, notice: t('posts.create.success') }
format.turbo_stream
end
else
render :new
end
end
def update
if @post.update(post_params)
respond_to do |format|
format.html { redirect_to main_community_post_path(@post.community, @post) }
format.turbo_stream
end
else
render :edit
end
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :content, :community_id, :user_id, :expiry_time, visible_user_ids: [])
end
end
EOF
# CommentsController
cat <<EOF > app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def create
@comment = Comment.new(comment_params)
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to main_community_post_path(@comment.post.community, @comment.post) }
end
else
render :new
end
end
private
def comment_params
params.require(:comment).permit(:content, :post_id, :user_id)
end
end
EOF
# Turbo Stream Views
mkdir -p app/views/posts app/views/comments
cat <<EOF > app/views/posts/_post.html.erb
<%= turbo_frame_tag dom_id(post) do %>
<div class="post" id="post_<%= post.id %>">
<h2><%= link_to post.title, main_community_post_path(post.community, post) %></h2>
<p><%= post.content %></p>
</div>
<% end %>
EOF
cat <<EOF > app/views/comments/_comment.html.erb
<%= turbo_frame_tag dom_id(comment) do %>
<div class="comment" id="comment_<%= comment.id %>">
<p><%= comment.content %></p>
</div>
<% end %>
EOF
cat <<EOF > app/views/posts/create.turbo_stream.erb
<%= turbo_stream.append "posts", partial: "posts/post", locals: { post: @post } %>
EOF
cat <<EOF > app/views/posts/update.turbo_stream.erb
<%= turbo_stream.replace @post, partial: "posts/post", locals: { post: @post } %>
EOF
cat <<EOF > app/views/comments/create.turbo_stream.erb
<%= turbo_stream.append "comments", partial: "comments/comment", locals: { comment: @comment } %>
EOF
# FriendlyId for SEO-friendly URLs
echo "Installing FriendlyId for SEO-friendly URLs..."
bundle add friendly_id
bundle install
bin/rails generate friendly_id
commit_to_git "Installed FriendlyId for SEO-friendly URLs."
cat <<EOF > app/models/user.rb
class User < ApplicationRecord
extend FriendlyId
friendly_id :username, use: :slugged
end
EOF
cat <<EOF > app/models/community.rb
class Community < ApplicationRecord
has_many :posts, class_name: "Post"
validates :name, presence: true
extend FriendlyId
friendly_id :name, use: :slugged
end
EOF
cat <<EOF > app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
belongs_to :community, class_name: "Community"
has_many :comments, class_name: "Comment", dependent: :destroy
has_many :post_visibilities
has_many :visible_users, through: :post_visibilities, source: :user
has_many :reactions, as: :reactable, dependent: :destroy
validates :title, :content, presence: true
extend FriendlyId
friendly_id :title, use: :slugged
end
EOF
cat <<EOF > app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post, class_name: "Post"
belongs_to :user
validates :content, presence: true
extend FriendlyId
friendly_id :content, use: :slugged
end
EOF
commit_to_git "Set up FriendlyId for SEO-friendly URLs for User, Community, Post, and Comment models."
# I18n and Babosa for translation and transliteration
echo "Setting up I18n and Babosa for translation and transliteration..."
bundle add babosa
cat <<EOF > config/initializers/locale.rb
I18n.available_locales = [:en, :no]
I18n.default_locale = :en
require "babosa"
EOF
commit_to_git "Set up I18n and Babosa for translation and transliteration."
# Add Private Posts feature
echo "Adding private posts feature..."
cat <<EOF > app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :post_visibilities
has_many :visible_users, through: :post_visibilities, source: :user
has_many :comments, dependent: :destroy
has_many :reactions, as: :reactable, dependent: :destroy
validates :content, presence: true
after_create :set_expiry
after_update_commit { broadcast_replace_to "posts" }
def visible_to?(user)
self.visible_users.include?(user)
end
def set_expiry
ExpiryJob.set(wait_until: self.expiry_time).perform_later(self.id) if self.expiry_time.present?
end
end
EOF
cat <<EOF > app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!
def create
@post = current_user.posts.new(post_params)
if @post.save
params[:post][:visible_user_ids].each do |user_id|
@post.post_visibilities.create(user_id: user_id)
end
@post.visible_users.each do |user|
Notification.create(user: user, post: @post, message: "You have a new private post")
end
respond_to do |format|
format.html { redirect_to @post, notice: t('posts.create.success') }
format.turbo_stream
end
else
render :new
end
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:content, :expiry_time, visible_user_ids: [])
end
end
EOF
commit_to_git "Added private posts feature."cd "$BASE_DIR"
# Run the PWA generator
bin/rails generate pwa:install
# Stage changes and commit them
commit_to_git "Configured Rails to run as a Progressive Web App (PWA)"
cd "$BASE_DIR"
gem install bundler --user-install
gem install rails --user-install
bundle config set --local path "$HOME/.local"
rails33 new $APP --database=postgresql --javascript=esbuild --css=sass --assets=propshaft
cd $APP
git init
bundle install
yarn install
commit_to_git "Initial commit: Generate Rails app with PostgreSQL, Esbuild, SASS, and Propshaft."
#!/usr/bin/env zsh
# __shared/@reddit_features.sh — Reddit-style voting and comments for Rails 8
# Source from app installers. Do not execute directly.
set -euo pipefail
setup_reddit_models() {
log "Setting up Reddit-style vote+comment models"
generate_model Vote \
user:references votable:references{polymorphic}:index \
value:integer
generate_model Comment \
user:references commentable:references{polymorphic}:index \
parent_id:integer content:text \
score:integer:'default[0]' \
upvotes:integer:'default[0]' downvotes:integer:'default[0]'
log_ok "Reddit models ready"
}
write_reddit_model_logic() {
cat > app/models/vote.rb << 'RUBY'
class Vote < ApplicationRecord
belongs_to :user
belongs_to :votable, polymorphic: true
validates :value, inclusion: { in: [1, -1] }
validates :user_id, uniqueness: { scope: %i[votable_type votable_id] }
after_save :sync_votable_score
after_destroy :sync_votable_score
private
def sync_votable_score
votable.recalculate_score! if votable.respond_to?(:recalculate_score!)
end
end
RUBY
cat > app/models/concerns/votable.rb << 'RUBY'
module Votable
extend ActiveSupport::Concern
included do
has_many :votes, as: :votable, dependent: :destroy
end
def upvote_by(user)
cast_vote(user, 1)
end
def downvote_by(user)
cast_vote(user, -1)
end
def vote_by(user)
votes.find_by(user: user)
end
def recalculate_score!
up = votes.where(value: 1).count
down = votes.where(value: -1).count
update_columns(score: up - down, upvotes: up, downvotes: down)
end
private
def cast_vote(user, value)
existing = votes.find_by(user: user)
if existing
existing.value == value ? existing.destroy! : existing.update!(value: value)
else
votes.create!(user: user, value: value)
end
recalculate_score!
end
end
RUBY
cat > app/models/comment.rb << 'RUBY'
class Comment < ApplicationRecord
include Votable
belongs_to :user
belongs_to :commentable, polymorphic: true
belongs_to :parent, class_name: "Comment", optional: true
has_many :replies, class_name: "Comment", foreign_key: :parent_id, dependent: :destroy
validates :content, presence: true, length: { maximum: 10_000 }
scope :roots, -> { where(parent_id: nil) }
scope :top, -> { order(score: :desc) }
scope :new_first,-> { order(created_at: :desc) }
def depth
parent ? parent.depth + 1 : 0
end
def tree
[self] + replies.top.flat_map(&:tree)
end
end
RUBY
cat > app/models/concerns/commentable.rb << 'RUBY'
module Commentable
extend ActiveSupport::Concern
included do
has_many :comments, as: :commentable, dependent: :destroy
end
def comment_count
comments.count
end
end
RUBY
log_ok "Reddit model logic written"
}
write_vote_controller() {
cat > app/controllers/votes_controller.rb << 'RUBY'
class VotesController < ApplicationController
before_action :require_authentication
VOTABLE_TYPES = %w[Post Comment].freeze
def create
votable = find_votable
value = params[:value].to_i
raise ArgumentError, "invalid value" unless value.in?([-1, 1])
votable.public_send(value == 1 ? :upvote_by : :downvote_by, Current.user)
respond_to do |format|
format.turbo_stream
format.json { render json: { score: votable.score } }
end
end
private
def find_votable
type = params[:votable_type]
raise ArgumentError, "invalid type" unless VOTABLE_TYPES.include?(type)
type.constantize.find(params[:votable_id])
end
end
RUBY
log_ok "Vote controller written"
}cd "$BASE_DIR"
if ! command_exists redis-server; then
echo "Redis is not installed. Installing..."
doas pkg_add -U redis
doas rcctl enable redis
doas rcctl start redis
fi
commit_to_git "Configured Redis"
#!/usr/bin/env zsh
# __shared/@twitter_features.sh — Twitter-style posts, follows, hashtags for Rails 8
# Source from app installers. Do not execute directly.
set -euo pipefail
setup_twitter_models() {
log "Setting up Twitter-style models"
generate_model Post \
user:references content:string \
likes_count:integer:'default[0]' \
reposts_count:integer:'default[0]' \
replies_count:integer:'default[0]' \
in_reply_to_id:integer
generate_model Follow \
follower:references{User} following:references{User}
generate_model Like \
user:references likeable:references{polymorphic}:index
generate_model Repost \
user:references post:references quote:text
generate_model Hashtag name:string:uniq posts_count:integer:'default[0]'
generate_model Tagging \
post:references hashtag:references
generate_model Notification \
user:references actor:references{User} \
notifiable:references{polymorphic}:index \
action:string read_at:datetime
log_ok "Twitter models ready"
}
write_twitter_model_logic() {
cat > app/models/post.rb << 'RUBY'
class Post < ApplicationRecord
belongs_to :user
belongs_to :reply_to, class_name: "Post", foreign_key: :in_reply_to_id, optional: true
has_many :replies, class_name: "Post", foreign_key: :in_reply_to_id, dependent: :destroy
has_many :likes, as: :likeable, dependent: :destroy
has_many :reposts, dependent: :destroy
has_many :taggings, dependent: :destroy
has_many :hashtags, through: :taggings
validates :content, presence: true, length: { maximum: 280 }
after_create_commit :extract_hashtags
after_create_commit :notify_mentions
after_create_commit :broadcast_to_followers
scope :chronological, -> { order(created_at: :desc) }
scope :for_feed, ->(user) {
where(user: user.following + [user])
.where(in_reply_to_id: nil)
.chronological
}
private
def extract_hashtags
tags = content.scan(/#([\w]+)/).flatten.uniq
tags.each do |tag|
h = Hashtag.find_or_create_by!(name: tag.downcase)
taggings.find_or_create_by!(hashtag: h)
h.increment!(:posts_count)
end
end
def notify_mentions
content.scan(/@(\w+)/).flatten.each do |handle|
mentioned = User.find_by(username: handle)
next unless mentioned && mentioned != user
Notification.create!(
user: mentioned, actor: user,
notifiable: self, action: "mention"
)
end
end
def broadcast_to_followers
user.followers.each do |follower|
broadcast_prepend_to [follower, "feed"], partial: "posts/post", locals: { post: self }
end
end
end
RUBY
cat > app/models/follow.rb << 'RUBY'
class Follow < ApplicationRecord
belongs_to :follower, class_name: "User"
belongs_to :following, class_name: "User"
validates :follower_id, uniqueness: { scope: :following_id }
validate :no_self_follow
after_create_commit :notify_following
after_destroy :decrement_counts
private
def no_self_follow
errors.add(:base, "cannot follow yourself") if follower_id == following_id
end
def notify_following
Notification.create!(
user: following, actor: follower,
notifiable: self, action: "follow"
)
end
def decrement_counts; end
end
RUBY
cat > app/models/user.rb << 'RUBY'
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :posts, dependent: :destroy
has_many :likes, dependent: :destroy
has_many :reposts, dependent: :destroy
has_many :notifications, dependent: :destroy
has_many :sent_follows, class_name: "Follow", foreign_key: :follower_id, dependent: :destroy
has_many :received_follows, class_name: "Follow", foreign_key: :following_id, dependent: :destroy
has_many :following, through: :sent_follows, source: :following
has_many :followers, through: :received_follows, source: :follower
has_one_attached :avatar
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :username, presence: true, uniqueness: true, format: { with: /\A[a-z0-9_]+\z/ }, length: { maximum: 30 }
normalizes :email_address, with: -> e { e.strip.downcase }
def follow!(other)
sent_follows.find_or_create_by!(following: other)
end
def unfollow!(other)
sent_follows.find_by(following: other)&.destroy!
end
def following?(other)
sent_follows.exists?(following: other)
end
end
RUBY
log_ok "Twitter model logic written"
}
write_twitter_controllers() {
cat > app/controllers/posts_controller.rb << 'RUBY'
class PostsController < ApplicationController
before_action :require_authentication, except: %i[index show]
def index
@posts = Post.includes(:user).for_feed(Current.user).limit(50)
end
def show
@post = Post.find(params[:id])
@replies = @post.replies.includes(:user).chronological
@reply = Post.new(in_reply_to_id: @post.id)
end
def new
@post = Post.new
end
def create
@post = Current.user.posts.build(post_params)
if @post.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to root_path }
end
else
render :new, status: :unprocessable_entity
end
end
def destroy
@post = Current.user.posts.find(params[:id])
@post.destroy!
respond_to do |format|
format.turbo_stream
format.html { redirect_to root_path }
end
end
private
def post_params
params.require(:post).permit(:content, :in_reply_to_id)
end
end
RUBY
cat > app/controllers/follows_controller.rb << 'RUBY'
class FollowsController < ApplicationController
before_action :require_authentication
def create
user = User.find(params[:user_id])
Current.user.follow!(user)
respond_to do |format|
format.turbo_stream
format.html { redirect_to user }
end
end
def destroy
user = User.find(params[:user_id])
Current.user.unfollow!(user)
respond_to do |format|
format.turbo_stream
format.html { redirect_to user }
end
end
end
RUBY
cat > app/controllers/likes_controller.rb << 'RUBY'
class LikesController < ApplicationController
before_action :require_authentication
LIKEABLE_TYPES = %w[Post].freeze
def create
likeable = find_likeable
unless Like.exists?(user: Current.user, likeable: likeable)
Like.create!(user: Current.user, likeable: likeable)
likeable.increment!(:likes_count)
end
respond_to do |format|
format.turbo_stream
format.json { render json: { count: likeable.likes_count } }
end
end
def destroy
likeable = find_likeable
like = Like.find_by(user: Current.user, likeable: likeable)
if like
like.destroy!
likeable.decrement!(:likes_count)
end
respond_to do |format|
format.turbo_stream
format.json { render json: { count: likeable.likes_count } }
end
end
private
def find_likeable
type = params[:likeable_type]
raise ArgumentError unless LIKEABLE_TYPES.include?(type)
type.constantize.find(params[:likeable_id])
end
end
RUBY
log_ok "Twitter controllers written"
}#!/bin/zsh
if ! command_exists yarn; then
echo "Yarn is not installed. Installing..."
doas pkg_add -U node
doas npm install yarn -g
fi
<%# Renders a flash message with a dismiss button – expects locals: flash_message, flash_type %>
<% return unless flash_message.present? %>
<div class="flash flash-<%= h flash_type %>"
role="alert"
aria-live="assertive"
aria-atomic="true"
data-controller="flash"
data-action="click->flash#dismiss">
<div class="flash__content" data-flash-target="content">
<%= sanitize(
flash_message,
tags: %w[p b i u strong em a br],
attributes: %w[href target]
) %>
</div>
<button type="button"
class="flash-dismiss"
aria-label="Dismiss alert"
data-action="click->flash#dismiss">
<span aria-hidden="true">×</span>
<span class="visually-hidden">Dismiss this message</span>
</button>
</div><%# frozen_string_literal: true %>
<%= tag.footer class: "site-footer", role: "contentinfo" do %>
<div class="footer-content">
<p class="footer-text">
<%= t(
"footer.copyright",
year: Time.zone.now.year,
app_name: ApplicationHelper.application_name
) %>
</p>
<nav aria-label="Footer links">
<% (footer_links || []).each do |link| %>
<%= link_to t(link[:translation_key]), link[:path],
class: "footer-link",
rel: "noopener" %>
<% end %>
</nav>
</div>
<% end %><%= tag.meta charset: "utf-8" %>
<%= tag.meta name: "viewport", content: "width=device-width, initial-scale=1" %>
<%# Open Graph tags %>
<%= tag.meta property: "og:type", content: "website" %>
<%= tag.meta property: "og:image", content: (content_for?(:og_image) ? h(yield(:og_image)) : asset_path("default_og_image.png")) %>
<%= tag.meta property: "og:url", content: ERB::Util.u(request.original_url) %>
<%= tag.meta property: "og:description", content: (content_for?(:description) ? h(yield(:description)) : "My App Description") %>
<%= tag.meta property: "og:title", content: (content_for?(:title) ? h(yield(:title)) : "My App") %>
<%# Twitter Card tags – fall back to Open Graph values when not provided %>
<%= tag.meta name: "twitter:card", content: "summary_large_image" %>
<%= tag.meta name: "twitter:title", content: (content_for?(:title) ? h(yield(:title)) : "My App") %>
<%= tag.meta name: "twitter:description", content: (content_for?(:description) ? h(yield(:description)) : "My App Description") %>
<%= tag.meta name: "twitter:image", content: (content_for?(:og_image) ? h(yield(:og_image)) : asset_path("default_og_image.png")) %><%# frozen_string_literal: true %>
<a href="#main-content" class="skip-nav">Skip to main content</a>
<header class="site-header">
<div class="container">
<nav class="nav-main" aria-label="Main navigation" role="navigation">
<div class="nav-brand">
<%= link_to root_path, class: "logo-link" do %>
<span class="logo"><%= @app_name.presence || "App" %></span>
<% end %>
<% if (tenant = ActsAsTenant.current_tenant&.name).present? %>
<span class="tenant"><%= tenant %></span>
<% end %>
</div>
<ul class="nav-links">
<% if user_signed_in? %>
<li>
<span class="nav-user" aria-label="<%= current_user.email %>"><%= current_user.email %></span>
</li>
<li>
<%= link_to t("navigation.sign_out"), destroy_user_session_path,
method: :delete,
class: "nav-link",
data: { turbo_method: :delete } %>
</li>
<% else %>
<li>
<%= link_to t("navigation.sign_in"), new_user_session_path, class: "nav-link" %>
</li>
<% end %>
</ul>
</nav>
</div>
</header><!DOCTYPE html>
<html lang="<%= html_escape((I18n.locale.presence || I18n.default_locale || 'en').to_s) %>" dir="<%= html_escape(rtl_locale?(I18n.locale) ? 'rtl' : 'ltr') %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<% title = content_for?(:title) ? yield(:title) : (t('site.title', default: 'Default Site Title')) %>
<%= render "shared/meta", title: title %>
<%= yield :head if content_for?(:head) %>
</head>
<body data-controller="<%= html_escape(controller_name) %>"<% if defined?(body_class) && body_class.present? %> class="<%= html_escape(body_class) %>"<% end %>>
<%= render "shared/skip_links" %>
<%= render "shared/nav" %>
<main id="main-content" class="site-main" role="main">
<%= render "shared/flash" %>
<%= yield %>
</main>
<%= render "shared/footer" %>
<%= yield :scripts if content_for?(:scripts) %>
</body>
</html> "use strict";
const IN_SANDBOX=false;
const FADE_MS=3500,START_FADE_IN=true,DPR=Math.min(2,window.devicePixelRatio||1),isLowEnd=(navigator.hardwareConcurrency&&navigator.hardwareConcurrency<=2)||(navigator.deviceMemory&&navigator.deviceMemory<=2);
(()=>{const e=document.getElementById("uiDots");if(!e)return;const s=[0,1,2,3,2,1];let i=0;const t=()=>{e.textContent=".".repeat(s[i]);i=(i+1)%s.length};t();try{clearInterval(window.__RB_DOTS_IV)}catch{}window.__RB_DOTS_IV=setInterval(t,600)})();
const motionScale=()=>typeof matchMedia==="function"&&matchMedia("(prefers-reduced-motion: reduce)").matches?.35:1;
class SimpleCarousel{constructor(e,i=2800){this.slides=Array.from(e.querySelectorAll(".carousel-slide"));this.i=0;this.n=this.slides.length;if(this.n>1)this.t=setInterval(()=>this.next(),i)}next(){this.slides[this.i].classList.remove("active");this.i=(this.i+1)%this.n;this.slides[this.i].classList.add("active")}}
new SimpleCarousel(document.getElementById("cityCarousel"));
const YOUTUBE_TRACKS=[
{artist:"J Dilla",title:"Microphone Master",id:"9EGHwkDix78"},
{artist:"J Dilla",title:"In Space",id:"vO2nWXCVt6o"},
{artist:"J Dilla",title:"Timeless",id:"dbbfo9_7D8g"},
{artist:"AFTA-1",title:"Due Time",id:"WC09qDzU9y4"},
{artist:"Flying Lotus",title:"Massage Situation",id:"6oUx6wGCekM"},
{artist:"Madlib",title:"Eye",id:"ScVz2mntmCE"},
{artist:"Slum Village",title:"Players",id:"KsULjOCYdnY"},
{artist:"Jay Electronica",title:"Exhibit A",id:"H3UIHZshNQ0"},
{artist:"Slum Village",title:"La La (Instrumental)",id:"EYJxxHQ7sX0"},
{artist:"Slum Village",title:"Get It Together",id:"t6T-Q6HMbEo"},
{artist:"Slum Village",title:"Fantastic",id:"a3ISYWWYgz8"},
{artist:"Flying Lotus",title:"me Yesterday//Corded",id:"8DgAhgmpXNA"},
{artist:"Flying Lotus",title:"Camel",id:"fU9YRGLPDQ8"},
{artist:"Flying Lotus",title:"Golden Diva",id:"iu4FVvR2QQs"},
{artist:"Slum Village",title:"Worlds Full of Sadness",id:"MU3nfxsz2XA"},
{artist:"A. Mochi & Takaaki Itoh",title:"Sarria's Mind",id:"gFKArkiz8vU"},
{artist:"Samiyam",title:"Rounded",id:"oeaY2h_cKsg"},
{artist:"Chase Swayze",title:"Traffic",id:"bH-30pDoQdo"},
{artist:"Chase Swayze",title:"Underrated",id:"1jjFk2Vp5ok"},
{artist:"Flying Lotus",title:"BTS Radio 2006",id:"6nWdggkulHk",start:1364}
];
const loadYouTubeAPI=()=>{if(IN_SANDBOX||window.__YT_API_LOADED)return;window.__YT_API_LOADED=true;const s=document.createElement("script");s.src="https://www.youtube.com/iframe_api";s.async=true;document.head.appendChild(s)};
// MP3 Playlist Detection and Parsing
const detectMp3Playlist=async()=>{
if(IN_SANDBOX)return null;
let tracks=[];
try{
let r=await fetch("playlist.json");
if(r.ok){
const data=await r.json();
if(Array.isArray(data)&&data.length>0)tracks=tracks.concat(data.map(t=>({...t,src:t.src})));
}
}catch{}
try{
let r=await fetch("playlist.m3u");
if(r.ok){
const text=await r.text();
const m3uTracks=parseM3U(text);
if(m3uTracks&&m3uTracks.length>0)tracks=tracks.concat(m3uTracks);
}
}catch{}
try{
let r=await fetch("index.json");
if(r.ok){
const data=await r.json();
if(Array.isArray(data)){
const mp3Files=data.filter(f=>typeof f==='string'&&f.toLowerCase().endsWith('.mp3'));
tracks=tracks.concat(mp3Files.map(f=>{
const name=f.replace(/\.mp3$/i,'').replace(/[-_]/g,' ');
return{title:name,artist:'',src:f};
}));
}else if(data.files&&Array.isArray(data.files)){
const mp3Files=data.files.filter(f=>typeof f==='string'&&f.toLowerCase().endsWith('.mp3'));
tracks=tracks.concat(mp3Files.map(f=>{
const name=f.replace(/\.mp3$/i,'').replace(/[-_]/g,' ');
return{title:name,artist:'',src:f};
}));
}
}
}catch{}
return tracks.length>0?tracks:null;
};
const parseM3U=(text)=>{
const lines=text.split('\n').map(l=>l.trim()).filter(l=>l);
const tracks=[];
let current={};
for(const line of lines){
if(line.startsWith('#EXTINF:')){
const info=line.substring(8);
const parts=info.split(',');
if(parts.length>=2){
current.title=parts[1].trim();
const match=parts[0].match(/(\d+)/);
if(match)current.duration=parseInt(match[1]);
}
}else if(!line.startsWith('#')&&line){
current.src=line;
if(current.src)tracks.push({...current});
current={};
}
}
return tracks.length>0?tracks:null;
};
const YT_ORIGIN="https://www.youtube.com";
const ytPost=(i,f,a=[])=>{if(IN_SANDBOX)return;try{if(!i||!i.contentWindow)return;i.contentWindow.postMessage({event:"command",func:f,args:a},YT_ORIGIN)}catch{try{i.contentWindow.postMessage({event:"command",func:f,args:a},"*")}catch{}}};
class Mp3AudioEngine{
constructor(tracks){
this.started=false;this.muted=true;this.trackIndex=0;
this.tracks=tracks.slice().sort(()=>Math.random()-.5);
this.activeKey="a";this.inactiveKey="b";
this.players={a:null,b:null};this._fadeIv=null;this._prefadeTimer=null;
this.audioContext=null;this.analyser=null;this.dataArray=null;
this.beatPhase=0;this.energyLevel=.5;this._lastBeat=0;this._beatEnv=0;
this._initAudioElements();
}
_initAudioElements(){
// Create two audio elements for crossfading
this.players.a=new Audio();
this.players.b=new Audio();
this.players.a.crossOrigin="anonymous";
this.players.b.crossOrigin="anonymous";
this.players.a.preload="auto";
this.players.b.preload="auto";
this.players.a.volume=0;
this.players.b.volume=0;
// Setup Web Audio Context and Analyser
try{
this.audioContext=new(window.AudioContext||window.webkitAudioContext)();
this.analyser=this.audioContext.createAnalyser();
this.analyser.fftSize=512;
this.analyser.smoothingTimeConstant=0.8;
this.dataArray=new Uint8Array(this.analyser.frequencyBinCount);
// Connect active player to analyser
this._connectAnalyser();
}catch{
this.audioContext=null;
}
// Setup event listeners
['a','b'].forEach(k=>{
const p=this.players[k];
p.addEventListener('ended',()=>{
if(k===this.activeKey)this.beginCrossfade({fast:true});
});
p.addEventListener('canplay',()=>{
if(k===this.activeKey&&this.started){
this._setupNextCrossfade(p);
}
});
p.addEventListener('error',()=>{
if(k===this.activeKey)this.beginCrossfade({fast:true});
});
});
}
_connectAnalyser(){
if(!this.audioContext||!this.analyser)return;
try{
const activePlayer=this.players[this.activeKey];
if(activePlayer&&!activePlayer._sourceNode){
activePlayer._sourceNode=this.audioContext.createMediaElementSource(activePlayer);
activePlayer._sourceNode.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
}
}catch{}
}
_setupNextCrossfade(player){
if(!player.duration)return;
const fadeTime=Math.max(FADE_MS+1000,player.duration*1000-FADE_MS-500);
clearTimeout(this._prefadeTimer);
this._prefadeTimer=setTimeout(()=>this.beginCrossfade({}),fadeTime);
}
start(){
this.started=true;this.updateUITrack();
if(this.audioContext&&this.audioContext.state==='suspended'){
this.audioContext.resume();
}
this._loadOn(this.activeKey,this.tracks[this.trackIndex],{fadeIn:START_FADE_IN});
}
_loadOn(k,t,{fadeIn}={fadeIn:true}){
if(!k||!t||!this.players[k])return;
const p=this.players[k];
p.src=t.src;
p.load();
if(fadeIn){
this._fadeVolumes({toKey:k,ms:FADE_MS});
}else{
p.volume=this.muted?0:1;
}
// Connect to analyser if this is the active player
if(k===this.activeKey){
this._connectAnalyser();
}
// Auto-play when ready
p.addEventListener('canplay',()=>{
if(!this.muted||fadeIn)p.play().catch(()=>{});
},{once:true});
}
beginCrossfade({fast=false}={}){
clearInterval(this._fadeIv);clearTimeout(this._prefadeTimer);
const n=(this.trackIndex+1)%this.tracks.length,t=this.tracks[n];
const f=this.activeKey,o=this.inactiveKey;
this._loadOn(o,t,{fadeIn:false});
setTimeout(()=>{
this._fadeVolumes({fromKey:f,toKey:o,ms:fast?Math.min(1200,FADE_MS):FADE_MS});
this.trackIndex=n;this.updateUITrack();
},fast?200:500);
}
prev(){
clearInterval(this._fadeIv);clearTimeout(this._prefadeTimer);
const p=(this.trackIndex-1+this.tracks.length)%this.tracks.length,t=this.tracks[p];
const f=this.activeKey,o=this.inactiveKey;
this._loadOn(o,t,{fadeIn:false});
setTimeout(()=>{
this._fadeVolumes({fromKey:f,toKey:o,ms:FADE_MS});
this.trackIndex=p;this.updateUITrack();
},300);
}
next(){this.beginCrossfade({fast:false})}
toggleMute(){
this.muted=!this.muted;
const p=this.players[this.activeKey];
if(p){
... 551 lines truncated (951 total)#!/usr/bin/env sh
# @shared_functions.sh — shared helpers for DEPLOY/rails/* scripts
# Source this file; do not execute directly.
#
# Conventions:
# APP_DIR — full path to the Rails app (caller must set, e.g. /home/brgen/app)
# APP_PORT — TCP port Falcon listens on (default 3000)
: "${APP_PORT:=3000}"
readonly APP_PORT
# Ensure required environment variables are present
: "${APP_DIR:?APP_DIR must be set before sourcing}"
set -eu
set -o pipefail
IFS=$'\n\t'
# ── Logging ──────────────────────────────────────────────────────────────────
log() { printf '%b\n' "$(printf '\033[36m==>\033[0m %s' "$*")"; }
log_ok() { printf '%b\n' "$(printf '\033[32m✔\033[0m %s' "$*")"; }
log_warn() { printf '%b\n' "$(printf '\033[33mWARN\033[0m %s' "$*")" >&2; }
log_err() { printf '%b\n' "$(printf '\033[31mERR\033[0m %s' "$*")" >&2; }
# ── Precondition checks ───────────────────────────────────────────────────────
command_exists() {
cmd=$1
if ! command -v "$cmd" >/dev/null 2>&1; then
log_err "Required command not found: $cmd"
exit 1
fi
log_ok "$cmd found"
}
check_app_exists() {
sentinel=$1
if [ -f "$sentinel" ]; then
log_warn "Already set up ($sentinel exists). Skipping."
return 0
fi
return 1
}
# ── App scaffolding ───────────────────────────────────────────────────────────
setup_full_app() {
app_dir=$1
mkdir -p "$(dirname "$app_dir")"
if [ ! -f "$app_dir/config/application.rb" ]; then
log "Creating Rails 8 app at $app_dir"
rails new "$app_dir" \
--database=sqlite3 \
--skip-git \
--asset-pipeline=propshaft \
--javascript=importmap \
--skip-test
fi
cd "$app_dir"
# Ensure Falcon is the server adapter
if ! grep -q '"falcon"' Gemfile; then
printf '%s\n' 'gem "falcon"' >> Gemfile
bundle install --quiet
fi
log_ok "Working in: $app_dir"
}
# ── Gem helpers ───────────────────────────────────────────────────────────────
install_gem() {
gem=$1 version=${2:-}
if ! grep -q "\"$gem\"" Gemfile 2>/dev/null; then
if [ -n "$version" ]; then
printf '%s\n' "gem \"$gem\", \"$version\"" >> Gemfile
else
printf '%s\n' "gem \"$gem\"" >> Gemfile
fi
bundle install --quiet
log_ok "gem $gem installed"
else
log_ok "gem $gem already present"
fi
}
# ── Database helpers ──────────────────────────────────────────────────────────
db_setup() {
RAILS_ENV=production bin/rails db:create db:migrate 2>&1 |
grep -E "Created|migrated|error" || :
log_ok "database ready"
}
# ── relayd helpers ────────────────────────────────────────────────────────────
relayd_add_relay() {
host=$1 port=$2
table_name=${host%%.*}
conf=/etc/relayd.conf
if grep -q "table <${table_name}>" "$conf" 2>/dev/null; then
return 0
fi
sudo sh -c "cat >> $conf <<'EOF'
table <${table_name}> { 127.0.0.1 }
EOF"
log_ok "relayd table <${table_name}> → :${port} added (reload relayd to apply)"
}
# ── rc.d helpers ──────────────────────────────────────────────────────────────
install_rcd() {
svc=$1 app_dir=$2 port=$3 user=$4
rcd="/etc/rc.d/${svc}"
[ -f "$rcd" ] && return 0
sudo sh -c "cat > $rcd <<'EOF'
#!/bin/ksh
daemon_execdir='${app_dir}'
daemon='${app_dir}/bin/rails'
daemon_flags='server -b 0.0.0.0 -p ${port} -e production'
daemon_user='${user}'
. /etc/rc.d/rc.subr
rc_cmd \$1
EOF"
sudo chmod 755 "$rcd"
sudo rcctl enable "$svc"
log_ok "rc.d/${svc} installed"
}
# ── Asset helpers ─────────────────────────────────────────────────────────────
generate_default_css() {
mkdir -p app/assets/stylesheets
cat > app/assets/stylesheets/application.css <<'CSS'
:root {
--bg: #0a0a0a;
--surface: #1a1a1a;
--text: #e8eaed;
--text-dim: #9aa0a6;
--primary: #8ab4f8;
--accent: #ff4500;
--radius: 8px;
--space: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
main { max-width: 1200px; margin: 0 auto; padding: calc(var(--space) * 2); }
a { color: var(--primary); text-decoration: none; }
.card { background: var(--surface); border-radius: var(--radius); padding: calc(var(--space) * 2); margin-bottom: calc(var(--space) * 2); }
@media (max-width: 768px) { main { padding: var(--space); } }
CSS
log_ok "default CSS written"
}
generate_all_stimulus_controllers() {
mkdir -p app/javascript/controllers
cat > app/javascript/controllers/index.js <<'JS'
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
JS
log_ok "Stimulus controllers index written"
}# amber
The world's largest social network for fashion. Rails 8 · PostgreSQL + pgvector · Redis · Falcon.
## Features
- **Import your wardrobe** — photograph each item; Amber removes backgrounds, upscales, relights, post-processes for professional shots of you (or AI fashion models) wearing them.
- **Mix & Match Magic** — four counter-rotating Stimulus carousels propose new outfit combinations, weighted by your evolving taste vector.
- **Closet Organization** — cleaning/storage tips drawn from architecture, interior design, and zen minimalism.
- **Wardrobe Analytics** — track usage, cost-per-wear, surface underutilized items.
- **Style Assistant** — daily outfit suggestions tuned to context and weather.
- **Shop Smarter** — surfaces newest/most-popular items from Net-a-porter et al.; supports your own affiliate links.
## Social
User profiles · activity feed · anonymous posting · public chatroom · live webcam streaming.
## Stack
CDN (Cloudflare) │ Load balancer (relayd) │ Falcon (Rails 8) │ ┌────┴──────────┐ │ │ PostgreSQL Redis
- pgvector (Action Cable)
## Deploy
```zsh
doas zsh DEPLOY/rails/amber/amber.sh
## `DEPLOY/rails/amber/amber.sh`
```bash
#!/usr/bin/env zsh
# amber.sh — deploys tracked Rails tree at app/ as %APP_NAME%
set -euo pipefail
APP_NAME=%APP_NAME%
APP_DIR=/home/${APP_NAME}/app
APP_PORT=61352
APP_DOMAIN=amber.brgen.no
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}/app
. "${SCRIPT_DIR:h}/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1 }
log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
typeset bundle_home="/home/${APP_NAME}/.bundle"
if [[ ! -d ${bundle_home}/gems ]]; then
log "Bootstrapping gems from amber"
doas mkdir -p "$bundle_home"
doas cp -R /home/amber/.bundle/gems "$bundle_home/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
print "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bundle install --deployment --without development:test"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t app .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name app app
# 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.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
bundle exec bootsnap precompile -j 1 --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times.
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# 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
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# 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"]
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# 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
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
gem "kamal", 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"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# 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
gem "pagy", "~> 9.3"
gem "ruby-openai"
gem "dartsass-rails"
gem "falcon"
# README
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
* Ruby version
* System dependencies
* Configuration
* Database creation
* Database initialization
* How to run the test suite
* Services (job queues, cache servers, search engines, etc.)
* Deployment instructions
* ...# 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
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
endclass AiController < ApplicationController
before_action :require_authentication
def analyze_item
item = Current.user.items.find(params[:id])
result = WardrobeAiService.new(Current.user).analyze_joy(item)
item.update!(spark_joy: result["sparks_joy"]) if result["sparks_joy"].in?([true, false])
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("item_#{item.id}_analysis", partial: "ai/analysis", locals: { result: result, item: item }) }
format.json { render json: result }
end
end
def tag_item
item = Current.user.items.find(params[:id])
result = WardrobeAiService.new(Current.user).enclothed_cognition_tag(item)
item.update!(mood_effect: result["mood_effect"], life_phase: result["life_phase"])
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("item_#{item.id}_tags", partial: "ai/item_tags", locals: { item: item.reload, result: result }) }
format.html { redirect_to item }
end
end
def suggest_outfits
@suggestions = WardrobeAiService.new(Current.user).suggest_outfits(
occasion: params[:occasion], season: params[:season]
)
end
def declutter_guide
@candidates = WardrobeAiService.new(Current.user).declutter_candidates
end
def capsule
@result = WardrobeAiService.new(Current.user).capsule_optimizer
end
def color_palette
@result = WardrobeAiService.new(Current.user).color_palette_analysis
end
def search
@query = params[:q].to_s.strip
if @query.present?
result = WardrobeAiService.new(Current.user).natural_language_search(@query)
ids = Array(result["item_ids"])
@items = Current.user.items.where(id: ids)
@explanation = result["explanation"]
else
@items = Current.user.items.none
end
end
def mood_board
@description = params[:description].to_s.strip
if @description.present?
result = WardrobeAiService.new(Current.user).mood_board_match(@description)
ids = Array(result["item_ids"])
@items = Current.user.items.where(id: ids)
@outfit_name = result["outfit_name"]
@reasoning = result["description"]
end
end
def occasion_map
@coverage = Item::OCCASIONS.each_with_object({}) do |occ, h|
h[occ] = Current.user.items.by_occasion(occ).to_a
end
end
endclass ApplicationController < ActionController::Base
include Authentication
include Pagy::Backend
allow_browser versions: :modern
endmodule Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
endclass FollowsController < ApplicationController
def create
user = User.find(params[:user_id])
Current.user.follows_as_follower.find_or_create_by!(followee: user) unless Current.user == user
redirect_back fallback_location: user_path(user)
end
def destroy
user = User.find(params[:user_id])
Current.user.follows_as_follower.find_by(followee: user)&.destroy!
redirect_back fallback_location: user_path(user)
end
endclass HomeController < ApplicationController
def index
return unless authenticated?
items = Current.user.items
@items_count = items.count
@joy_count = items.joy.count
@never_worn_count = items.never_worn.count
@worn_this_month = items.where("updated_at > ?", 30.days.ago).where("times_worn > 0").count
@utilization_rate = @items_count > 0 ? (@worn_this_month * 100.0 / @items_count).round : 0
@worst_cpw = items.where("price > 0 AND times_worn > 0")
.select { |i| i.cost_per_wear }
.sort_by { |i| -i.cost_per_wear }
.first(3)
@aging_unworn = items.aging_unworn.limit(4)
@recent_items = items.recent.limit(6)
@planned_this_week = Current.user.planned_outfits.this_week.includes(:outfit)
@weather = WeatherService.today
end
endclass ItemsController < ApplicationController
before_action :require_authentication
before_action :set_item, only: %i[show edit update destroy spark_joy declutter wear]
before_action :authorize!, only: %i[edit update destroy spark_joy declutter wear]
def index
@pagy, @items = pagy(Current.user.items.recent)
end
def show; end
def new
@item = Current.user.items.build
end
def create
@item = Current.user.items.build(item_params)
@item.save ? redirect_to(@item, notice: "Item added") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@item.update(item_params) ? redirect_to(@item, notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@item.destroy
redirect_to items_path, notice: "Removed from wardrobe"
end
def spark_joy
@item.update!(spark_joy: true)
redirect_to items_path, notice: "This item sparks joy!"
end
def declutter
@item.update!(spark_joy: false)
redirect_to items_path, notice: "Marked for declutter"
end
def wear
@item.wear!
redirect_to @item, notice: "Worn today — +1"
end
private
def set_item = @item = Item.find(params[:id])
def authorize!
redirect_to(items_path, alert: "Unauthorized") unless @item.user == Current.user
end
def item_params
params.require(:item).permit(
:title, :category, :color, :size, :material,
:brand, :price, :times_worn, :purchase_date,
:mood_effect, :life_phase, :occasion_tags, :season,
photos: []
)
end
endclass OutfitsController < ApplicationController
before_action :require_authentication
before_action :set_outfit, only: %i[show edit update destroy like]
before_action :authorize!, only: %i[edit update destroy]
def index
@pagy, @outfits = pagy(Current.user.outfits.order(created_at: :desc))
end
def show; end
def new
@outfit = Current.user.outfits.build
end
def create
@outfit = Current.user.outfits.build(outfit_params)
@outfit.save ? redirect_to(@outfit, notice: "Outfit created") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@outfit.update(outfit_params) ? redirect_to(@outfit, notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@outfit.destroy
redirect_to outfits_path, notice: "Outfit deleted"
end
def like
@outfit.like!
redirect_to @outfit
end
private
def set_outfit = @outfit = Outfit.find(params[:id])
def authorize!
redirect_to(outfits_path, alert: "Unauthorized") unless @outfit.user == Current.user
end
def outfit_params
params.require(:outfit).permit(:name, :description, :category, :season, :occasion)
end
endclass PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
endclass PlannedOutfitsController < ApplicationController
before_action :require_authentication
def index
@planned = Current.user.planned_outfits.upcoming.includes(:outfit)
@outfits = Current.user.outfits.order(:name)
end
def create
@plan = Current.user.planned_outfits.build(plan_params)
@plan.save ? redirect_to(planned_outfits_path, notice: "Planned") : redirect_to(planned_outfits_path, alert: @plan.errors.full_messages.first)
end
def destroy
Current.user.planned_outfits.find(params[:id]).destroy!
redirect_to planned_outfits_path
end
private
def plan_params = params.require(:planned_outfit).permit(:outfit_id, :planned_date, :notes)
endclass PostsController < ApplicationController
before_action :set_post, only: %i[show destroy like]
def index
@pagy, @posts = pagy(Post.recent.includes(:user, :outfit, :item))
end
def feed
@pagy, @posts = pagy(Current.user.feed_posts.includes(:user, :outfit, :item))
end
def show; end
def new
@post = Post.new
end
def create
@post = Current.user.posts.build(post_params)
@post.save ? redirect_to(posts_path, notice: "Posted") : render(:new, status: :unprocessable_entity)
end
def destroy
@post.destroy!
redirect_to posts_path
end
def like
@post.like!
redirect_back fallback_location: posts_path
end
private
def set_post = @post = Post.find(params[:id])
def post_params = params.require(:post).permit(:body, :outfit_id, :item_id)
endclass RegistrationsController < ApplicationController
allow_unauthenticated_access only: %i[new create]
def new = render
def create
user = User.new(registration_params)
if user.save
start_new_session_for user
redirect_to root_path, notice: "Welcome to Amber!"
else
render :new, status: :unprocessable_entity
end
end
private
def registration_params
params.require(:user).permit(:email_address, :password, :password_confirmation)
end
endclass SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
endclass UsersController < ApplicationController
def show
@user = User.find(params[:id])
@items = @user.items.recent.limit(12)
@outfits = @user.outfits.order(created_at: :desc).limit(6)
@posts = @user.posts.recent.limit(10)
end
endmodule ApplicationHelper
include Pagy::Frontend
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["select", "grid"]
filter() {
const val = this.selectTarget.value
this.gridTarget.querySelectorAll("[data-category]").forEach(c => {
c.hidden = val && c.dataset.category !== val
})
}
}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}import { application } from "./application"
// controllers are auto-imported via eagerLoadControllersFrom in application.js
// or listed here explicitly:import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}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
endclass ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
endclass PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
endclass ApplicationRecord < ActiveRecord::Base
primary_abstract_class
endclass Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
endclass Follow < ApplicationRecord
belongs_to :follower, class_name: "User", touch: true
belongs_to :followee, class_name: "User", touch: true
validates :follower_id, uniqueness: { scope: :followee_id }
validate :no_self_follow
private
def no_self_follow
errors.add(:followee, "can't follow yourself") if follower_id == followee_id
end
endclass Item < ApplicationRecord
belongs_to :user
has_many :outfit_items, dependent: :destroy
has_many :outfits, through: :outfit_items
has_many_attached :photos
validates :title, :category, presence: true
validates :times_worn, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
validates :price, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
broadcasts_refreshes
scope :joy, -> { where(spark_joy: true) }
scope :by_category, ->(c) { where(category: c) }
scope :by_mood, ->(m) { where(mood_effect: m) }
scope :by_occasion, ->(o) { where("occasion_tags LIKE ?", "%#{o}%") }
scope :current_self, -> { where(life_phase: "current") }
scope :recent, -> { order(created_at: :desc) }
scope :worn_most, -> { order(times_worn: :desc) }
scope :never_worn, -> { where("times_worn = 0 OR times_worn IS NULL") }
scope :aging_unworn, -> { never_worn.where("purchase_date < ?", 6.months.ago) }
CATEGORIES = %w[Tops Bottoms Dresses Shoes Accessories Outerwear].freeze
SEASONS = %w[Spring Summer Autumn Winter All-Season].freeze
MOOD_EFFECTS = %w[energising calming confident playful neutral].freeze
LIFE_PHASES = %w[current past-self aspirational].freeze
OCCASIONS = %w[work casual formal gym date travel].freeze
def cost_per_wear
return nil unless price.present? && times_worn.to_i > 0
(price / times_worn).round(2)
end
def occasions
occasion_tags.to_s.split(",").map(&:strip)
end
def wear!
increment!(:times_worn)
touch
end
endclass Outfit < ApplicationRecord
belongs_to :user
has_many :outfit_items, dependent: :destroy
has_many :items, through: :outfit_items
validates :name, presence: true
broadcasts_refreshes
def like!
increment!(:likes_count)
end
endclass OutfitItem < ApplicationRecord
belongs_to :outfit
belongs_to :item
validates :outfit, :item, presence: true
validates :item_id, uniqueness: { scope: :outfit_id }
default_scope { order(:position) }
endclass PlannedOutfit < ApplicationRecord
belongs_to :user
belongs_to :outfit
validates :planned_date, presence: true
validates :planned_date, uniqueness: { scope: :user_id }
scope :upcoming, -> { where("planned_date >= ?", Date.today).order(:planned_date) }
scope :this_week, -> { where(planned_date: Date.today..7.days.from_now) }
broadcasts_refreshes
endclass Post < ApplicationRecord
belongs_to :user
belongs_to :outfit, optional: true, touch: true
belongs_to :item, optional: true, touch: true
validates :body, presence: true, length: { maximum: 500 }
scope :recent, -> { order(created_at: :desc) }
broadcasts_refreshes
def like! = increment!(:likes_count)
endclass Session < ApplicationRecord
belongs_to :user
endclass User < ApplicationRecord
has_secure_password
has_many :posts, dependent: :destroy
has_many :items, dependent: :destroy
has_many :outfits, dependent: :destroy
has_many :planned_outfits, dependent: :destroy
has_many :follows_as_follower, class_name: "Follow", foreign_key: :follower_id, dependent: :destroy
has_many :follows_as_followee, class_name: "Follow", foreign_key: :followee_id, dependent: :destroy
has_many :following, through: :follows_as_follower, source: :followee
has_many :followers, through: :follows_as_followee, source: :follower
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
broadcasts_refreshes
def following?(other) = follows_as_follower.exists?(followee: other)
def feed_posts = Post.where(user: [self] + following.to_a).recent
end# frozen_string_literal: true
class WardrobeAiService
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
MODEL = "google/gemini-2.0-flash-001"
def initialize(user)
@user = user
@client = OpenAI::Client.new(
access_token: ENV.fetch("OPENROUTER_API_KEY"),
uri_base: OPENROUTER_BASE
)
end
def analyze_joy(item)
prompt = <<~PROMPT
Analyze this clothing item from a Marie Kondo perspective.
Reply with JSON: {"sparks_joy": true/false, "reason": "brief explanation", "suggestion": "action to take"}
Item: #{item.title}
Category: #{item.category}
Color: #{item.color}
Times worn: #{item.times_worn || 0}
Age: #{item.purchase_date ? "#{((Date.today - item.purchase_date) / 365).to_i} years" : "unknown"}
PROMPT
chat(prompt).tap do |r|
r["sparks_joy"] = nil unless r.key?("sparks_joy")
r["reason"] ||= "Analysis unavailable"
r["suggestion"] ||= "Trust your instincts"
end
end
def suggest_outfits(occasion: nil, season: nil)
items_summary = @user.items.joy.limit(20).map { |i| "#{i.title} (#{i.category}, #{i.color})" }.join(", ")
prompt = <<~PROMPT
Suggest 3 outfit combinations from these wardrobe items.
#{occasion ? "Occasion: #{occasion}" : ""}
#{season ? "Season: #{season}" : ""}
Items: #{items_summary}
Reply with JSON: {"outfits": [{"name": "outfit name", "items": ["item1", ...], "description": "why it works"}]}
PROMPT
chat(prompt)["outfits"] || []
end
def declutter_candidates
@user.items.aging_unworn.order(price: :desc)
end
def capsule_optimizer
catalog = @user.items.map { |i| "#{i.id}:#{i.title}(#{i.category},#{i.color})" }.join("; ")
prompt = <<~P
You are a capsule wardrobe expert. Given this wardrobe catalog, select a minimum keep-set
that maximises outfit combinations. For each item return: keep/consider/release and reason.
Respond with JSON: {"items":[{"id":N,"title":"...","decision":"keep|consider|release","reason":"..."}],"gap_items":["description of missing pieces"]}
Catalog: #{catalog}
P
chat(prompt)
end
def color_palette_analysis
items_desc = @user.items.map { |i| "#{i.title}: #{i.color}" }.join(", ")
prompt = <<~P
Analyse this wardrobe color list and identify the dominant palette, harmony gaps,
and any clashing items. Map to a seasonal color system where possible.
Respond with JSON: {"palette":"...","season_type":"...","harmonious":["item desc"],"clashing":["item desc"],"suggestions":["..."]}
Items: #{items_desc}
P
chat(prompt)
end
def natural_language_search(query)
catalog = @user.items.map { |i| "id=#{i.id} #{i.title} #{i.category} #{i.color} #{i.material} #{i.occasion_tags} #{i.season}" }.join("\n")
prompt = <<~P
From this wardrobe, find items matching: "#{query}"
Return JSON: {"item_ids":[array of matching ids],"explanation":"..."}
Wardrobe:
#{catalog}
P
chat(prompt)
end
def mood_board_match(description)
catalog = @user.items.map { |i| "id=#{i.id} #{i.title} #{i.category} #{i.color} #{i.material}" }.join("\n")
prompt = <<~P
Style reference: "#{description}"
From this wardrobe, suggest the best outfit matching that aesthetic.
Return JSON: {"item_ids":[array of ids],"outfit_name":"...","description":"why this matches"}
Wardrobe:
#{catalog}
P
chat(prompt)
end
def enclothed_cognition_tag(item)
prompt = <<~P
For this clothing item, suggest the most likely psychological/mood effect when worn.
Choose one: energising, calming, confident, playful, neutral.
Also suggest life_phase: current, past-self, or aspirational.
Reply JSON: {"mood_effect":"...","life_phase":"...","reason":"..."}
Item: #{item.title}, category: #{item.category}, color: #{item.color}, brand: #{item.brand}
P
chat(prompt)
end
private
def chat(prompt)
response = @client.chat(
parameters: {
model: MODEL,
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" }
}
)
JSON.parse(response.dig("choices", 0, "message", "content"))
rescue => e
Rails.logger.error("WardrobeAI error: #{e.message}")
{}
end
end# frozen_string_literal: true
class WeatherService
BERGEN_LAT = 60.39
BERGEN_LNG = 5.32
API_URL = "https://api.open-meteo.com/v1/forecast"
def self.today
uri = URI("#{API_URL}?latitude=#{BERGEN_LAT}&longitude=#{BERGEN_LNG}" \
"¤t=temperature_2m,weathercode,windspeed_10m" \
"&forecast_days=1")
data = JSON.parse(Net::HTTP.get(uri))
current = data["current"]
{
temp: current["temperature_2m"].to_f,
code: current["weathercode"].to_i,
wind: current["windspeed_10m"].to_f,
description: decode_weather(current["weathercode"].to_i)
}
rescue => e
Rails.logger.warn("WeatherService: #{e.message}")
nil
end
def self.decode_weather(code)
case code
when 0 then "Clear"
when 1..3 then "Partly cloudy"
when 45, 48 then "Foggy"
when 51..67 then "Rainy"
when 71..77 then "Snowy"
when 80..82 then "Showers"
when 95..99 then "Thunderstorm"
else "Mixed"
end
end
end<aside class="ai-card">
<% if result["sparks_joy"].nil? %>
<p class="dim">Analysis unavailable</p>
<% else %>
<strong><%= result["sparks_joy"] ? "Sparks joy" : "Does not spark joy" %></strong>
<p><%= result["reason"] %></p>
<p class="dim"><em><%= result["suggestion"] %></em></p>
<% end %>
</aside><div id="item_<%= item.id %>_tags" class="ai-card">
<% if item.mood_effect.present? %>
<span class="tag">Mood: <%= item.mood_effect %></span>
<% end %>
<% if item.life_phase.present? %>
<span class="tag tag--phase"><%= item.life_phase %></span>
<% end %>
<% if result["reason"].present? %>
<p class="dim"><%= result["reason"] %></p>
<% end %>
</div><% content_for :title, "Capsule Optimizer" %>
<h1>Capsule Wardrobe Optimizer</h1>
<% if @result["items"] %>
<div class="capsule-list">
<% @result["items"].each do |item| %>
<div class="capsule-row capsule-row--<%= item["decision"] %>">
<span class="capsule-decision"><%= item["decision"] %></span>
<strong><%= item["title"] %></strong>
<span class="dim"><%= item["reason"] %></span>
</div>
<% end %>
</div>
<% if @result["gap_items"]&.any? %>
<h2>Gap items to consider buying</h2>
<ul><% @result["gap_items"].each do |g| %><li><%= g %></li><% end %></ul>
<% end %>
<% else %>
<p class="dim">Add more items to your wardrobe first.</p>
<% end %>
<p><%= link_to "← Dashboard", root_path %></p><% content_for :title, "Colour Palette" %>
<h1>Wardrobe Colour Palette</h1>
<% if @result["palette"] %>
<div class="ai-card">
<p><strong>Palette:</strong> <%= @result["palette"] %></p>
<% if @result["season_type"].present? %><p><strong>Seasonal type:</strong> <%= @result["season_type"] %></p><% end %>
</div>
<% if @result["clashing"]&.any? %>
<h2>Clashing items</h2>
<ul><% @result["clashing"].each do |i| %><li><%= i %></li><% end %></ul>
<% end %>
<% if @result["suggestions"]&.any? %>
<h2>Suggestions</h2>
<ul><% @result["suggestions"].each do |s| %><li><%= s %></li><% end %></ul>
<% end %>
<% else %>
<p class="dim">Not enough items to analyse.</p>
<% end %>
<p><%= link_to "← Dashboard", root_path %></p><% content_for :title, "Declutter guide" %>
<h1>Declutter guide</h1>
<% if @candidates.any? %>
<p class="dim">Items to consider letting go:</p>
<div class="item-grid"><%= render @candidates %></div>
<% else %>
<p>No declutter candidates — your wardrobe is in great shape.</p>
<% end %>
<p><%= link_to "Back", items_path %></p><% content_for :title, "Mood Board Match" %>
<h1>Mood board match</h1>
<%= form_with url: ai_mood_board_path, method: :get do |f| %>
<div class="field">
<%= f.label :description, "Describe the aesthetic or paste a style reference" %>
<%= f.text_area :description, value: @description, rows: 3, class: "input input--wide" %>
</div>
<div class="actions"><%= f.submit "Match from wardrobe", class: "btn" %></div>
<% end %>
<% if @outfit_name.present? %>
<div class="ai-card">
<h2><%= @outfit_name %></h2>
<p><%= @reasoning %></p>
</div>
<div class="item-grid"><%= render @items %></div>
<% end %><% content_for :title, "Occasion Coverage" %>
<h1>Occasion coverage map</h1>
<div class="occasion-grid">
<% @coverage.each do |occasion, items| %>
<div class="occasion-card occasion-card--<%= items.size < 2 ? 'sparse' : 'covered' %>">
<h3><%= occasion.capitalize %></h3>
<span class="occasion-count"><%= items.size %> items</span>
<% if items.size < 2 %>
<p class="dim occasion-warn">Gap — consider adding pieces</p>
<% end %>
<% items.first(3).each do |item| %>
<div class="dim"><%= link_to item.title, item %></div>
<% end %>
</div>
<% end %>
</div>
<p><%= link_to "← Dashboard", root_path %></p><% content_for :title, "Search Wardrobe" %>
<h1>Search your wardrobe</h1>
<%= form_with url: ai_search_path, method: :get do |f| %>
<div class="field-row">
<%= f.search_field :q, value: @query, placeholder: "e.g. something warm but not bulky for a meeting", autofocus: true, class: "input input--wide" %>
<%= f.submit "Search", class: "btn" %>
</div>
<% end %>
<% if @explanation.present? %>
<p class="dim"><%= @explanation %></p>
<% end %>
<% if @items&.any? %>
<div class="item-grid"><%= render @items %></div>
<% elsif @query.present? %>
<p class="dim">No matches found.</p>
<% end %><% content_for :title, "Outfit suggestions" %>
<h1>Outfit suggestions</h1>
<% @suggestions.each_with_index do |s, i| %>
<article class="ai-card">
<h2><%= s["name"] || "Option #{i + 1}" %></h2>
<p class="dim"><%= s["items"]&.join(", ") %></p>
<p><%= s["description"] %></p>
</article>
<% end %>
<p><%= link_to "Back to wardrobe", items_path %></p><% content_for :title, "Dashboard" %>
<% if authenticated? %>
<% if @weather %>
<div class="weather-bar">
<%= @weather[:description] %> · <%= @weather[:temp] %>°C
<% if @weather[:temp] < 10 %>· Wear layers<% elsif @weather[:temp] > 20 %>· Light fabrics<% end %>
</div>
<% end %>
<header class="dash-stats">
<dl>
<div><dt>Items</dt><dd><%= @items_count %></dd></div>
<div><dt>Spark joy</dt><dd><%= @joy_count %></dd></div>
<div><dt>Never worn</dt><dd><%= @never_worn_count %></dd></div>
<div><dt>Utilisation</dt><dd class="<%= @utilization_rate < 20 ? 'stat-warn' : '' %>"><%= @utilization_rate %>%</dd></div>
</dl>
<nav>
<%= link_to "Add item", new_item_path, class: "btn" %>
<%= link_to "Search wardrobe", ai_search_path, class: "btn" %>
<%= link_to "Capsule plan", ai_capsule_path, class: "btn" %>
</nav>
</header>
<% if @planned_this_week.any? %>
<section>
<h2>This week</h2>
<div class="plan-list">
<% @planned_this_week.each do |plan| %>
<div class="plan-row">
<span class="plan-date"><%= plan.planned_date.strftime("%a %-d") %></span>
<%= link_to plan.outfit.name, plan.outfit %>
<%= button_to "✕", planned_outfit_path(plan), method: :delete, class: "btn-link" %>
</div>
<% end %>
</div>
</section>
<% end %>
<% if @worst_cpw.any? %>
<section>
<h2>Worst cost-per-wear</h2>
<div class="cpw-list">
<% @worst_cpw.each do |item| %>
<div class="cpw-row">
<%= link_to item.title, item %>
<span class="cpw-val">£<%= item.cost_per_wear %>/wear · worn <%= item.times_worn %>×</span>
</div>
<% end %>
</div>
</section>
<% end %>
<% if @aging_unworn.any? %>
<section>
<h2>Aging unworn</h2>
<div class="item-grid"><%= render @aging_unworn %></div>
</section>
<% end %>
<% if @recent_items.any? %>
<h2>Recent</h2>
<div class="item-grid"><%= render @recent_items %></div>
<p><%= link_to "All items →", items_path %></p>
<% else %>
<p><%= link_to "Add your first item", new_item_path %></p>
<% end %>
<% else %>
<p>Welcome to Amber. <%= link_to "Sign in", new_session_path %> to manage your wardrobe.</p>
<% end %><%= form_with model: item, class: "form" do |f| %>
<%= render "shared/errors", object: item %>
<div class="field"><%= f.label :title %><%= f.text_field :title, autofocus: true %></div>
<div class="field">
<%= f.label :category %>
<%= f.select :category, Item::CATEGORIES, include_blank: "Select…" %>
</div>
<div class="field"><%= f.label :color %><%= f.text_field :color %></div>
<div class="field"><%= f.label :size %><%= f.text_field :size %></div>
<div class="field"><%= f.label :material %><%= f.text_field :material %></div>
<div class="field"><%= f.label :brand %><%= f.text_field :brand %></div>
<div class="field"><%= f.label :price %><%= f.number_field :price, step: "0.01", min: 0 %></div>
<div class="field"><%= f.label :purchase_date %><%= f.date_field :purchase_date %></div>
<div class="field">
<%= f.label :season %>
<%= f.select :season, Item::SEASONS, include_blank: "Select…" %>
</div>
<div class="field">
<%= f.label :occasion_tags, "Occasions (comma-separated)" %>
<%= f.text_field :occasion_tags, placeholder: "work, casual, formal" %>
</div>
<div class="field">
<%= f.label :mood_effect, "Mood effect" %>
<%= f.select :mood_effect, Item::MOOD_EFFECTS, include_blank: "Not set" %>
</div>
<div class="field">
<%= f.label :life_phase, "Life phase" %>
<%= f.select :life_phase, Item::LIFE_PHASES, include_blank: "Not set" %>
</div>
<div class="field"><%= f.label :photos %><%= f.file_field :photos, multiple: true, accept: "image/*" %></div>
<div class="actions"><%= f.submit class: "btn" %> <%= link_to "Cancel", items_path %></div>
<% end %><article class="item-card" id="<%= dom_id(item) %>" data-category="<%= item.category %>">
<% if item.photos.attached? %>
<%= image_tag item.photos.first.variant(resize_to_fill: [300, 300]), class: "item-photo" %>
<% end %>
<%= link_to item.title, item, class: "item-title" %>
<span class="dim"><%= item.category %><%= " · #{item.color}" if item.color.present? %></span>
<span class="dim">Worn <%= item.times_worn.to_i %>×<%= " · #{number_to_currency(item.price)}" if item.price? %></span>
<nav>
<%= button_to "Wear", wear_item_path(item), method: :post, class: "btn-sm" %>
<% unless item.spark_joy? %>
<%= button_to "Joy", spark_joy_item_path(item), method: :post, class: "btn-sm" %>
<% end %>
<%= link_to "Edit", edit_item_path(item), class: "btn-sm" %>
</nav>
</article><% content_for :title, "Edit" %>
<h1>Edit <%= @item.title %></h1>
<%= render "form", item: @item %><% content_for :title, "Wardrobe" %>
<%= turbo_stream_from "items" %>
<section data-controller="filter">
<header>
<h1>Wardrobe (<%= @pagy.count %>)</h1>
<%= link_to "Add item", new_item_path, class: "btn" %>
<select data-action="change->filter#filter" data-filter-target="select">
<option value="">All</option>
<% Item::CATEGORIES.each do |cat| %>
<option value="<%= cat %>"><%= cat %></option>
<% end %>
</select>
</header>
<div class="item-grid" id="items" data-filter-target="grid">
<%= render @items %>
</div>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %>
</section><% content_for :title, "Add item" %>
<h1>Add item</h1>
<%= render "form", item: @item %><% content_for :title, @item.title %>
<article class="item-detail">
<% if @item.photos.attached? %>
<div class="item-photos">
<% @item.photos.each do |p| %>
<%= image_tag p.variant(resize_to_limit: [600, 600]) %>
<% end %>
</div>
<% end %>
<header>
<h1><%= @item.title %></h1>
<% if @item.spark_joy? %><span class="badge">Sparks joy</span><% end %>
</header>
<dl class="meta">
<dt>Category</dt><dd><%= @item.category %></dd>
<% if @item.color.present? %><dt>Color</dt><dd><%= @item.color %></dd><% end %>
<% if @item.size.present? %><dt>Size</dt><dd><%= @item.size %></dd><% end %>
<% if @item.material.present? %><dt>Material</dt><dd><%= @item.material %></dd><% end %>
<% if @item.brand.present? %><dt>Brand</dt><dd><%= @item.brand %></dd><% end %>
<% if @item.price? %><dt>Price</dt><dd><%= number_to_currency(@item.price) %></dd><% end %>
<dt>Worn</dt><dd><%= @item.times_worn.to_i %> times</dd>
<% if @item.purchase_date? %><dt>Purchased</dt><dd><%= @item.purchase_date.strftime("%b %Y") %></dd><% end %>
</dl>
<% if @item.mood_effect.present? || @item.life_phase.present? %>
<div class="tag-row">
<% if @item.mood_effect.present? %><span class="tag">Mood: <%= @item.mood_effect %></span><% end %>
<% if @item.life_phase.present? %><span class="tag tag--phase"><%= @item.life_phase %></span><% end %>
</div>
<% end %>
<div id="item_<%= @item.id %>_analysis"></div>
<div id="item_<%= @item.id %>_tags"></div>
<nav>
<%= button_to "Worn today", wear_item_path(@item), method: :post, class: "btn" %>
<%= button_to "AI analyse", ai_analyze_item_path(@item), method: :post, class: "btn" %>
<%= button_to "AI tag mood", ai_tag_item_path(@item), method: :post, class: "btn" %>
<%= link_to "Edit", edit_item_path(@item), class: "btn" %>
<%= button_to "Delete", @item, method: :delete, data: { turbo_confirm: "Remove this item?" }, class: "btn btn-danger" %>
</nav>
</article><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="turbo-cache-control" content="no-preview">
<title><%= content_for?(:title) ? yield(:title) + " – Amber" : "Amber" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<nav>
<%= link_to root_path, class: "brand" do %>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.38 3.46L16 2a4 4 0 01-8 0L3.62 3.46a2 2 0 00-1.34 2.23l.58 3.57a1 1 0 00.99.86H5v10c0 1.1.9 2 2 2h10a2 2 0 002-2V10h1.15a1 1 0 00.99-.86l.58-3.57a2 2 0 00-1.34-2.23z"/></svg>
Amber
<% end %>
<% if authenticated? %>
<%= link_to "Feed", feed_posts_path %>
<%= link_to "Post", new_post_path %>
<%= link_to "Wardrobe", items_path %>
<%= link_to "Outfits", outfits_path %>
<%= link_to "Planner", planned_outfits_path %>
<%= link_to "Search", ai_search_path %>
<%= link_to "Occasions", ai_occasions_path %>
<%= link_to "Sign out", session_path, data: { turbo_method: :delete } %>
<% else %>
<%= link_to "Sign in", new_session_path %>
<%= link_to "Sign up", new_registration_path %>
<% end %>
</nav>
<%= render "shared/flash" %>
<main><%= yield %></main>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %><%= form_with model: outfit, class: "form" do |f| %>
<%= render "shared/errors", object: outfit %>
<div class="field"><%= f.label :name %><%= f.text_field :name, autofocus: true %></div>
<div class="field"><%= f.label :description %><%= f.text_area :description, rows: 3 %></div>
<div class="field">
<%= f.label :category %>
<%= f.select :category, %w[Casual Formal Work Workout Evening], include_blank: "Select…" %>
</div>
<div class="field">
<%= f.label :season %>
<%= f.select :season, Item::SEASONS, include_blank: "Select…" %>
</div>
<div class="field"><%= f.label :occasion %><%= f.text_field :occasion %></div>
<div class="actions"><%= f.submit class: "btn" %> <%= link_to "Cancel", outfits_path %></div>
<% end %><article class="item-card" id="<%= dom_id(outfit) %>">
<%= link_to outfit.name, outfit, class: "item-title" %>
<span class="dim"><%= [outfit.season, outfit.occasion].compact.join(" · ") %></span>
<span class="dim"><%= outfit.items.count %> items · <%= outfit.likes_count %> likes</span>
</article><% content_for :title, "Edit outfit" %>
<h1>Edit <%= @outfit.name %></h1>
<%= render "form", outfit: @outfit %><% content_for :title, "Outfits" %>
<%= turbo_stream_from "outfits" %>
<header>
<h1>Outfits</h1>
<%= link_to "New outfit", new_outfit_path, class: "btn" %>
</header>
<div class="item-grid" id="outfits"><%= render @outfits %></div>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %><% content_for :title, "New outfit" %>
<h1>New outfit</h1>
<%= render "form", outfit: @outfit %><% content_for :title, @outfit.name %>
<header>
<h1><%= @outfit.name %></h1>
<span class="dim"><%= [@outfit.season, @outfit.category, @outfit.occasion].compact.join(" · ") %></span>
</header>
<p><%= @outfit.description %></p>
<div class="item-grid"><%= render @outfit.items %></div>
<nav>
<%= button_to "Like (#{@outfit.likes_count})", like_outfit_path(@outfit), method: :post, class: "btn" %>
<%= link_to "Edit", edit_outfit_path(@outfit), class: "btn" %>
<%= button_to "Delete", @outfit, method: :delete, data: { turbo_confirm: "Delete?" }, class: "btn btn-danger" %>
</nav><div class="auth-form">
<h1>New password</h1>
<%= form_with model: @user, url: password_path(params[:token]), method: :put do |f| %>
<div class="field">
<%= f.label :password, "New password" %>
<%= f.password_field :password, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :password_confirmation, "Confirm password" %>
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="actions">
<%= f.submit "Set password", class: "btn btn--primary" %>
</div>
<% end %>
</div><div class="auth-form">
<h1>Reset password</h1>
<%= form_with url: passwords_path do |f| %>
<div class="field">
<%= f.label :email_address, "Email" %>
<%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
</div>
<div class="actions">
<%= f.submit "Send reset link", class: "btn btn--primary" %>
</div>
<% end %>
</div><p>
You can reset your password on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.<% content_for :title, "Planner" %>
<%= turbo_stream_from "planned_outfits" %>
<h1>Outfit Planner</h1>
<%= form_with model: PlannedOutfit.new, url: planned_outfits_path do |f| %>
<div class="field-row">
<%= f.date_field :planned_date, min: Date.today, class: "input" %>
<%= f.select :outfit_id, @outfits.map { |o| [o.name, o.id] }, { include_blank: "Select outfit…" } %>
<%= f.text_field :notes, placeholder: "Notes…" %>
<%= f.submit "Plan it", class: "btn" %>
</div>
<% end %>
<div class="plan-list">
<% @planned.each do |plan| %>
<div class="plan-row">
<span class="plan-date"><%= plan.planned_date.strftime("%A %-d %b") %></span>
<%= link_to plan.outfit.name, plan.outfit %>
<% if plan.notes.present? %><span class="dim"><%= plan.notes %></span><% end %>
<%= button_to "✕", planned_outfit_path(plan), method: :delete, class: "btn-link" %>
</div>
<% end %>
<% if @planned.empty? %>
<p class="dim">No outfits planned yet.</p>
<% end %>
</div><article>
<header>
<%= link_to post.user.email_address.split("@").first, user_path(post.user) %>
<time datetime="<%= post.created_at.iso8601 %>"><%= time_ago_in_words(post.created_at) %> ago</time>
</header>
<p><%= post.body %></p>
<% if post.outfit %><p><em>Outfit: <%= link_to post.outfit.name, outfit_path(post.outfit) %></em></p><% end %>
<% if post.item %><p><em>Item: <%= link_to post.item.title, item_path(post.item) %></em></p><% end %>
<footer>
<%= button_to "♥ #{post.likes_count}", like_post_path(post), method: :post %>
<% if post.user == Current.user %>
<%= button_to "Delete", post_path(post), method: :delete, data: { turbo_confirm: "Delete?" } %>
<% end %>
</footer>
</article><h1>Your Feed</h1>
<%= link_to "New post", new_post_path, class: "btn" %>
<%= render @posts %>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %><%= turbo_stream_from "posts" %>
<h1>Community</h1>
<%= render @posts %>
<%= pagy_nav(@pagy) if @pagy.pages > 1 %><h1>Share a look</h1>
<%= form_with model: @post do |f| %>
<%= render "shared/errors", object: @post %>
<div class="field">
<%= f.label :body, "What are you wearing?" %>
<%= f.text_area :body, rows: 3, maxlength: 500, placeholder: "Share your outfit…" %>
</div>
<div class="field">
<%= f.label :outfit_id, "Tag an outfit (optional)" %>
<%= f.select :outfit_id, Current.user.outfits.map { |o| [o.name, o.id] }, { include_blank: "—" } %>
</div>
<div class="field">
<%= f.label :item_id, "Tag an item (optional)" %>
<%= f.select :item_id, Current.user.items.map { |i| [i.title, i.id] }, { include_blank: "—" } %>
</div>
<div class="actions"><%= f.submit "Post", class: "btn btn--primary" %></div>
<% end %><%= turbo_stream_from @post %>
<%= render @post %>
<%= link_to 'Back', posts_path %>{
"name": "App",
"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": "App.",
"theme_color": "red",
"background_color": "red"
}// 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)
// }
// })
// )
// })<div class="auth-form">
<h1>Create account</h1>
<%= form_with model: User.new, url: registration_path do |f| %>
<div class="field">
<%= f.label :email_address, "Email" %>
<%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password, autocomplete: "new-password" %>
</div>
<div class="field">
<%= f.label :password_confirmation, "Confirm password" %>
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>
<div class="actions">
<%= f.submit "Create account", class: "btn btn--primary" %>
</div>
<p><%= link_to "Already have an account? Sign in", new_session_path %></p>
<% end %>
</div><div class="auth-form">
<h1>Sign in</h1>
<%= form_with url: session_path do |f| %>
<%= render "shared/errors", object: f.object if f.object.respond_to?(:errors) %>
<div class="field">
<%= f.label :email_address, "Email" %>
<%= f.email_field :email_address, autofocus: true, autocomplete: "email" %>
</div>
<div class="field">
<%= f.label :password %>
<%= f.password_field :password, autocomplete: "current-password" %>
</div>
<div class="actions">
<%= f.submit "Sign in", class: "btn btn--primary" %>
</div>
<p><%= link_to "Forgot password?", new_password_path %></p>
<% end %>
</div><% if object.errors.any? %>
<div class="errors">
<% object.errors.full_messages.each do |msg| %>
<p class="error-msg"><%= msg %></p>
<% end %>
</div>
<% end %><% flash.each do |type, msg| %>
<div class="flash flash--<%= type %>"><%= msg %></div>
<% end %><%= pagy_nav(pagy) if pagy.pages > 1 %><%= turbo_stream_from @user %>
<header class="profile-header">
<h1><%= @user.email_address.split("@").first %></h1>
<p><%= @user.items.count %> items · <%= @user.followers.count %> followers · <%= @user.following.count %> following</p>
<% if authenticated? && Current.user != @user %>
<% if Current.user.following?(@user) %>
<%= button_to "Unfollow", unfollow_user_path(@user), method: :delete, class: "btn" %>
<% else %>
<%= button_to "Follow", follow_user_path(@user), method: :post, class: "btn btn--primary" %>
<% end %>
<% end %>
</header>
<h2>Recent items</h2>
<div class="item-grid">
<% @items.each do |item| %>
<%= link_to item_path(item) do %>
<% if item.photos.attached? %>
<%= image_tag item.photos.first, alt: item.title %>
<% else %>
<div class="item-placeholder"><%= item.category %></div>
<% end %>
<p><%= item.title %></p>
<% end %>
<% end %>
</div>
<h2>Posts</h2>
<%= render @posts %>require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
endENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" # Set up gems listed in the Gemfile.
require "bootsnap/setup" # Speed up boot time by caching expensive operations.# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit.
# CVEs that are not relevant to the application can be enumerated on the ignore list below.
ignore:
- CVE-THAT-DOES-NOT-APPLY# 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.daydefault: &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# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# 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: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: app.example.com
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.9
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# Load the Rails application.
require_relative "application"
# Initialize the Rails application.
Rails.application.initialize!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
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Suppress logger output for asset requests.
config.assets.quiet = 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!
endrequire "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 the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# 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 bin/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# 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# frozen_string_literal: true
load :rack, :supervisor
hostname = File.basename(__dir__)
port = ENV.fetch("PORT", 61352).to_i
rack hostname do
endpoint Async::HTTP::Endpoint.parse("http://0.0.0.0:\#{port}")
end# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.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# 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
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end# 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
]# 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"
# endrequire "pagy/extras/overflow"
Pagy::DEFAULT[:items] = 25
Pagy::DEFAULT[:overflow] = :last_pagerequire "net/http"
require "uri"
require "json"# 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"# 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. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# 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"]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# 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 12Rails.application.routes.draw do
resource :registration, only: %i[new create]
resource :session
resources :passwords, param: :token
resources :items do
member do
post :spark_joy
post :declutter
post :wear
end
end
resources :outfits do
member { post :like }
end
resources :planned_outfits, only: %i[index create destroy]
resources :posts, only: %i[index show new create destroy] do
member { post :like }
collection { get :feed }
end
resources :users, only: :show do
member { post :follow; delete :unfollow }
end
scope :ai do
post "items/:id/analyze", to: "ai#analyze_item", as: :ai_analyze_item
post "items/:id/tag", to: "ai#tag_item", as: :ai_tag_item
get "outfits/suggest", to: "ai#suggest_outfits", as: :ai_suggest_outfits
get "declutter", to: "ai#declutter_guide", as: :ai_declutter
get "capsule", to: "ai#capsule", as: :ai_capsule
get "palette", to: "ai#color_palette", as: :ai_palette
get "search", to: "ai#search", as: :ai_search
get "moodboard", to: "ai#mood_board", as: :ai_mood_board
get "occasions", to: "ai#occasion_map", as: :ai_occasions
end
root "home#index"
get "up", to: "rails/health#show", as: :rails_health_check
endtest:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>
# 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 %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]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
endActiveRecord::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
endclass CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
endclass CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
end# 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
endclass CreateItems < ActiveRecord::Migration[8.1]
def change
create_table :items do |t|
t.string :title
t.string :category
t.string :color
t.string :size
t.string :material
t.string :brand
t.decimal :price
t.integer :times_worn
t.date :purchase_date
t.boolean :spark_joy
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
endclass CreateOutfitItems < ActiveRecord::Migration[8.1]
def change
create_table :outfit_items do |t|
t.references :outfit, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.integer :position
t.timestamps
end
end
endclass CreatePlannedOutfits < ActiveRecord::Migration[8.1]
def change
create_table :planned_outfits do |t|
t.references :user, null: false, foreign_key: true
t.references :outfit, null: false, foreign_key: true
t.date :planned_date
t.text :notes
t.timestamps
end
end
endclass AddExtendedFieldsToItems < ActiveRecord::Migration[8.1]
def change
add_column :items, :mood_effect, :string
add_column :items, :life_phase, :string
add_column :items, :occasion_tags, :string
add_column :items, :season, :string
end
endclass CreateOutfits < ActiveRecord::Migration[8.1]
def change
create_table :outfits do |t|
t.string :name
t.text :description
t.string :category
t.string :season
t.string :occasion
t.integer :likes_count
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
endclass CreateFollows < ActiveRecord::Migration[8.1]
def change
create_table :follows do |t|
t.references :follower, null: false, foreign_key: true
t.references :followee, null: false, foreign_key: true
t.timestamps
end
end
endclass CreatePosts < ActiveRecord::Migration[8.1]
def change
create_table :posts do |t|
t.text :body
t.references :user, null: false, foreign_key: true
t.references :outfit, null: false, foreign_key: true
t.references :item, null: false, foreign_key: true
t.integer :likes_count
t.timestamps
end
end
endActiveRecord::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# 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.1].define(version: 2026_05_04_180410) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", 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.bigint "byte_size", null: false
t.string "checksum"
t.string "content_type"
t.datetime "created_at", null: false
t.string "filename", null: false
t.string "key", null: false
t.text "metadata"
t.string "service_name", 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 "items", force: :cascade do |t|
t.string "brand"
t.string "category"
t.string "color"
t.datetime "created_at", null: false
t.string "life_phase"
t.string "material"
t.string "mood_effect"
t.string "occasion_tags"
t.decimal "price"
t.date "purchase_date"
t.string "season"
t.string "size"
t.boolean "spark_joy"
t.integer "times_worn"
t.string "title"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id"], name: "index_items_on_user_id"
end
create_table "outfit_items", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "item_id", null: false
t.integer "outfit_id", null: false
t.integer "position"
t.datetime "updated_at", null: false
t.index ["item_id"], name: "index_outfit_items_on_item_id"
t.index ["outfit_id"], name: "index_outfit_items_on_outfit_id"
end
create_table "planned_outfits", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "notes"
t.integer "outfit_id", null: false
t.date "planned_date"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["outfit_id"], name: "index_planned_outfits_on_outfit_id"
t.index ["user_id"], name: "index_planned_outfits_on_user_id"
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email_address", null: false
t.string "password_digest", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", 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 "items", "users"
add_foreign_key "outfit_items", "items"
add_foreign_key "outfit_items", "outfits"
add_foreign_key "planned_outfits", "outfits"
add_foreign_key "planned_outfits", "users"
add_foreign_key "sessions", "users"
end# 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# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
# baibl
Bible study and annotation app. Rails 8. PostgreSQL.
## Deploy
```zsh
cd ~/pub4/MASTER/DEPLOY/rails/baibl
doas zsh baibl.sh
## `DEPLOY/rails/baibl/app/Dockerfile`
```text
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t app .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name app app
# 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.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
bundle exec bootsnap precompile -j 1 --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times.
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# 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
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# 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"]
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# 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
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
gem "kamal", 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"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# 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
gem "pagy"
gem "prism", "1.9.0"
gem "falcon"
# README
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
* Ruby version
* System dependencies
* Configuration
* Database creation
* Database initialization
* How to run the test suite
* Services (job queues, cache servers, search engines, etc.)
* Deployment instructions
* ...# 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
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Method
allow_browser versions: :modern
endclass BookmarksController < ApplicationController
before_action :require_authentication
def index
@pagy, @bookmarks = pagy(Current.user.bookmarks.includes(verse: [:book, :chapter]))
end
def create
verse = Verse.find(params[:verse_id])
@bookmark = Current.user.bookmarks.find_or_create_by!(verse: verse)
respond_to do |format|
format.turbo_stream
format.json { render json: { status: "ok" } }
end
end
def destroy
@bookmark = Current.user.bookmarks.find(params[:id])
@bookmark.destroy!
respond_to do |format|
format.turbo_stream
format.html { redirect_to bookmarks_path }
end
end
endmodule Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
endclass HighlightsController < ApplicationController
before_action :require_authentication
def create
verse = Verse.find(params[:verse_id])
@highlight = Current.user.highlights.find_or_initialize_by(verse: verse)
@highlight.update!(color: params[:color] || "yellow")
respond_to do |format|
format.turbo_stream
format.json { render json: { status: "ok" } }
end
end
def destroy
@highlight = Current.user.highlights.find(params[:id])
@highlight.destroy!
respond_to do |format|
format.turbo_stream
format.json { render json: { status: "ok" } }
end
end
endclass PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
endclass ScripturesController < ApplicationController
allow_unauthenticated_access only: %i[index book chapter search]
def index
@books = Book.ordered
@daily_verse = Verse.order("RANDOM()").limit(1).first
end
def book
@book = Book.find_by!(abbreviation: params[:abbreviation])
@chapters = @book.chapters.order(:number)
end
def chapter
@book = Book.find_by!(abbreviation: params[:book_abbreviation])
@chapter = @book.chapters.find_by!(number: params[:number])
@verses = @chapter.verses.order(:number).includes(:highlights, :bookmarks)
end
def search
@pagy, @verses = pagy(Verse.full_text_search(params[:q]).includes(:book, :chapter), items: 20)
render :search
end
endclass SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
endmodule ApplicationHelper
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}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
endclass ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
endclass ApplicationRecord < ActiveRecord::Base
primary_abstract_class
endclass Book < ApplicationRecord
has_many :chapters, dependent: :destroy
has_many :verses, dependent: :destroy
TESTAMENTS = %w[Old New].freeze
validates :name, :abbreviation, :testament, presence: true
validates :testament, inclusion: { in: TESTAMENTS }
validates :abbreviation, uniqueness: true
scope :old_testament, -> { where(testament: "Old").order(:order_index) }
scope :new_testament, -> { where(testament: "New").order(:order_index) }
scope :ordered, -> { order(:order_index) }
endclass Bookmark < ApplicationRecord
belongs_to :verse
belongs_to :user
validates :verse_id, uniqueness: { scope: :user_id }
after_create_commit -> { broadcast_append_to [user, "bookmarks"] }
endclass Chapter < ApplicationRecord
belongs_to :book
has_many :verses, dependent: :destroy
validates :number, presence: true
validates :number, uniqueness: { scope: :book_id }
scope :ordered, -> { order(:number) }
def reference = "#{book.name} #{number}"
endclass Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
endclass Highlight < ApplicationRecord
belongs_to :verse
belongs_to :user
COLORS = %w[yellow green blue pink orange].freeze
validates :color, inclusion: { in: COLORS }
validates :verse_id, uniqueness: { scope: :user_id }
after_create_commit -> { broadcast_replace_to [user, "highlights"] }
endclass ReadingPlan < ApplicationRecord
belongs_to :user, optional: true
has_many :reading_plan_days, dependent: :destroy
validates :name, presence: true
validates :duration_days, numericality: { greater_than: 0 }, allow_nil: true
def progress
return 0.0 if reading_plan_days.empty?
reading_plan_days.where.not(completed_at: nil).count.to_f / reading_plan_days.count
end
endclass ReadingPlanDay < ApplicationRecord
belongs_to :reading_plan
belongs_to :book
validates :day_number, presence: true
validates :day_number, uniqueness: { scope: :reading_plan_id }
scope :ordered, -> { order(:day_number) }
def completed? = completed_at.present?
endclass Session < ApplicationRecord
belongs_to :user
endclass User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :highlights, dependent: :destroy
has_many :bookmarks, dependent: :destroy
has_many :reading_plans, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
endclass Verse < ApplicationRecord
include PgSearch::Model
belongs_to :chapter
belongs_to :book
has_many :highlights, dependent: :destroy
has_many :bookmarks, dependent: :destroy
validates :number, :content, presence: true
validates :number, uniqueness: { scope: :chapter_id }
pg_search_scope :full_text_search,
against: :content,
using: { tsearch: { prefix: true, dictionary: "english" } }
scope :in_chapter, ->(chapter) { where(chapter: chapter).order(:number) }
def reference
"#{book.name} #{chapter.number}:#{number}"
end
end<% content_for :title, "Bookmarks" %>
<h1>Bookmarks</h1>
<% if @bookmarks.any? %>
<% @bookmarks.each do |bookmark| %>
<article id="<%= dom_id(bookmark) %>">
<p><%= link_to "#{bookmark.verse.book.abbreviation} #{bookmark.verse.chapter.number}:#{bookmark.verse.number}", scripture_chapter_path(bookmark.verse.book.abbreviation, bookmark.verse.chapter.number) %></p>
<p><%= bookmark.verse.content %></p>
<% if bookmark.note.present? %><p><%= bookmark.note %></p><% end %>
<%= button_to "Remove", bookmark, method: :delete %>
</article>
<% end %>
<%= @pagy.series_nav if @pagy.pages > 1 %>
<% else %>
<p>No bookmarks yet. Bookmark verses while reading.</p>
<% end %><%= turbo_stream.replace "highlight_#{@highlight.verse_id}", partial: "highlights/toggle", locals: { verse: @highlight.verse, highlight: @highlight } %><%= turbo_stream.replace "highlight_#{@highlight.verse_id}", partial: "highlights/toggle", locals: { verse: @highlight.verse, highlight: nil } %><!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "App" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="application-name" content="App">
<meta name="mobile-web-app-capable" content="yes">
<%= 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) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
</head>
<body>
<%= yield %>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %>{
"name": "App",
"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": "App.",
"theme_color": "red",
"background_color": "red"
}// 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)
// }
// })
// )
// })<% content_for :title, @book.name %>
<h1><%= @book.name %></h1>
<nav>
<% @chapters.each do |chapter| %>
<%= link_to chapter.number, scripture_chapter_path(@book.abbreviation, chapter.number) %>
<% end %>
</nav><% content_for :title, "#{@book.name} #{@chapter.number}" %>
<header>
<h1><%= @book.name %> <%= @chapter.number %></h1>
<nav>
<% if @prev_chapter %>
<%= link_to "← #{@book.abbreviation} #{@prev_chapter}", scripture_chapter_path(@book.abbreviation, @prev_chapter) %>
<% end %>
<%= link_to @book.name, scripture_book_path(@book.abbreviation) %>
<% if @next_chapter %>
<%= link_to "#{@book.abbreviation} #{@next_chapter} →", scripture_chapter_path(@book.abbreviation, @next_chapter) %>
<% end %>
</nav>
</header>
<section>
<% @verses.each do |verse| %>
<span id="v<%= verse.number %>" data-controller="verse" data-verse-id="<%= verse.id %>">
<sup><%= verse.number %></sup>
<span><%= verse.content %></span>
<% if authenticated? %>
<% highlight = @highlights[verse.id] %>
<% bookmark = @bookmarks[verse.id] %>
<%= button_to highlight ? "★" : "☆", highlights_path(verse_id: verse.id), method: highlight ? :delete : :post, params: highlight ? { id: highlight.id } : {}, class: ("active" if highlight), data: { turbo_stream: true } %>
<%= button_to bookmark ? "🔖" : "📌", bookmark ? bookmark_path(bookmark) : bookmarks_path(verse_id: verse.id), method: bookmark ? :delete : :post, class: ("active" if bookmark), data: { turbo_stream: true } %>
<% end %>
</span>
<% end %>
</section><% content_for :title, "Scripture" %>
<nav>
<% @books.each do |book| %>
<%= link_to book.abbreviation, scripture_book_path(book.abbreviation), title: book.name %>
<% end %>
</nav>
<% if @books.any? %>
<p>Select a book to begin reading.</p>
<% end %><% content_for :title, "Search" %>
<%= form_with url: scripture_search_path, method: :get do |f| %>
<%= f.search_field :q, value: @query, placeholder: "Search scripture…", autofocus: true %>
<%= f.submit "Search" %>
<% end %>
<% if @results %>
<p><%= @results.size %> results for "<%= @query %>"</p>
<% @results.each do |verse| %>
<article>
<p><%= verse.book.abbreviation %> <%= verse.chapter.number %>:<%= verse.number %></p>
<p><%= verse.content %></p>
</article>
<% end %>
<% end %>require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
endENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" # Set up gems listed in the Gemfile.
require "bootsnap/setup" # Speed up boot time by caching expensive operations.# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit.
# CVEs that are not relevant to the application can be enumerated on the ignore list below.
ignore:
- CVE-THAT-DOES-NOT-APPLYdevelopment:
adapter: async
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: app_production# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# 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: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: app.example.com
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.9
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# Load the Rails application.
require_relative "application"
# Initialize the Rails application.
Rails.application.initialize!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
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Suppress logger output for asset requests.
config.assets.quiet = 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!
endrequire "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 the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# 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 = :mem_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
# config.active_job.queue_adapter = :resque
# 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 bin/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# 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# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.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# 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
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end# 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
]# 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# 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"# 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. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# 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"]Rails.application.routes.draw do
resource :session
resources :passwords, param: :token
root "scriptures#index"
get "scripture", to: "scriptures#index", as: :scripture_index
get "scripture/:abbreviation", to: "scriptures#book", as: :scripture_book
get "scripture/:book_abbreviation/:number", to: "scriptures#chapter", as: :scripture_chapter
get "search", to: "scriptures#search", as: :scripture_search
resources :highlights, only: %i[create destroy]
resources :bookmarks
get "up", to: "rails/health#show", as: :rails_health_check
endtest:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>
# 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 %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
endclass CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
endclass CreateBooks < ActiveRecord::Migration[8.1]
def change
create_table :books do |t|
t.string :name
t.string :abbreviation
t.string :testament
t.integer :chapter_count
t.integer :order_index
t.timestamps
end
end
endclass CreateChapters < ActiveRecord::Migration[8.1]
def change
create_table :chapters do |t|
t.references :book, foreign_key: true
t.integer :number
t.timestamps
end
end
endclass CreateVerses < ActiveRecord::Migration[8.1]
def change
create_table :verses do |t|
t.references :chapter, foreign_key: true
t.references :book, foreign_key: true
t.integer :number
t.text :content
t.timestamps
end
end
endclass CreateHighlights < ActiveRecord::Migration[8.1]
def change
create_table :highlights do |t|
t.references :verse, foreign_key: true
t.references :user, foreign_key: true
t.string :color
t.text :note
t.timestamps
end
end
endclass CreateBookmarks < ActiveRecord::Migration[8.1]
def change
create_table :bookmarks do |t|
t.references :verse, foreign_key: true
t.references :user, foreign_key: true
t.text :note
t.timestamps
end
end
endclass CreateReadingPlans < ActiveRecord::Migration[8.1]
def change
create_table :reading_plans do |t|
t.string :name
t.text :description
t.integer :duration_days
t.references :user, foreign_key: true
t.timestamps
end
end
endclass CreateReadingPlanDays < ActiveRecord::Migration[8.1]
def change
create_table :reading_plan_days do |t|
t.references :reading_plan, foreign_key: true
t.integer :day_number
t.references :book, foreign_key: true
t.integer :chapter_start
t.integer :chapter_end
t.datetime :completed_at
t.timestamps
end
end
end# 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# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#!/usr/bin/env zsh
# baibl.sh — deploys tracked Rails tree at app/ as %APP_NAME%
set -euo pipefail
APP_NAME=%APP_NAME%
APP_DIR=/home/${APP_NAME}/app
APP_PORT=10007
APP_DOMAIN=baibl.no
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}/app
. "${SCRIPT_DIR:h}/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1 }
log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
typeset bundle_home="/home/${APP_NAME}/.bundle"
if [[ ! -d ${bundle_home}/gems ]]; then
log "Bootstrapping gems from amber"
doas mkdir -p "$bundle_home"
doas cp -R /home/amber/.bundle/gems "$bundle_home/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
print "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bundle install --deployment --without development:test"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"# blognet
Blog network platform. Rails 8. PostgreSQL.
## Deploy
```zsh
cd ~/pub4/MASTER/DEPLOY/rails/blognet
doas zsh blognet.sh
## `DEPLOY/rails/blognet/app/Dockerfile`
```text
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t app .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name app app
# 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.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
bundle exec bootsnap precompile -j 1 --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times.
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# 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
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# 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"]
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# 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
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
gem "kamal", 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"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# 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
gem "pagy"
gem "friendly_id"
gem "falcon"
# README
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
* Ruby version
* System dependencies
* Configuration
* Database creation
* Database initialization
* How to run the test suite
* Services (job queues, cache servers, search engines, etc.)
* Deployment instructions
* ...# 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
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
endclass ApplicationController < ActionController::Base
include Pagy::Method
allow_browser versions: :modern
endclass BlogsController < ApplicationController
before_action :require_authentication, except: %i[index show]
before_action :set_blog, only: %i[show edit update destroy]
before_action :authorize!, only: %i[edit update destroy]
def index
@pagy, @blogs = pagy(Blog.published.includes(:user).recent)
end
def show
@pagy, @posts = pagy(@blog.posts.published.includes(:user, :tags))
end
def new
@blog = Current.user.blogs.build
end
def create
@blog = Current.user.blogs.build(blog_params)
@blog.save ? redirect_to(@blog, notice: "Blog created") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@blog.update(blog_params) ? redirect_to(@blog, notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@blog.destroy
redirect_to blogs_path, notice: "Blog deleted"
end
private
def set_blog = @blog = Blog.find_by!(slug: params[:id])
def authorize! = redirect_to(blogs_path, alert: "Unauthorized") unless @blog.user == Current.user
def blog_params = params.require(:blog).permit(:name, :description, :published, :banner)
endclass CommentsController < ApplicationController
before_action :require_authentication
before_action :set_post
def create
@comment = @post.comments.build(comment_params.merge(user: Current.user))
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to [@post.blog, @post] }
end
else
render :new, status: :unprocessable_entity
end
end
def destroy
@comment = @post.comments.find(params[:id])
@comment.destroy! if @comment.user == Current.user
respond_to do |format|
format.turbo_stream
format.html { redirect_to [@post.blog, @post] }
end
end
private
def set_post = @post = Post.find_by!(slug: params[:post_id])
def comment_params
params.require(:comment).permit(:content, :parent_id)
end
endmodule Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
endclass PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
endclass PostsController < ApplicationController
before_action :require_authentication, except: %i[index show]
before_action :set_blog
before_action :set_post, only: %i[show edit update destroy]
before_action :authorize!, only: %i[edit update destroy]
def index
@pagy, @posts = pagy(@blog.posts.published.includes(:user, :tags))
end
def show
@post.increment!(:views_count)
@comments = @post.comments.approved.roots.includes(:user, :replies)
@comment = Comment.new
end
def new
@post = @blog.posts.build
end
def create
@post = @blog.posts.build(post_params.merge(user: Current.user))
@post.save ? redirect_to([@blog, @post], notice: "Post created") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@post.update(post_params) ? redirect_to([@blog, @post], notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@post.destroy
redirect_to @blog, notice: "Post deleted"
end
private
def set_blog = @blog = Blog.find_by!(slug: params[:blog_id])
def set_post = @post = @blog.posts.find_by!(slug: params[:id])
def authorize! = redirect_to(@blog, alert: "Unauthorized") unless @post.user == Current.user
def post_params
params.require(:post).permit(:title, :body, :published, :slug, images: [])
end
endclass SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
endmodule ApplicationHelper
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}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
endclass ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
endclass PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
endclass ApplicationRecord < ActiveRecord::Base
primary_abstract_class
endclass Blog < ApplicationRecord
belongs_to :user
has_many :posts, dependent: :destroy
has_one_attached :banner
validates :name, :slug, presence: true
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
before_validation :generate_slug, on: :create
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
def to_param = slug
private
def generate_slug
self.slug ||= name.to_s.parameterize
end
endclass Categorization < ApplicationRecord
belongs_to :post
belongs_to :category
validates :post_id, uniqueness: { scope: :category_id }
endclass Category < ApplicationRecord
has_many :categorizations, dependent: :destroy
has_many :posts, through: :categorizations
validates :name, :slug, presence: true
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
before_validation :generate_slug, on: :create
def to_param = slug
private
def generate_slug
self.slug ||= name.to_s.parameterize
end
endclass Comment < ApplicationRecord
belongs_to :post
belongs_to :user
belongs_to :parent, class_name: "Comment", optional: true
has_many :replies, class_name: "Comment", foreign_key: :parent_id, dependent: :destroy
validates :content, presence: true, length: { maximum: 5000 }
scope :roots, -> { where(parent_id: nil).order(created_at: :asc) }
scope :approved, -> { where(approved: true) }
after_create_commit :broadcast_comment
private
def broadcast_comment
broadcast_append_to [post, "comments"], partial: "comments/comment", locals: { comment: self }
post.increment!(:comments_count)
end
endclass Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
endclass Post < ApplicationRecord
belongs_to :blog
belongs_to :user
has_rich_text :body
has_many_attached :images
has_many :comments, dependent: :destroy
has_many :categorizations, dependent: :destroy
has_many :categories, through: :categorizations
has_many :taggings, dependent: :destroy
has_many :tags, through: :taggings
validates :title, :slug, presence: true
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
before_validation :generate_slug, on: :create
before_save :set_published_at
scope :published, -> { where(published: true).order(published_at: :desc) }
scope :drafts, -> { where(published: false) }
scope :recent, -> { order(created_at: :desc) }
def to_param = slug
def reading_time
words = body.to_plain_text.split.size
[(words / 200.0).ceil, 1].max
end
private
def generate_slug
self.slug ||= title.to_s.parameterize
end
def set_published_at
self.published_at = Time.current if published? && published_at.nil?
end
endclass Session < ApplicationRecord
belongs_to :user
endclass Tag < ApplicationRecord
has_many :taggings, dependent: :destroy
has_many :posts, through: :taggings
validates :name, presence: true, uniqueness: true
before_validation -> { self.name = name.to_s.strip.downcase }, on: :create
scope :popular, -> { where("posts_count > 0").order(posts_count: :desc) }
endclass Tagging < ApplicationRecord
belongs_to :post
belongs_to :tag, counter_cache: :posts_count
validates :post_id, uniqueness: { scope: :tag_id }
endclass User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
<% if blob.representable? %>
<%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
<% end %>
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<%= caption %>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<% end %>
</figcaption>
</figure><%= form_with model: blog do |f| %>
<%= render "shared/errors", object: blog %>
<p><%= f.label :name %><%= f.text_field :name, autofocus: true %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 2 %></p>
<p><%= f.label :published %><%= f.check_box :published %></p>
<p><%= f.submit %> <%= link_to "Cancel", blogs_path %></p>
<% end %><% content_for :title, "Edit blog" %>
<h1>Edit <%= @blog.name %></h1>
<%= render "form", blog: @blog %><% content_for :title, "Blogs" %>
<header>
<h1>Blogs</h1>
<% if authenticated? %><%= link_to "New blog", new_blog_path %><% end %>
</header>
<section id="blogs">
<% @blogs.each do |blog| %>
<article>
<%= link_to blog.name, blog_path(blog) %>
<p><%= blog.description %></p>
<small><%= blog.posts_count %> posts</small>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "New blog" %>
<h1>New blog</h1>
<%= render "form", blog: @blog %><% content_for :title, @blog.name %>
<header>
<h1><%= @blog.name %></h1>
<p><%= @blog.description %></p>
<% if @blog.user == Current.user %>
<%= link_to "New post", new_blog_post_path(@blog) %>
<%= link_to "Edit", edit_blog_path(@blog) %>
<% end %>
</header>
<section id="posts">
<% @posts.each do |post| %>
<article>
<%= link_to post.title, blog_post_path(@blog, post) %>
<small><%= post.published_at&.strftime("%b %-d, %Y") %> · <%= post.views_count %> views · <%= post.comments_count %> comments</small>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><article id="<%= dom_id(comment) %>">
<small><%= comment.user.email_address.split("@").first %></small>
<p><%= comment.content %></p>
<% if authenticated? && (comment.user == Current.user || @blog.user == Current.user) %>
<%= button_to "Delete", blog_post_comment_path(@blog, @post, comment), method: :delete %>
<% end %>
</article><div class="trix-content">
<%= yield -%>
</div><!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "App" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="application-name" content="App">
<meta name="mobile-web-app-capable" content="yes">
<%= 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) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
</head>
<body>
<%= yield %>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %><h1>Update your password</h1>
<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<%= form_with url: password_path(params[:token]), method: :put do |form| %>
<p><%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %></p>
<p><%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %></p>
<p><%= form.submit "Save" %></p>
<% end %><h1>Forgot your password?</h1>
<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<%= form_with url: passwords_path do |form| %>
<p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
<p><%= form.submit "Email reset instructions" %></p>
<% end %><p>
You can reset your password on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.<%= form_with model: [@blog, post] do |f| %>
<%= render "shared/errors", object: post %>
<p><%= f.label :title %><%= f.text_field :title, autofocus: true %></p>
<p><%= f.label :body %><%= f.rich_text_area :body %></p>
<p><%= f.label :published %><%= f.check_box :published %></p>
<p><%= f.submit %> <%= link_to "Cancel", blog_path(@blog) %></p>
<% end %><% content_for :title, "Edit post" %>
<h1>Edit post</h1>
<%= render "form", blog: @blog, post: @post %><% content_for :title, "New post" %>
<h1>New post</h1>
<%= render "form", blog: @blog, post: @post %><% content_for :title, @post.title %>
<article>
<header>
<h1><%= @post.title %></h1>
<small><%= @post.user.email_address.split("@").first %> · <%= @post.published_at&.strftime("%b %-d, %Y") %> · <%= @post.views_count %> views</small>
<% if @post.user == Current.user %>
<%= link_to "Edit", edit_blog_post_path(@blog, @post) %>
<%= button_to "Delete", blog_post_path(@blog, @post), method: :delete, data: { turbo_confirm: "Delete post?" } %>
<% end %>
</header>
<%= @post.body %>
</article>
<section id="comments">
<h2>Comments (<%= @post.comments_count %>)</h2>
<%= render @comments %>
<% if authenticated? %>
<%= form_with url: blog_post_comments_path(@blog, @post) do |f| %>
<p><%= f.text_area :content, rows: 3, placeholder: "Add a comment…" %></p>
<p><%= f.submit "Comment" %></p>
<% end %>
<% end %>
</section>{
"name": "App",
"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": "App.",
"theme_color": "red",
"background_color": "red"
}// 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)
// }
// })
// )
// })<% if flash[:alert] %><p role="alert"><%= flash[:alert] %></p><% end %>
<% if flash[:notice] %><p role="status"><%= flash[:notice] %></p><% end %>
<%= form_with url: session_path do |form| %>
<p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
<p><%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %></p>
<p><%= form.submit "Sign in" %></p>
<% end %>
<p><%= link_to "Forgot password?", new_password_path %></p>require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
endENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" # Set up gems listed in the Gemfile.
require "bootsnap/setup" # Speed up boot time by caching expensive operations.# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit.
# CVEs that are not relevant to the application can be enumerated on the ignore list below.
ignore:
- CVE-THAT-DOES-NOT-APPLY# 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.daydefault: &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# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# 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: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: app.example.com
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.9
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# Load the Rails application.
require_relative "application"
# Initialize the Rails application.
Rails.application.initialize!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
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Suppress logger output for asset requests.
config.assets.quiet = 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!
endrequire "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 the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# 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 bin/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# 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# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.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# 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
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end# 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
]# 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# 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"# 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. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# 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"]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# 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 12Rails.application.routes.draw do
resource :session
resources :passwords, param: :token
resources :blogs, path: "b" do
resources :posts, path: "p" do
resources :comments, only: %i[create destroy]
end
end
root "blogs#index"
get "up", to: "rails/health#show", as: :rails_health_check
endtest:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>
# 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 %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]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
endActiveRecord::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
endclass CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
endclass CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
end# 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# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.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 :action_text_rich_texts, id: primary_key_type do |t|
t.string :name, null: false
t.text :body, size: :long
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.timestamps
t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
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
endclass CreateBlogs < ActiveRecord::Migration[8.1]
def change
create_table :blogs do |t|
t.string :name
t.text :description
t.string :slug
t.references :user, foreign_key: true
t.boolean :published, default: false
t.integer :posts_count, default: 0
t.timestamps
end
add_index :blogs, :slug, unique: true
end
endclass CreatePosts < ActiveRecord::Migration[8.1]
def change
create_table :posts do |t|
t.string :title
t.string :slug
t.references :blog, foreign_key: true
t.references :user, foreign_key: true
t.boolean :published, default: false
t.datetime :published_at
t.integer :views_count, default: 0
t.integer :comments_count, default: 0
t.timestamps
end
add_index :posts, :slug, unique: true
end
endclass CreateCategories < ActiveRecord::Migration[8.1]
def change
create_table :categories do |t|
t.string :name
t.string :slug
t.text :description
t.timestamps
end
add_index :categories, :slug, unique: true
end
endclass CreateCategorizations < ActiveRecord::Migration[8.1]
def change
create_table :categorizations do |t|
t.references :post, foreign_key: true
t.references :category, foreign_key: true
t.timestamps
end
end
endclass CreateComments < ActiveRecord::Migration[8.1]
def change
create_table :comments do |t|
t.references :post, foreign_key: true
t.references :user, foreign_key: true
t.integer :parent_id
t.text :content
t.boolean :approved, default: true
t.timestamps
end
end
endclass CreateTags < ActiveRecord::Migration[8.1]
def change
create_table :tags do |t|
t.string :name
t.integer :posts_count, default: 0
t.timestamps
end
add_index :tags, :name, unique: true
end
endclass CreateTaggings < ActiveRecord::Migration[8.1]
def change
create_table :taggings do |t|
t.references :post, foreign_key: true
t.references :tag, foreign_key: true
t.timestamps
end
end
endActiveRecord::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# 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.1].define(version: 2026_05_01_020920) do
create_table "action_text_rich_texts", force: :cascade do |t|
t.text "body"
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
end
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", 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.bigint "byte_size", null: false
t.string "checksum"
t.string "content_type"
t.datetime "created_at", null: false
t.string "filename", null: false
t.string "key", null: false
t.text "metadata"
t.string "service_name", 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 "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email_address", null: false
t.string "password_digest", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", 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 "sessions", "users"
enduser = User.find_or_create_by!(email_address: "admin@blognet.example") do |u|
u.password = u.password_confirmation = "password123"
end
blog = Blog.find_or_create_by!(slug: "demo-blog") do |b|
b.name = "Demo Blog"
b.description = "A demonstration blog"
b.user = user
b.published = true
end
5.times do |i|
Post.find_or_create_by!(slug: "post-#{i + 1}") do |p|
p.title = "Post #{i + 1}: Getting Started with Rails 8"
p.body = "Rails 8 ships with Solid Cache, Solid Queue, and Solid Cable out of the box. This post covers what changed."
p.blog = blog
p.user = user
p.published = true
end
end
puts "Seeded #{Post.count} posts"# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#!/usr/bin/env zsh
# blognet.sh — deploys tracked Rails tree at app/ as %APP_NAME%
set -euo pipefail
APP_NAME=%APP_NAME%
APP_DIR=/home/${APP_NAME}/app
APP_PORT=10002
APP_DOMAIN=
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}/app
. "${SCRIPT_DIR:h}/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1 }
log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
typeset bundle_home="/home/${APP_NAME}/.bundle"
if [[ ! -d ${bundle_home}/gems ]]; then
log "Bootstrapping gems from amber"
doas mkdir -p "$bundle_home"
doas cp -R /home/amber/.bundle/gems "$bundle_home/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
print "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bundle install --deployment --without development:test"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"# brgen
### brgen.no, oshlo.no, trndheim.no, stvanger.no, trmso.no, longyearbyn.no, reykjavk.is, kbenhvn.dk, stholm.se, gtebrg.se, mlmoe.se, hlsinki.fi, lndon.uk, cardff.uk, mnchester.uk, brmingham.uk, lverpool.uk, edinbrgh.uk, glasgw.uk, amstrdam.nl, rottrdam.nl, utrcht.nl, brssels.be, zrich.ch, lchtenstein.li, frankfrt.de, wrsawa.pl, gdnsk.pl, brdeaux.fr, mrseille.fr, mlan.it, lsbon.pt, lsangeles.com, newyrk.us, chcago.us, houstn.us, dllas.us, austn.us, prtland.com, mnneapolis.com
Brgen is a hyperlocal social network unique to every major city. One Rails 8 codebase, served per-domain via SNI, with sub-applications for marketplace, dating, music, TV, and street-food takeaway. Monetization: SEO, PPC, affiliate marketing, targeted email.
### Sub-applications
| Namespace | Subdomain | Models |
|---|---|---|
| `Marketplace::` | `markedsplass.brgen.no` (and locale aliases: markadur, marknadsplats, marktplaats, marche, mercato, mercado, markkinapaikka, marketplace) | Category, Listing, Order |
| `Dating::` | `dating.brgen.no` | Profile, Like, Dislike, Match |
| `Playlist::` | `playlist.brgen.no` | Playlist, Track, PlaylistTrack, Listen |
| `Tv::` | `tv.brgen.no` | Channel, Video, Broadcast, Subscription, ViewEvent |
| `Takeaway::` | `takeaway.brgen.no` | Restaurant, MenuItem, Order, OrderItem |
### Stack
Rails 8 · SQLite3 · Solid Queue · Solid Cache · Hotwire (Turbo + Stimulus) · Devise · OmniAuth · Falcon · Active Storage · ImageProcessing · I18n.
### Deploy
```zsh
doas zsh DEPLOY/rails/brgen/brgen.shIdempotent. Installs gems, migrates, seeds, registers rc.d/brgen on the random app port, and adds the relayd backend.
DNS, TLS, HAProxy SNI routing, and per-city domain registration are handled by DEPLOY/openbsd/openbsd.sh.
## `DEPLOY/rails/brgen/README_takeaway.md`
```markdown
# brgen takeaway
Food ordering subapp for brgen.no. Rails 8. PostgreSQL.
## Models
- `Restaurant` — dining location with geocoding
- `MenuItem` — menu item with availability states and monetized price
- `Order` — lifecycle: placed → accepted → preparing → dispatched → delivered / canceled
## Deploy
```zsh
doas zsh brgen_takeaway.sh
## `DEPLOY/rails/brgen/README_tv.md`
```markdown
# brgen tv
Video and live-streaming subapp for brgen.no. Rails 8. PostgreSQL + Redis.
## Deploy
```zsh
doas zsh brgen_tv.sh
## `DEPLOY/rails/brgen/app/Dockerfile`
```text
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t app .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name app app
# 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.3.7
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock vendor ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
bundle exec bootsnap precompile -j 1 --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times.
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# 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
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# 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"]
source "https://rubygems.org"
ruby "~> 3.3"
gem "rails", "~> 8.0"
gem "sqlite3", "~> 2.1"
gem "falcon"
gem "async"
gem "async-http"
# Real-time
gem "turbo-rails"
gem "stimulus-rails"
gem "importmap-rails"
# Solid Stack (Rails 8)
gem "solid_queue"
gem "solid_cache"
gem "solid_cable"
# Authentication
gem "bcrypt", "~> 3.1"
# Social
gem "acts_as_tenant"
# Features
gem "pagy"
gem "image_processing"
gem "geocoder"
# Discovery — vision-LLM scrapers (lib/tasks/{reddit,amazon}.rake)
gem "ferrum"
group :development, :test do
gem "brakeman"
gem "rubocop-rails-omakase"
gem "faker"
end
# README
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
* Ruby version
* System dependencies
* Configuration
* Database creation
* Database initialization
* How to run the test suite
* Services (job queues, cache servers, search engines, etc.)
* Deployment instructions
* ...# 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
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
endmodule ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
endclass ApplicationController < ActionController::Base
include Authentication
include Pagy::Method
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
endclass CommentsController < ApplicationController
before_action :require_real_user
before_action :set_commentable
def create
@comment = @commentable.comments.build(comment_params)
@comment.user = Current.user
@comment.parent_id = params[:parent_id] if params[:parent_id]
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_back fallback_location: root_path }
end
else
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.replace("comment_form", partial: "comments/form", locals: { comment: @comment, commentable: @commentable }) }
format.html { redirect_back fallback_location: root_path, alert: @comment.errors.full_messages.to_sentence }
end
end
end
def destroy
@comment = Comment.find(params[:id])
@comment.destroy if @comment.user == Current.user
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@comment)) }
format.html { redirect_back fallback_location: root_path }
end
end
private
def set_commentable
if params[:post_id]
@commentable = Post.find(params[:post_id])
elsif params[:comment_id]
@commentable = Comment.find(params[:comment_id])
end
end
def comment_params
params.require(:comment).permit(:content)
end
endclass CommunitiesController < ApplicationController
before_action :require_real_user, only: [:new, :create]
before_action :set_community, only: [:show]
def index
@communities = Community.popular.includes(:user)
end
def show
@posts = @community.posts.hot.includes(:user, :votes)
end
def new
@community = Community.new
end
def create
@community = Community.new(community_params)
@community.user = Current.user
if @community.save
redirect_to @community, notice: "Community created."
else
render :new, status: :unprocessable_entity
end
end
private
def set_community = @community = Community.find(params[:id])
def community_params = params.require(:community).permit(:name, :description)
endmodule Authentication
extend ActiveSupport::Concern
included do
before_action :resume_session
helper_method :authenticated?, :current_user
end
class_methods do
# Rails 8 compat: controllers can declare they don't need auth enforcement
def allow_unauthenticated_access(**options)
skip_before_action :resume_session, **options rescue nil
end
end
private
def authenticated?
Current.user.present? && !Current.user.guest?
end
def current_user
Current.user
end
def resume_session
Current.session = find_session_by_cookie
if Current.session
Current.user = Current.session.user
else
Current.user = find_or_create_guest_user
end
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id])
end
def find_or_create_guest_user
guest_id = session[:guest_user_id]
if guest_id
User.find_by(id: guest_id, guest: true) || create_guest_user
else
create_guest_user
end
end
def create_guest_user
guest = User.create!(
email_address: "guest_#{SecureRandom.hex(8)}@guest.local",
password: SecureRandom.hex(16),
guest: true
)
session[:guest_user_id] = guest.id
guest
end
def require_real_user
unless authenticated?
redirect_to new_session_path, alert: "Sign in to continue"
end
end
# Rails 8 compat alias
alias_method :require_authentication, :resume_session
endclass ConversationsController < ApplicationController
before_action :require_real_user
def index
@conversations = Conversation.for_user(Current.user)
.includes(:participants, :messages)
.order("messages.created_at DESC")
end
def show
@conversation = Conversation.for_user(Current.user).find(params[:id])
@conversation.mark_read_for!(Current.user)
@messages = @conversation.messages.recent.limit(50).reverse
@message = Message.new
end
def create
other = User.find(params[:user_id])
@conversation = Conversation.find_or_create_direct(Current.user, other)
redirect_to @conversation
end
endclass Dating::BaseController < ApplicationController
endclass Dating::DislikesController < Dating::BaseController
def create
user = User.find(params[:user_id])
Dating::Dislike.find_or_create_by!(disliker: Current.user, dislikee: user)
redirect_to dating_root_path
end
endclass Dating::HomeController < Dating::BaseController
def index
profile = Current.user.dating_profile
unless profile&.visible?
redirect_to edit_dating_profile_path and return
end
liked_ids = Dating::Like.where(liker: Current.user).pluck(:likee_id)
disliked_ids = Dating::Dislike.where(disliker: Current.user).pluck(:dislikee_id)
excluded = (liked_ids + disliked_ids + [Current.user.id]).uniq
@pagy, @profiles = pagy(
Dating::Profile.visible
.where.not(user_id: excluded)
.includes(:user)
.order(Arel.sql("RANDOM()"))
)
end
endclass Dating::LikesController < Dating::BaseController
def create
user = User.find(params[:user_id])
Dating::Like.find_or_create_by!(liker: Current.user, likee: user)
redirect_to dating_root_path
end
endclass Dating::MatchesController < Dating::BaseController
def index
@pagy, @matches = pagy(
Dating::Match.active
.where("initiator_id = ? OR receiver_id = ?", Current.user.id, Current.user.id)
.includes(:initiator, :receiver)
)
end
endclass Dating::ProfilesController < Dating::BaseController
before_action :set_profile, only: %i[show edit update]
def show; end
def edit; end
def new
@profile = Current.user.build_dating_profile
end
def create
@profile = Current.user.build_dating_profile(profile_params)
@profile.save ?
redirect_to(dating_root_path, notice: "Profile created") :
render(:new, status: :unprocessable_entity)
end
def update
@profile.update(profile_params) ?
redirect_to(dating_root_path, notice: "Profile updated") :
render(:edit, status: :unprocessable_entity)
end
private
def set_profile = (@profile = Current.user.dating_profile || redirect_to(new_dating_profile_path))
def profile_params = params.require(:dating_profile).permit(:bio, :gender, :looking_for, :age, :location, :latitude, :longitude, :visible, photos: [])
endclass FollowsController < ApplicationController
before_action :require_real_user
def create
user = User.find(params[:user_id])
Current.user.follow!(user)
redirect_back fallback_location: root_path
end
def destroy
user = User.find(params[:user_id])
Current.user.unfollow!(user)
redirect_back fallback_location: root_path
end
endclass HomeController < ApplicationController
def index
@posts = if authenticated?
Current.user.timeline_posts.hot.includes(:user, :community, :votes).limit(50)
else
Post.hot.includes(:user, :community, :votes).limit(50)
end
@communities = Community.popular.limit(10)
end
endclass Marketplace::BaseController < ApplicationController
endclass Marketplace::CategoriesController < Marketplace::BaseController
allow_unauthenticated_access only: %i[show]
def show
@category = Marketplace::Category.find_by!(slug: params[:id])
@pagy, @listings = pagy(@category.listings.active.recent)
end
endclass Marketplace::ListingsController < Marketplace::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_listing, only: %i[show edit update destroy]
def index
scope = Marketplace::Listing.active.includes(:user, :category)
scope = scope.where("title LIKE ?", "%#{params[:q]}%") if params[:q].present?
scope = scope.where(category_id: params[:category_id]) if params[:category_id].present?
@pagy, @listings = pagy(scope.recent)
@categories = Marketplace::Category.roots.includes(:children)
end
def show
@listing.increment!(:views_count)
@order = Marketplace::Order.new if authenticated?
end
def new
@listing = Marketplace::Listing.new
@categories = Marketplace::Category.all
end
def create
@listing = Current.user.marketplace_listings.build(listing_params)
@listing.save ?
redirect_to(marketplace_listing_path(@listing), notice: "Listed") :
render(:new, status: :unprocessable_entity)
end
def edit
@categories = Marketplace::Category.all
end
def update
@listing.update(listing_params) ?
redirect_to(marketplace_listing_path(@listing)) :
render(:edit, status: :unprocessable_entity)
end
def destroy
@listing.update!(status: "removed")
redirect_to marketplace_listings_path
end
private
def set_listing = (@listing = Marketplace::Listing.find(params[:id]))
def listing_params = params.require(:marketplace_listing).permit(
:title, :description, :price_cents, :condition, :status, :location,
:category_id, photos: []
)
endclass Marketplace::OrdersController < Marketplace::BaseController
before_action :set_listing
def create
@order = @listing.orders.build(buyer: Current.user,
message: params.dig(:marketplace_order, :message),
price_cents: @listing.price_cents)
@order.save ?
redirect_to(marketplace_listing_path(@listing), notice: "Offer sent") :
redirect_to(marketplace_listing_path(@listing), alert: "Could not send offer")
end
def update
@order = Marketplace::Order.find(params[:id])
if @order.seller == Current.user
@order.accept! if params[:accept]
@order.decline! if params[:decline]
end
redirect_to marketplace_listing_path(@listing)
end
private
def set_listing = (@listing = Marketplace::Listing.find(params[:listing_id]))
endclass MessagesController < ApplicationController
before_action :require_real_user
before_action :set_conversation
def create
@message = @conversation.messages.build(message_params)
@message.sender = Current.user
if @message.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @conversation }
end
else
render :new, status: :unprocessable_entity
end
end
private
def set_conversation
@conversation = Conversation.for_user(Current.user).find(params[:conversation_id])
end
def message_params
params.require(:message).permit(:content, :message_type)
end
endclass PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
endclass Playlist::BaseController < ApplicationController
endclass Playlist::ListensController < Playlist::BaseController
def create
track = Playlist::Track.find(params[:track_id])
Playlist::Listen.create!(user: Current.user, track: track)
render json: { ok: true }
end
endclass Playlist::PlaylistsController < Playlist::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_playlist, only: %i[show edit update destroy]
def index
@pagy, @playlists = pagy(Playlist::Playlist.public_playlists.popular.includes(:user))
end
def show
@tracks = @playlist.playlist_tracks.includes(:track)
end
def new
@playlist = Playlist::Playlist.new
end
def create
@playlist = Current.user.playlist_playlists.build(playlist_params)
@playlist.save ?
redirect_to(playlist_playlist_path(@playlist), notice: "Playlist created") :
render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@playlist.update(playlist_params) ?
redirect_to(playlist_playlist_path(@playlist)) :
render(:edit, status: :unprocessable_entity)
end
def destroy
@playlist.destroy
redirect_to playlist_playlists_path
end
private
def set_playlist = (@playlist = Playlist::Playlist.find(params[:id]))
def playlist_params = params.require(:playlist_playlist).permit(:name, :description, :public_access)
endclass Playlist::TracksController < Playlist::BaseController
before_action :set_playlist
def create
track = Playlist::Track.find_or_create_by!(title: params.dig(:playlist_track, :title),
artist: params.dig(:playlist_track, :artist)) do |t|
t.assign_attributes(track_params.except(:title, :artist))
end
@playlist.add_track!(track, user: Current.user)
redirect_to playlist_playlist_path(@playlist), notice: "Track added"
end
def destroy
pt = @playlist.playlist_tracks.find(params[:id])
pt.destroy
redirect_to playlist_playlist_path(@playlist)
end
private
def set_playlist = (@playlist = Playlist::Playlist.find(params[:playlist_id]))
def track_params = params.require(:playlist_track).permit(:title, :artist, :album, :duration_seconds, :source_type, :source_url, :genre)
endclass PlaylistController < ApplicationController
def index
@playlists = [
{name: "Bergen Beats", tracks: 12, genre: "Electronic"},
{name: "Norwegian Folk", tracks: 8, genre: "Folk"},
{name: "Midnight Jazz", tracks: 15, genre: "Jazz"}
]
end
endclass PostsController < ApplicationController
before_action :require_real_user, only: [:new, :create, :edit, :update, :destroy]
before_action :set_post, only: [:show, :edit, :update, :destroy]
before_action :set_community, only: [:new, :create]
def index
@posts = Post.hot.includes(:user, :community, :votes)
end
def show
@comments = @post.comments.where(parent_id: nil).best.includes(:user, :votes, replies: [:user, :votes])
@new_comment = Comment.new
end
def new
@post = Post.new(community: @community)
end
def create
@post = Post.new(post_params)
@post.user = Current.user
@post.community = @community if @community
if @post.save
redirect_to @post, notice: "Posted."
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @post.update(post_params)
redirect_to @post
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@post.destroy
redirect_to posts_path
end
private
def set_post
@post = Post.find(params[:id])
end
def set_community
@community = Community.find_by(id: params[:community_id])
end
def post_params
params.require(:post).permit(:title, :content, :community_id)
end
endclass SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
endclass Takeaway::BaseController < ApplicationController
endclass Takeaway::MenuItemsController < Takeaway::BaseController
before_action :set_restaurant
def create
@item = @restaurant.menu_items.build(item_params)
@item.save ?
redirect_to(takeaway_restaurant_path(@restaurant), notice: "Item added") :
redirect_to(takeaway_restaurant_path(@restaurant), alert: @item.errors.full_messages.to_sentence)
end
def destroy
@restaurant.menu_items.find(params[:id]).destroy
redirect_to takeaway_restaurant_path(@restaurant)
end
private
def set_restaurant = (@restaurant = Current.user.takeaway_restaurants.find(params[:restaurant_id]))
def item_params = params.require(:takeaway_menu_item).permit(:name, :description, :price_cents, :available, :vegetarian, :vegan, :photo)
endclass Takeaway::OrdersController < Takeaway::BaseController
before_action :set_restaurant, only: %i[new create]
def index
@pagy, @orders = pagy(Current.user.takeaway_orders.recent.includes(:restaurant))
end
def show
@order = Current.user.takeaway_orders.find(params[:id])
end
def new
@order = Takeaway::Order.new
@menu_items = @restaurant.menu_items.available
end
def create
@order = @restaurant.orders.build(order_params.merge(user: Current.user))
item_params.each do |item_id, qty|
next unless qty.to_i > 0
item = @restaurant.menu_items.find_by(id: item_id)
next unless item
@order.order_items.build(menu_item: item, quantity: qty.to_i, unit_price_cents: item.price_cents)
end
if @order.save
@order.calculate_totals!
redirect_to takeaway_order_path(@order), notice: "Order placed"
else
@menu_items = @restaurant.menu_items.available
render :new, status: :unprocessable_entity
end
end
def update
@order = Takeaway::Order.find(params[:id])
@order.advance_status! if @order.restaurant.user == Current.user
redirect_to takeaway_order_path(@order)
end
private
def set_restaurant = (@restaurant = Takeaway::Restaurant.find(params[:restaurant_id]))
def order_params = params.require(:takeaway_order).permit(:delivery_address, :special_instructions)
def item_params = params.dig(:takeaway_order, :items) || {}
endclass Takeaway::RestaurantsController < Takeaway::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_restaurant, only: %i[show edit update destroy]
def index
scope = Takeaway::Restaurant.active.includes(:user)
scope = scope.where(cuisine_type: params[:cuisine]) if params[:cuisine].present?
scope = scope.where("name LIKE ?", "%#{params[:q]}%") if params[:q].present?
@pagy, @restaurants = pagy(scope.popular)
end
def show
@menu_items = @restaurant.menu_items.available
end
def new
@restaurant = Takeaway::Restaurant.new
end
def create
@restaurant = Current.user.takeaway_restaurants.build(restaurant_params)
@restaurant.save ?
redirect_to(takeaway_restaurant_path(@restaurant), notice: "Restaurant created") :
render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@restaurant.update(restaurant_params) ?
redirect_to(takeaway_restaurant_path(@restaurant)) :
render(:edit, status: :unprocessable_entity)
end
def destroy
@restaurant.destroy
redirect_to takeaway_restaurants_path
end
private
def set_restaurant = (@restaurant = Takeaway::Restaurant.find(params[:id]))
def restaurant_params = params.require(:takeaway_restaurant).permit(
:name, :description, :address, :city, :phone, :cuisine_type,
:delivery_fee_cents, :min_order_cents, :active
)
endclass Tv::BaseController < ApplicationController
endclass Tv::ChannelsController < Tv::BaseController
allow_unauthenticated_access only: %i[index show]
before_action :set_channel, only: %i[show edit update destroy subscribe unsubscribe]
def index = (@pagy, @channels = pagy(Tv::Channel.popular.includes(:user)))
def show = (@pagy, @videos = pagy(@channel.videos.published))
def new = (@channel = Tv::Channel.new)
def edit; end
def create
@channel = Current.user.tv_channels.build(channel_params)
@channel.save ? redirect_to(tv_channel_path(@channel), notice: "Channel created") : render(:new, status: :unprocessable_entity)
end
def update
@channel.update(channel_params) ? redirect_to(tv_channel_path(@channel)) : render(:edit, status: :unprocessable_entity)
end
def destroy = (@channel.destroy and redirect_to tv_channels_path)
def subscribe
Tv::Subscription.find_or_create_by!(user: Current.user, tv_channel: @channel)
redirect_back fallback_location: tv_channel_path(@channel)
end
def unsubscribe
Tv::Subscription.find_by(user: Current.user, tv_channel: @channel)&.destroy
redirect_back fallback_location: tv_channel_path(@channel)
end
private
def set_channel = (@channel = Tv::Channel.find_by!(slug: params[:id]))
def channel_params = params.require(:tv_channel).permit(:name, :description, :banner, :avatar)
endclass Tv::HomeController < Tv::BaseController
allow_unauthenticated_access
def index
@pagy_trending, @trending = pagy(Tv::Video.trending.includes(:channel), limit: 12)
@live = Tv::Broadcast.live.includes(:channel).limit(6)
@recent = Tv::Video.recent.includes(:channel).limit(8)
end
endclass Tv::VideosController < Tv::BaseController
allow_unauthenticated_access only: %i[show]
before_action :set_video, only: %i[show destroy]
def show
@video.view_events.create!(user: Current.user) if authenticated?
@video.increment!(:views_count)
end
def new = (@video = Tv::Video.new)
def create
channel = Current.user.tv_channels.find(params[:tv_channel_id])
@video = channel.videos.build(video_params.merge(user: Current.user, status: "ready"))
@video.save ? redirect_to(tv_video_path(@video), notice: "Video uploaded") : render(:new, status: :unprocessable_entity)
end
def destroy = (@video.destroy and redirect_to tv_root_path)
private
def set_video = (@video = Tv::Video.find(params[:id]))
def video_params = params.require(:tv_video).permit(:title, :description, :video_file, :thumbnail, :tv_channel_id)
endclass TypingIndicatorsController < ApplicationController
before_action :authenticate_user!
def create
conversation = Conversation.for_user(current_user).find(params[:conversation_id])
TypingIndicator.set!(conversation:, user: current_user)
head :ok
end
endclass VotesController < ApplicationController
before_action :require_authentication
def create
@votable = find_votable
vote = @votable.votes.find_or_initialize_by(user: Current.user)
value = params.dig(:vote, :value).to_i
if vote.persisted? && vote.value == value
vote.destroy
else
vote.update!(value:)
end
respond_to do |format|
format.turbo_stream
format.html { redirect_back fallback_location: root_path }
end
end
private
def find_votable
return Post.find(params[:post_id]) if params[:post_id]
return Comment.find(params[:comment_id]) if params[:comment_id]
raise ActiveRecord::RecordNotFound, "no votable in params"
end
endmodule ApplicationHelper
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { conversationId: Number }
connect() {
this.timer = setInterval(() => this.expire(), 6000)
}
disconnect() {
clearInterval(this.timer)
}
expire() {
if (!this.element.children.length) return
this.element.replaceChildren()
}
}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { url: String }
connect() {
this.lastPing = 0
}
ping() {
const now = Date.now()
if (now - this.lastPing < 2000) return
this.lastPing = now
fetch(this.urlValue, {
method: "POST",
headers: { "X-CSRF-Token": this.csrf, "Accept": "text/vnd.turbo-stream.html" }
})
}
get csrf() {
return document.querySelector("meta[name=csrf-token]")?.content || ""
}
}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
endclass ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
endclass PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
endclass ApplicationRecord < ActiveRecord::Base
primary_abstract_class
endclass Comment < ApplicationRecord
include Votable
belongs_to :user
belongs_to :commentable, polymorphic: true, touch: true
belongs_to :parent, class_name: "Comment", optional: true
has_many :replies, class_name: "Comment", foreign_key: :parent_id, dependent: :destroy
has_many :votes, as: :votable, dependent: :destroy
validates :content, presence: true, length: { minimum: 1, maximum: 10000 }
scope :best, -> { left_joins(:votes).group(:id).order("SUM(COALESCE(votes.value, 0)) DESC") }
scope :top, -> { best }
scope :new_first, -> { order(created_at: :desc) }
scope :controversial, -> {
left_joins(:votes).group(:id)
.having("COUNT(CASE WHEN votes.value = 1 THEN 1 END) > 0")
.having("COUNT(CASE WHEN votes.value = -1 THEN 1 END) > 0")
.order("ABS(SUM(votes.value)) ASC")
}
def root? = parent_id.nil?
def depth = parent ? parent.depth + 1 : 0
endclass Community < ApplicationRecord
belongs_to :user, optional: true
has_many :posts, dependent: :destroy
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
validates :description, length: { maximum: 500 }
POPULAR_SQL = Arel.sql("COUNT(posts.id) DESC")
scope :popular, -> { left_joins(:posts).group(:id).order(POPULAR_SQL) }
endmodule Commentable
extend ActiveSupport::Concern
included do
has_many :comments, as: :commentable, dependent: :destroy
end
def root_comments = comments.where(parent_id: nil)
def comment_count = comments.count
endmodule Mentionable
extend ActiveSupport::Concern
included do
after_save :sync_mentions
end
private
def sync_mentions
usernames = (try(:content).to_s + " " + try(:title).to_s).scan(/@(\w+)/).flatten.uniq
usernames.each do |uname|
user = User.find_by(username: uname)
mentions.find_or_create_by!(mentioned_user: user) if user && user != try(:user)
end
end
endmodule Taggable
extend ActiveSupport::Concern
included do
has_many :taggings, as: :taggable, dependent: :destroy
has_many :hashtags, through: :taggings
after_save :sync_hashtags
end
def hashtag_list = hashtags.pluck(:name).join(" ")
private
def sync_hashtags
names = Hashtag.extract(try(:content).to_s + " " + try(:title).to_s)
tags = names.map { |n| Hashtag.find_or_create_by!(name: n).tap { |h| h.increment!(:usage_count) } }
self.hashtags = tags
end
endmodule Votable
extend ActiveSupport::Concern
included do
has_many :votes, as: :votable, dependent: :destroy
end
def score = votes.sum(:value)
def upvotes = votes.where(value: 1).count
def downvotes = votes.where(value: -1).count
def voted_by?(u) = u && votes.find_by(user: u)&.value
def upvoted_by?(u) = voted_by?(u) == 1
def downvoted_by?(u) = voted_by?(u) == -1
endclass Conversation < ApplicationRecord
has_many :conversation_participants, dependent: :destroy
has_many :participants, through: :conversation_participants, source: :user
has_many :messages, dependent: :destroy
has_many :typing_indicators, dependent: :destroy
validates :conversation_type, inclusion: { in: %w[direct group] }
scope :for_user, ->(u) { joins(:conversation_participants).where(conversation_participants: { user: u }) }
def self.direct_between(a, b)
for_user(a).for_user(b).where(conversation_type: "direct").first
end
def self.find_or_create_direct(a, b)
direct_between(a, b) || create!(conversation_type: "direct").tap do |c|
c.participants << a << b
end
end
def unread_count_for(user)
participant = conversation_participants.find_by(user:)
return 0 unless participant
messages.where("created_at > ?", participant.last_read_at || Time.at(0)).count
end
def mark_read_for!(user)
conversation_participants.find_by(user:)&.update!(last_read_at: Time.now)
end
endclass ConversationParticipant < ApplicationRecord
belongs_to :conversation
belongs_to :user
endclass Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :user
endmodule Dating
def self.table_name_prefix
"dating_"
end
endclass Dating::Dislike < ApplicationRecord
belongs_to :disliker, class_name: "User"
belongs_to :dislikee, class_name: "User"
validates :disliker_id, uniqueness: { scope: :dislikee_id }
validate :no_self_dislike
private
def no_self_dislike
errors.add(:dislikee, "can't dislike yourself") if disliker_id == dislikee_id
end
endclass Dating::Like < ApplicationRecord
belongs_to :liker, class_name: "User"
belongs_to :likee, class_name: "User"
validates :liker_id, uniqueness: { scope: :likee_id }
validate :no_self_like
after_create :check_mutual_match
private
def no_self_like
errors.add(:likee, "can't like yourself") if liker_id == likee_id
end
def check_mutual_match
return unless Dating::Like.exists?(liker_id: likee_id, likee_id: liker_id)
Dating::Match.find_or_create_by!(initiator_id: liker_id, receiver_id: likee_id) do |m|
m.status = "matched"
end
end
endclass Dating::Match < ApplicationRecord
belongs_to :initiator, class_name: "User"
belongs_to :receiver, class_name: "User"
validates :initiator_id, uniqueness: { scope: :receiver_id }
validates :status, inclusion: { in: %w[pending matched unmatched] }
scope :active, -> { where(status: "matched") }
def other_user(user)
initiator_id == user.id ? receiver : initiator
end
endclass Dating::Profile < ApplicationRecord
belongs_to :user
has_many_attached :photos
GENDERS = %w[man woman nonbinary other].freeze
LOOKING_FOR = %w[man woman everyone].freeze
validates :bio, length: { maximum: 500 }
validates :age, numericality: { greater_than: 17, less_than: 100 }, allow_nil: true
validates :gender, inclusion: { in: GENDERS }, allow_nil: true
validates :looking_for, inclusion: { in: LOOKING_FOR }, allow_nil: true
scope :visible, -> { where(visible: true) }
scope :nearby, ->(lat, lng, km = 50) {
where("ABS(latitude - ?) < ? AND ABS(longitude - ?) < ?", lat, km / 111.0, lng, km / 111.0)
}
def liked_by?(user) = Dating::Like.exists?(liker: user, likee: self.user)
def disliked_by?(user) = Dating::Dislike.exists?(disliker: user, dislikee: self.user)
def matched_with?(user)
Dating::Match.where(status: "matched")
.where("(initiator_id = ? AND receiver_id = ?) OR (initiator_id = ? AND receiver_id = ?)",
self.user_id, user.id, user.id, self.user_id).exists?
end
endclass Follow < ApplicationRecord
belongs_to :follower, class_name: "User"
belongs_to :followed, class_name: "User"
validates :follower_id, uniqueness: { scope: :followed_id }
validate :no_self_follow
private
def no_self_follow
errors.add(:base, "cannot follow yourself") if follower_id == followed_id
end
endclass Hashtag < ApplicationRecord
has_many :taggings, dependent: :destroy
validates :name, presence: true, uniqueness: { case_sensitive: false }
before_validation { self.name = name.to_s.downcase.gsub(/[^a-z0-9_]/, "") }
scope :trending, -> { order(usage_count: :desc) }
def self.extract(text)
text.to_s.scan(/#([a-zA-Z0-9_]+)/).flatten.map(&:downcase).uniq
end
endmodule Marketplace
def self.table_name_prefix
"marketplace_"
end
endclass Marketplace::Category < ApplicationRecord
belongs_to :parent, class_name: "Marketplace::Category", optional: true
has_many :children, class_name: "Marketplace::Category", foreign_key: :parent_id, dependent: :nullify
has_many :listings, class_name: "Marketplace::Listing", foreign_key: :category_id, dependent: :nullify
validates :name, :slug, presence: true
validates :slug, uniqueness: true
before_validation { self.slug ||= name.to_s.parameterize }
scope :roots, -> { where(parent_id: nil) }
def to_param = slug
endclass Marketplace::Listing < ApplicationRecord
belongs_to :user
belongs_to :category, class_name: "Marketplace::Category",
foreign_key: :category_id, optional: true
has_many :orders, class_name: "Marketplace::Order",
foreign_key: :listing_id, dependent: :destroy
has_many_attached :photos
CONDITIONS = %w[new like_new good fair poor].freeze
STATUSES = %w[active sold reserved removed].freeze
validates :title, presence: true, length: { maximum: 200 }
validates :price_cents, numericality: { greater_than_or_equal_to: 0 }
validates :condition, inclusion: { in: CONDITIONS }, allow_nil: true
validates :status, inclusion: { in: STATUSES }
before_validation { self.status ||= "active"; self.currency ||= "NOK" }
scope :active, -> { where(status: "active") }
scope :recent, -> { order(created_at: :desc) }
scope :popular, -> { order(views_count: :desc) }
def price_display = "#{price_cents / 100.0} #{currency}"
def sold? = status == "sold"
endclass Marketplace::Order < ApplicationRecord
belongs_to :buyer, class_name: "User"
belongs_to :listing, class_name: "Marketplace::Listing"
STATUSES = %w[pending accepted declined completed].freeze
validates :status, inclusion: { in: STATUSES }
before_validation { self.status ||= "pending" }
def seller = listing.user
def accept! = update!(status: "accepted")
def decline! = update!(status: "declined")
endclass Mention < ApplicationRecord
belongs_to :mentionable, polymorphic: true
belongs_to :mentioned_user
endclass Message < ApplicationRecord
belongs_to :conversation
belongs_to :sender, class_name: "User", foreign_key: :sender_id
has_many :message_receipts, dependent: :destroy
has_one_attached :attachment
validates :content, presence: true, length: { maximum: 10_000 }
validates :message_type, inclusion: { in: %w[text image file audio] }
broadcasts_to :conversation, inserts_by: :append, target: "messages"
after_create :deliver_receipts
after_create :clear_typing_indicators
scope :recent, -> { order(created_at: :desc) }
def expired? = expires_at&.past?
private
def deliver_receipts
conversation.participants.where.not(id: sender_id).each do |u|
message_receipts.create!(user: u, delivered_at: Time.now)
end
end
def clear_typing_indicators
TypingIndicator.where(conversation:, user: sender).delete_all
end
endclass MessageReceipt < ApplicationRecord
belongs_to :message
belongs_to :user
endmodule Playlist
def self.table_name_prefix
"playlist_"
end
endclass Playlist::Listen < ApplicationRecord
belongs_to :user
belongs_to :track, class_name: "Playlist::Track", foreign_key: :playlist_track_id
after_create :increment_plays
private
def increment_plays
track.playlists.each { |pl| pl.increment!(:plays_count) }
end
endclass Playlist::Playlist < ApplicationRecord
belongs_to :user
has_many :playlist_tracks, class_name: "Playlist::PlaylistTrack",
foreign_key: :playlist_playlist_id, dependent: :destroy
has_many :tracks, through: :playlist_tracks, class_name: "Playlist::Track",
source: :track
validates :name, presence: true, length: { maximum: 100 }
scope :public_playlists, -> { where(public_access: true) }
scope :popular, -> { order(plays_count: :desc) }
scope :recent, -> { order(created_at: :desc) }
def add_track!(track, user:)
pos = playlist_tracks.maximum(:position).to_i + 1
playlist_tracks.find_or_create_by!(track: track) do |pt|
pt.position = pos
pt.user = user
end
increment!(:tracks_count)
end
endclass Playlist::PlaylistTrack < ApplicationRecord
belongs_to :playlist, class_name: "Playlist::Playlist", foreign_key: :playlist_playlist_id
belongs_to :track, class_name: "Playlist::Track", foreign_key: :playlist_track_id
belongs_to :user
validates :playlist_playlist_id, uniqueness: { scope: :playlist_track_id }
default_scope { order(:position) }
endclass Playlist::Track < ApplicationRecord
has_many :playlist_tracks, class_name: "Playlist::PlaylistTrack",
foreign_key: :playlist_track_id, dependent: :destroy
has_many :playlists, through: :playlist_tracks, class_name: "Playlist::Playlist"
has_many :listens, class_name: "Playlist::Listen",
foreign_key: :playlist_track_id, dependent: :destroy
SOURCE_TYPES = %w[youtube spotify soundcloud direct].freeze
validates :title, :artist, presence: true
validates :source_type, inclusion: { in: SOURCE_TYPES }, allow_nil: true
def duration_formatted
return "—" unless duration_seconds
min, sec = duration_seconds.divmod(60)
"#{min}:%02d" % sec
end
endclass Post < ApplicationRecord
include Votable
belongs_to :user
belongs_to :community, optional: true
has_many :comments, as: :commentable, dependent: :destroy
has_many :votes, as: :votable, dependent: :destroy
has_many :taggings, dependent: :destroy
has_many :hashtags, through: :taggings
has_many :mentions, dependent: :destroy
validates :title, presence: true, length: { maximum: 300 }
validates :content, length: { maximum: 40_000 }
broadcasts_refreshes
VOTE_SQL = Arel.sql("SUM(COALESCE(votes.value,0)) DESC, posts.created_at DESC")
TOP_SQL = Arel.sql("SUM(COALESCE(votes.value,0)) DESC")
scope :hot, -> { left_joins(:votes).group(:id).order(VOTE_SQL) }
scope :fresh, -> { order(created_at: :desc) }
scope :top, -> { left_joins(:votes).group(:id).order(TOP_SQL) }
def comment_count = comments.count
def author_name = user&.username.presence || "anon"
endclass Reaction < ApplicationRecord
belongs_to :user
belongs_to :post
endclass Session < ApplicationRecord
belongs_to :user
endclass Stream < ApplicationRecord
belongs_to :user
belongs_to :post
endclass Tagging < ApplicationRecord
belongs_to :taggable, polymorphic: true
belongs_to :hashtag
endmodule Takeaway
def self.table_name_prefix
"takeaway_"
end
endclass Takeaway::MenuItem < ApplicationRecord
belongs_to :restaurant, class_name: "Takeaway::Restaurant"
has_one_attached :photo
validates :name, :price_cents, presence: true
validates :price_cents, numericality: { greater_than: 0 }
scope :available, -> { where(available: true) }
def price_display = "#{price_cents / 100.0} NOK"
endclass Takeaway::Order < ApplicationRecord
belongs_to :user
belongs_to :restaurant, class_name: "Takeaway::Restaurant"
has_many :order_items, class_name: "Takeaway::OrderItem", dependent: :destroy
STATUSES = %w[pending confirmed preparing out_for_delivery delivered cancelled].freeze
validates :status, inclusion: { in: STATUSES }
validates :delivery_address, presence: true
before_validation { self.status ||= "pending" }
scope :active, -> { where.not(status: %w[delivered cancelled]) }
scope :recent, -> { order(created_at: :desc) }
def calculate_totals!
sub = order_items.sum { |oi| oi.unit_price_cents * oi.quantity }
fee = restaurant.delivery_fee_cents.to_i
update!(subtotal_cents: sub, delivery_fee_cents: fee, total_cents: sub + fee)
end
def advance_status!
idx = STATUSES.index(status)
update!(status: STATUSES[idx + 1]) if idx && idx < STATUSES.length - 1
end
def total_display = "#{total_cents.to_i / 100.0} NOK"
endclass Takeaway::OrderItem < ApplicationRecord
belongs_to :order, class_name: "Takeaway::Order"
belongs_to :menu_item, class_name: "Takeaway::MenuItem"
validates :quantity, numericality: { greater_than: 0 }
def subtotal_cents = unit_price_cents * quantity
def subtotal_display = "#{subtotal_cents / 100.0} NOK"
endclass Takeaway::Restaurant < ApplicationRecord
belongs_to :user
has_many :menu_items, class_name: "Takeaway::MenuItem", dependent: :destroy
has_many :orders, class_name: "Takeaway::Order", dependent: :destroy
CUISINE_TYPES = %w[Norwegian Italian Chinese Japanese Indian Thai Mexican Pizza Burger Kebab Sushi Vegetarian Vegan].freeze
validates :name, :address, :cuisine_type, presence: true
validates :delivery_fee_cents, :min_order_cents,
numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
scope :active, -> { where(active: true) }
scope :popular, -> { order(rating: :desc) }
def update_rating!
avg = orders.joins(:reviews).average("takeaway_reviews.rating") rescue nil
update_columns(rating: avg&.round(1) || 0)
end
endmodule Tv
def self.table_name_prefix
"tv_"
end
endclass Tv::Broadcast < ApplicationRecord
belongs_to :channel, class_name: "Tv::Channel", foreign_key: :tv_channel_id
belongs_to :user
has_one_attached :thumbnail
validates :title, presence: true
before_create { self.stream_key = SecureRandom.hex(16) }
scope :live, -> { where(status: "live") }
scope :scheduled, -> { where(status: "scheduled") }
def go_live! = update!(status: "live", started_at: Time.current)
def end_live! = update!(status: "ended", ended_at: Time.current)
endclass Tv::Channel < ApplicationRecord
belongs_to :user
has_many :videos, class_name: "Tv::Video", foreign_key: :tv_channel_id, dependent: :destroy
has_many :broadcasts, class_name: "Tv::Broadcast", foreign_key: :tv_channel_id, dependent: :destroy
has_many :subscriptions, class_name: "Tv::Subscription", foreign_key: :tv_channel_id, dependent: :destroy
has_many :subscribers, through: :subscriptions, source: :user
has_one_attached :banner
has_one_attached :avatar
validates :name, :slug, presence: true
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9_-]+\z/ }
before_validation { self.slug ||= name.to_s.parameterize }
scope :popular, -> { order(subscribers_count: :desc) }
def to_param = slug
def live? = broadcasts.where(status: "live").exists?
endclass Tv::Subscription < ApplicationRecord
belongs_to :user
belongs_to :channel, class_name: "Tv::Channel", foreign_key: :tv_channel_id
validates :user_id, uniqueness: { scope: :tv_channel_id }
endclass Tv::Video < ApplicationRecord
belongs_to :channel, class_name: "Tv::Channel", foreign_key: :tv_channel_id
belongs_to :user
has_many :view_events, class_name: "Tv::ViewEvent", foreign_key: :tv_video_id, dependent: :destroy
has_one_attached :video_file
has_one_attached :thumbnail
STATUSES = %w[processing ready published unlisted].freeze
validates :title, presence: true
validates :status, inclusion: { in: STATUSES }, allow_nil: true
scope :published, -> { where(status: "published").order(published_at: :desc) }
scope :trending, -> { published.order(views_count: :desc) }
scope :recent, -> { published.order(published_at: :desc) }
def duration_formatted
return "—" unless duration_seconds
h, rem = duration_seconds.divmod(3600)
m, s = rem.divmod(60)
h > 0 ? "%d:%02d:%02d" % [h, m, s] : "%d:%02d" % [m, s]
end
endclass Tv::ViewEvent < ApplicationRecord
belongs_to :user
belongs_to :video, class_name: "Tv::Video", foreign_key: :tv_video_id
endclass TypingIndicator < ApplicationRecord
belongs_to :conversation
belongs_to :user
scope :active, -> { where("expires_at > ?", Time.now) }
def self.set!(conversation:, user:)
rec = find_or_create_by(conversation:, user:)
rec.update!(expires_at: 5.seconds.from_now)
Turbo::StreamsChannel.broadcast_replace_to(
conversation,
target: "typing-indicator",
partial: "typing_indicators/indicator",
locals: { conversation:, except_user: user }
)
rec
end
endclass User < ApplicationRecord
has_secure_password
has_many :marketplace_listings, class_name: 'Marketplace::Listing', dependent: :destroy
has_many :marketplace_orders, class_name: 'Marketplace::Order', foreign_key: :buyer_id, dependent: :destroy
has_many :takeaway_restaurants, class_name: 'Takeaway::Restaurant', dependent: :destroy
has_many :takeaway_orders, class_name: 'Takeaway::Order', dependent: :destroy
has_many :playlist_playlists, class_name: 'Playlist::Playlist', dependent: :destroy
has_many :playlist_listens, class_name: 'Playlist::Listen', dependent: :destroy
has_one :dating_profile, class_name: 'Dating::Profile', dependent: :destroy
has_many :dating_likes, class_name: 'Dating::Like', foreign_key: :liker_id, dependent: :destroy
has_many :dating_dislikes, class_name: 'Dating::Dislike', foreign_key: :disliker_id, dependent: :destroy
has_many :dating_matches_as_initiator, class_name: 'Dating::Match', foreign_key: :initiator_id, dependent: :destroy
has_many :dating_matches_as_receiver, class_name: 'Dating::Match', foreign_key: :receiver_id, dependent: :destroy
has_many :tv_channels, class_name: "Tv::Channel", dependent: :destroy
has_many :tv_subscriptions, class_name: "Tv::Subscription", dependent: :destroy
has_many :subscribed_channels, through: :tv_subscriptions, source: :tv_channel
has_many :sessions, dependent: :destroy
has_many :posts, dependent: :destroy
has_many :comments, dependent: :destroy
has_many :communities
# Voting
has_many :votes, dependent: :destroy
# Social follows
has_many :follows_as_follower, class_name: "Follow", foreign_key: :follower_id, dependent: :destroy
has_many :follows_as_followed, class_name: "Follow", foreign_key: :followed_id, dependent: :destroy
has_many :following, through: :follows_as_follower, source: :followed
has_many :followers, through: :follows_as_followed, source: :follower
# Messaging
has_many :conversation_participants, dependent: :destroy
has_many :conversations, through: :conversation_participants
validates :email_address, presence: true, uniqueness: true
validates :username, uniqueness: true, allow_nil: true
normalizes :email_address, with: ->(e) { e.strip.downcase }
def follow!(other)
follows_as_follower.find_or_create_by!(followed: other) unless other == self
end
def unfollow!(other)
follows_as_follower.find_by(followed: other)&.destroy
end
def following?(other) = follows_as_follower.exists?(followed: other)
def timeline_posts
Post.where(user: [self] + following).order(created_at: :desc)
end
def update_karma!
k = Vote.joins("JOIN posts ON posts.id = votes.votable_id AND votes.votable_type = 'Post'")
.where(posts: { user_id: id }).sum(:value)
k += Vote.joins("JOIN comments ON comments.id = votes.votable_id AND votes.votable_type = 'Comment'")
.where(comments: { user_id: id }).sum(:value)
update_column(:karma, k)
end
endclass Vote < ApplicationRecord
belongs_to :user
belongs_to :votable, polymorphic: true, touch: true
validates :value, inclusion: { in: [-1, 1] }
validates :user_id, uniqueness: { scope: [:votable_type, :votable_id] }
after_save :update_author_karma
after_destroy :update_author_karma
private
def update_author_karma
votable.user.update_karma! if votable.respond_to?(:user)
end
endrequire "ferrum"
require "net/http"
require "json"
require "base64"
class Scrape
MODEL = ENV.fetch("SCRAPE_MODEL", "google/gemini-2.0-flash-001")
ENDPOINT = URI("https://openrouter.ai/api/v1/chat/completions")
HTML_MAX = 60_000
def self.call(url, schema:, hint: nil)
browser = Ferrum::Browser.new(headless: true, timeout: 30,
browser_options: { "no-sandbox": nil })
browser.go_to(url)
browser.network.wait_for_idle(timeout: 10)
html = browser.body
png = Base64.strict_encode64(browser.screenshot(encoding: :binary, full: true))
browser.quit
reason(url:, html:, png:, schema:, hint:)
end
def self.reason(url:, html:, png:, schema:, hint:)
prompt = <<~TXT
Source: #{url}
Extract every listed item on the page as JSON. Use the screenshot to read visual layout (cards, sponsored banners, hidden overlays); use the HTML for exact text and links.
#{hint}
Reply with one JSON object: {"items":[{#{schema.join(', ')}}, ...]}.
HTML (truncated to #{HTML_MAX} bytes):
#{html.byteslice(0, HTML_MAX)}
TXT
payload = {
model: MODEL,
messages: [{
role: "user",
content: [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: "data:image/png;base64,#{png}" } }
]
}],
response_format: { type: "json_object" }
}
req = Net::HTTP::Post.new(ENDPOINT,
"Content-Type" => "application/json",
"Authorization" => "Bearer #{ENV.fetch('OPENROUTER_API_KEY')}")
req.body = payload.to_json
res = Net::HTTP.start(ENDPOINT.hostname, ENDPOINT.port, use_ssl: true, read_timeout: 60) { |h| h.request(req) }
JSON.parse(JSON.parse(res.body).dig("choices", 0, "message", "content")).fetch("items", [])
end
end<%= turbo_frame_tag dom_id(comment) do %>
<article data-depth="<%= [comment.depth, 4].min %>">
<header>
<span><%= comment.user&.username || "anon" %></span>
<span> · <%= time_ago_in_words(comment.created_at) %> ago</span>
<% if authenticated? %>
·
<% root = comment.commentable.is_a?(Post) ? comment.commentable : comment.commentable.commentable %>
<%= link_to "reply", "#reply-#{comment.id}", data: { action: "click->reply#toggle" } %>
<% if comment.user == Current.user %>
<%= button_to "delete", comment, method: :delete, data: { turbo_confirm: "Delete?" } %>
<% end %>
<% end %>
</header>
<p><%= comment.content %></p>
<% if authenticated? %>
<section id="reply-<%= comment.id %>" hidden>
<%= form_with url: post_comments_path(comment.commentable.is_a?(Post) ? comment.commentable : comment.commentable), data: { turbo: true } do |f| %>
<%= f.hidden_field :parent_id, value: comment.id %>
<p><%= f.text_area :content, placeholder: "Reply...", rows: 3 %></p>
<p><%= f.submit "Reply" %></p>
<% end %>
</section>
<% end %>
<% comment.replies.best.each do |reply| %>
<%= render "comments/comment", comment: reply %>
<% end %>
</article>
<% end %><h1>Communities</h1>
<% if authenticated? %><%= link_to "+ New", new_community_path %><% end %>
<% if @communities.any? %>
<ul>
<% @communities.each do |c| %>
<li>
<%= link_to c.name, community_path(c) %>
<% if c.description.present? %><p><%= c.description %></p><% end %>
<small><%= c.posts.count %> posts</small>
</li>
<% end %>
</ul>
<% else %>
<p>No communities yet. <%= link_to "Create one", new_community_path if authenticated? %></p>
<% end %><h1>New community</h1>
<%= form_with model: @community do |f| %>
<% if @community.errors.any? %>
<ul>
<% @community.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
</ul>
<% end %>
<p>
<%= f.label :name %>
<%= f.text_field :name, placeholder: "e.g. bergen, tech, musikk" %>
</p>
<p>
<%= f.label :description %>
<%= f.text_area :description, placeholder: "What is this community about?", rows: 3 %>
</p>
<p>
<%= f.submit "Create" %>
<%= link_to "Cancel", communities_path %>
</p>
<% end %><% content_for :title, @community.name %>
<header>
<h1><%= @community.name %></h1>
<% if @community.description.present? %><p><%= @community.description %></p><% end %>
<p><strong><%= @community.posts.count %></strong> posts</p>
</header>
<% if authenticated? %>
<p><%= link_to "+ New post in #{@community.name}", new_community_post_path(@community) %></p>
<% end %>
<% if @posts.any? %>
<% @posts.each do |post| %>
<%= render "posts/post", post: post %>
<% end %>
<% else %>
<p>No posts yet in this community.</p>
<% end %>
<aside>
<h3>About <%= @community.name %></h3>
<p><%= @community.description.presence || "No description." %></p>
<% if authenticated? %>
<p><%= link_to "New post", new_community_post_path(@community) %></p>
<% end %>
</aside><h1>Messages</h1>
<% if @conversations.any? %>
<ul>
<% @conversations.each do |c| %>
<% other = (c.participants - [Current.user]).first %>
<% last = c.messages.last %>
<li>
<%= link_to conversation_path(c) do %>
<strong><%= other&.username || "unknown" %></strong>
<% if last %>
<span><%= truncate(last.content.to_s, length: 80) %></span>
<time datetime="<%= last.created_at.iso8601 %>" data-controller="timeago"><%= time_ago_in_words(last.created_at) %> ago</time>
<% end %>
<% unread = c.unread_count_for(Current.user) %>
<% if unread.positive? %><mark><%= unread %></mark><% end %>
<% end %>
</li>
<% end %>
</ul>
<% else %>
<p>No conversations yet.</p>
<% end %><%= turbo_stream_from @conversation %>
<% other = (@conversation.participants - [Current.user]).first %>
<header>
<h1><%= other&.username || "conversation" %></h1>
<%= link_to "← all", conversations_path %>
</header>
<ul id="messages">
<% @messages.each do |m| %>
<%= render "messages/message", message: m %>
<% end %>
</ul>
<p id="typing-indicator" data-controller="typing" data-typing-conversation-id-value="<%= @conversation.id %>"></p>
<%= form_with model: @message, url: conversation_messages_path(@conversation),
id: "new_message",
data: { turbo: true, controller: "typing-input", typing_input_url_value: conversation_typing_indicators_path(@conversation) } do |f| %>
<p><%= f.text_area :content, rows: 3, placeholder: "Send a message...", data: { action: "input->typing-input#ping" } %></p>
<%= f.hidden_field :message_type, value: "text" %>
<p><%= f.submit "Send" %></p>
<% end %><% content_for :title, "Dating" %>
<h1>Discover</h1>
<nav>
<%= link_to "Matches", dating_matches_path %>
<%= link_to "Edit profile", edit_dating_profile_path %>
</nav>
<section>
<% @profiles.each do |p| %>
<article>
<% if p.photos.attached? %>
<%= image_tag p.photos.first %>
<% end %>
<h2><%= p.user.email_address.split("@").first %><% if p.age %>, <%= p.age %><% end %></h2>
<% if p.location.present? %><p><%= p.location %></p><% end %>
<p><%= p.bio %></p>
<p>
<%= button_to "Like", dating_likes_path(user_id: p.user_id), method: :post %>
<%= button_to "Pass", dating_dislikes_path(user_id: p.user_id), method: :post %>
</p>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "Matches" %>
<h1>Matches</h1>
<%= link_to "Back to discover", dating_root_path %>
<% if @matches.none? %>
<p>No matches yet.</p>
<% else %>
<section>
<% @matches.each do |m| %>
<% other = m.other_user(Current.user) %>
<article>
<% if other.dating_profile&.photos&.attached? %>
<%= image_tag other.dating_profile.photos.first %>
<% end %>
<p><%= other.email_address.split("@").first %></p>
</article>
<% end %>
</section>
<% end %>
<%= @pagy.series_nav if @pagy.pages > 1 %><h1>Edit profile</h1>
<%= form_with model: @profile, url: dating_profile_path, method: :patch do |f| %>
<%= render "shared/errors", object: @profile %>
<p>
<%= f.label :bio, "About you" %>
<%= f.text_area :bio, rows: 4, maxlength: 500 %>
</p>
<p>
<%= f.label :gender %>
<%= f.select :gender, Dating::Profile::GENDERS, { include_blank: "—" } %>
</p>
<p>
<%= f.label :looking_for, "Looking for" %>
<%= f.select :looking_for, Dating::Profile::LOOKING_FOR, { include_blank: "—" } %>
</p>
<p>
<%= f.label :age %>
<%= f.number_field :age, min: 18, max: 99 %>
</p>
<p>
<%= f.label :location %>
<%= f.text_field :location %>
</p>
<p>
<%= f.label :photos, "Photos" %>
<%= f.file_field :photos, multiple: true, accept: "image/*" %>
</p>
<p>
<%= f.check_box :visible %> <%= f.label :visible, "Make profile visible" %>
</p>
<p><%= f.submit "Save" %></p>
<% end %><h1>Create your profile</h1>
<%= form_with model: @profile, url: dating_profile_path do |f| %>
<%= render "shared/errors", object: @profile %>
<p>
<%= f.label :bio, "About you" %>
<%= f.text_area :bio, rows: 4, maxlength: 500 %>
</p>
<p>
<%= f.label :gender %>
<%= f.select :gender, Dating::Profile::GENDERS, { include_blank: "—" } %>
</p>
<p>
<%= f.label :looking_for, "Looking for" %>
<%= f.select :looking_for, Dating::Profile::LOOKING_FOR, { include_blank: "—" } %>
</p>
<p>
<%= f.label :age %>
<%= f.number_field :age, min: 18, max: 99 %>
</p>
<p>
<%= f.label :location %>
<%= f.text_field :location %>
</p>
<p>
<%= f.label :photos, "Photos" %>
<%= f.file_field :photos, multiple: true, accept: "image/*" %>
</p>
<p>
<%= f.check_box :visible %> <%= f.label :visible, "Make profile visible" %>
</p>
<p><%= f.submit "Save" %></p>
<% end %><% content_for :title, "Your profile" %>
<h1>Your profile</h1>
<% if @profile.photos.attached? %>
<ul>
<% @profile.photos.each do |photo| %>
<li><%= image_tag photo %></li>
<% end %>
</ul>
<% end %>
<dl>
<dt>Bio</dt> <dd><%= @profile.bio.presence || "—" %></dd>
<dt>Age</dt> <dd><%= @profile.age || "—" %></dd>
<dt>Gender</dt> <dd><%= @profile.gender || "—" %></dd>
<dt>Looking for</dt> <dd><%= @profile.looking_for || "—" %></dd>
<dt>Location</dt> <dd><%= @profile.location.presence || "—" %></dd>
<dt>Visibility</dt> <dd><%= @profile.visible? ? "visible" : "hidden" %></dd>
</dl>
<%= link_to "Edit profile", edit_dating_profile_path %><div class="main-col">
<div class="sort-tabs">
<span class="sort-tab active">hot</span>
<%= link_to "fresh", posts_path(sort: "fresh"), class: "sort-tab" %>
</div>
<% if @posts.any? %>
<% @posts.each do |post| %>
<%= render "posts/post", post: post %>
<% end %>
<% else %>
<div class="empty">
<p>No posts yet. <%= authenticated? ? link_to("Create one", new_post_path) : link_to("Sign in to post", new_session_path) %>.</p>
</div>
<% end %>
</div>
<div class="side-col">
<div class="sidebar-card">
<h3>Communities</h3>
<ul>
<% @communities.each do |c| %>
<li><%= link_to c.name, community_path(c) %></li>
<% end %>
</ul>
<% if authenticated? %>
<div style="margin-top:12px"><%= link_to "+ New community", new_community_path, class: "btn btn-ghost" %></div>
<% end %>
</div>
<% if authenticated? %>
<div class="sidebar-card">
<%= link_to "New post", new_post_path, class: "btn", style: "width:100%;text-align:center" %>
</div>
<% end %>
</div><!DOCTYPE html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= content_for?(:title) ? "#{yield :title} — brgen" : "brgen" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body>
<nav>
<%= link_to "brgen", root_path %>
<%= link_to "communities", communities_path %>
<%= link_to "posts", posts_path %>
<% if authenticated? %>
<%= link_to "inbox", conversations_path %>
<%= link_to "sign out", session_path, data: { turbo_method: :delete } %>
<% else %>
<%= link_to "sign in", new_session_path %>
<% end %>
</nav>
<main>
<% if notice %><p role="status"><%= notice %></p><% end %>
<% if alert %><p role="alert"><%= alert %></p><% end %>
<%= yield %>
</main>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %><% content_for :title, @category.name %>
<h1><%= @category.name %></h1>
<section>
<% @listings.each do |l| %>
<article>
<%= link_to l.title, marketplace_listing_path(l) %>
<p><strong><%= l.price_display %></strong></p>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><h1>Edit listing</h1>
<%= form_with model: @listing, url: marketplace_listing_path(@listing), method: :patch do |f| %>
<%= render "shared/errors", object: @listing %>
<p><%= f.label :title %><%= f.text_field :title %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 4 %></p>
<p><%= f.label :price_cents, "Price (øre)" %><%= f.number_field :price_cents %></p>
<p>
<%= f.label :condition %>
<%= f.select :condition, Marketplace::Listing::CONDITIONS, { include_blank: "—" } %>
</p>
<p>
<%= f.label :category_id, "Category" %>
<%= f.collection_select :category_id, @categories, :id, :name, { include_blank: "—" } %>
</p>
<p><%= f.label :location %><%= f.text_field :location %></p>
<p>
<%= f.label :status %>
<%= f.select :status, Marketplace::Listing::STATUSES %>
</p>
<p><%= f.label :photos %><%= f.file_field :photos, multiple: true, accept: "image/*" %></p>
<p><%= f.submit "Save" %></p>
<% end %><% content_for :title, "Marketplace" %>
<h1>Marketplace</h1>
<% if authenticated? %><%= link_to "Sell something", new_marketplace_listing_path %><% end %>
<form method="get">
<p><input name="q" value="<%= params[:q] %>" placeholder="Search listings…"></p>
<p><button type="submit">Search</button></p>
</form>
<% if @categories.any? %>
<nav>
<% @categories.each do |cat| %>
<%= link_to cat.name, marketplace_category_path(cat) %>
<% end %>
</nav>
<% end %>
<section>
<% @listings.each do |l| %>
<article>
<% if l.photos.attached? %>
<%= link_to marketplace_listing_path(l) do %>
<%= image_tag l.photos.first %>
<% end %>
<% end %>
<%= link_to l.title, marketplace_listing_path(l) %>
<p><strong><%= l.price_display %></strong></p>
<small><%= l.location %> · <%= l.condition %></small>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><h1>New listing</h1>
<%= form_with model: @listing, url: marketplace_listings_path do |f| %>
<%= render "shared/errors", object: @listing %>
<p><%= f.label :title %><%= f.text_field :title %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 4 %></p>
<p><%= f.label :price_cents, "Price (øre)" %><%= f.number_field :price_cents %></p>
<p>
<%= f.label :condition %>
<%= f.select :condition, Marketplace::Listing::CONDITIONS, { include_blank: "—" } %>
</p>
<p>
<%= f.label :category_id, "Category" %>
<%= f.collection_select :category_id, @categories, :id, :name, { include_blank: "—" } %>
</p>
<p><%= f.label :location %><%= f.text_field :location %></p>
<p><%= f.label :photos %><%= f.file_field :photos, multiple: true, accept: "image/*" %></p>
<p><%= f.submit "List it" %></p>
<% end %><% content_for :title, @listing.title %>
<% if @listing.photos.attached? %>
<ul>
<% @listing.photos.each do |photo| %>
<li><%= image_tag photo %></li>
<% end %>
</ul>
<% end %>
<h1><%= @listing.title %></h1>
<p><strong><%= @listing.price_display %></strong></p>
<p><%= @listing.description %></p>
<p>Condition: <%= @listing.condition %> · Location: <%= @listing.location %></p>
<p>Seller: <%= @listing.user.email_address.split("@").first %></p>
<% if authenticated? && Current.user != @listing.user && !@listing.sold? %>
<h2>Make an offer</h2>
<%= form_with model: @order, url: marketplace_listing_orders_path(@listing) do |f| %>
<p><%= f.text_area :message, placeholder: "Message to seller (optional)", rows: 3 %></p>
<p><%= f.submit "Send offer" %></p>
<% end %>
<% end %>
<% if authenticated? && Current.user == @listing.user %>
<h2>Offers</h2>
<% @listing.orders.includes(:buyer).each do |o| %>
<article>
<p><%= o.buyer.email_address.split("@").first %> — <%= o.status %></p>
<% if o.message.present? %><p><%= o.message %></p><% end %>
<% if o.status == "pending" %>
<%= button_to "Accept", marketplace_listing_order_path(@listing, o), params: { accept: true }, method: :patch %>
<%= button_to "Decline", marketplace_listing_order_path(@listing, o), params: { decline: true }, method: :patch %>
<% end %>
</article>
<% end %>
<nav>
<%= link_to "Edit", edit_marketplace_listing_path(@listing) %>
<%= button_to "Remove", marketplace_listing_path(@listing), method: :delete %>
</nav>
<% end %><%= turbo_frame_tag dom_id(message) do %>
<article data-from="<%= message.sender == Current.user ? "self" : "peer" %>">
<header>
<span><%= message.sender.username %></span>
<time datetime="<%= message.created_at.iso8601 %>" data-controller="timeago"><%= time_ago_in_words(message.created_at) %> ago</time>
</header>
<p><%= message.content %></p>
<% if message.attachment.attached? %>
<% case message.message_type %>
<% when "image" %>
<%= image_tag message.attachment %>
<% when "audio" %>
<%= audio_tag message.attachment, controls: true %>
<% else %>
<%= link_to message.attachment.filename, message.attachment %>
<% end %>
<% end %>
</article>
<% end %><%= turbo_stream.replace "new_message" do %>
<%= form_with model: Message.new, url: conversation_messages_path(@conversation),
id: "new_message",
data: { turbo: true, controller: "typing-input", typing_input_url_value: conversation_typing_indicators_path(@conversation) } do |f| %>
<div class="field">
<%= f.text_area :content, rows: 3, placeholder: "Send a message...", data: { action: "input->typing-input#ping" } %>
</div>
<%= f.hidden_field :message_type, value: "text" %>
<%= f.submit "Send", class: "btn" %>
<% end %>
<% end %><h1>New message</h1>
<% if @message&.errors&.any? %>
<ul>
<% @message.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
<% end %>
<%= form_with model: @message, url: conversation_messages_path(@conversation), id: "new_message" do |f| %>
<p><%= f.text_area :content, rows: 4, placeholder: "Message..." %></p>
<%= f.hidden_field :message_type, value: "text" %>
<p><%= f.submit "Send" %></p>
<% end %><h1>Update your password</h1>
<% if flash[:alert] %><p><%= flash[:alert] %></p><% end %>
<%= form_with url: password_path(params[:token]), method: :put do |form| %>
<p><%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %></p>
<p><%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %></p>
<p><%= form.submit "Save" %></p>
<% end %><h1>Forgot your password?</h1>
<% if flash[:alert] %><p><%= flash[:alert] %></p><% end %>
<%= form_with url: passwords_path do |form| %>
<p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
<p><%= form.submit "Email reset instructions" %></p>
<% end %><p>
You can reset your password on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
</p>You can reset your password on
<%= edit_password_url(@user.password_reset_token) %>
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.<section>
<h2>Playlists</h2>
<p>Discover music from Bergen and beyond.</p>
</section>
<% @playlists.each do |playlist| %>
<article>
<h2><%= playlist[:name] %></h2>
<p><strong><%= playlist[:tracks] %></strong> tracks · <%= playlist[:genre] %></p>
</article>
<% end %><h1>Edit playlist</h1>
<%= form_with model: @playlist, url: playlist_playlist_path(@playlist), method: :patch do |f| %>
<%= render "shared/errors", object: @playlist %>
<p><%= f.label :name %><%= f.text_field :name %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 3 %></p>
<p><%= f.check_box :public_access %> <%= f.label :public_access, "Public" %></p>
<p><%= f.submit "Save" %></p>
<% end %><% content_for :title, "Playlists" %>
<h1>Playlists</h1>
<% if authenticated? %><%= link_to "New playlist", new_playlist_playlist_path %><% end %>
<section>
<% @playlists.each do |pl| %>
<article>
<%= link_to pl.name, playlist_playlist_path(pl) %>
<small><%= pl.tracks_count %> tracks · <%= pl.plays_count %> plays · <%= pl.user.email_address.split("@").first %></small>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><h1>New playlist</h1>
<%= form_with model: @playlist, url: playlist_playlists_path do |f| %>
<%= render "shared/errors", object: @playlist %>
<p><%= f.label :name %><%= f.text_field :name %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 3 %></p>
<p><%= f.check_box :public_access %> <%= f.label :public_access, "Public" %></p>
<p><%= f.submit "Create" %></p>
<% end %><% content_for :title, @playlist.name %>
<h1><%= @playlist.name %></h1>
<p><%= @playlist.description %></p>
<p><small><%= @playlist.tracks_count %> tracks · <%= @playlist.plays_count %> plays</small></p>
<% if authenticated? && Current.user == @playlist.user %>
<nav>
<%= link_to "Edit", edit_playlist_playlist_path(@playlist) %>
<%= button_to "Delete", playlist_playlist_path(@playlist), method: :delete, data: { turbo_confirm: "Delete?" } %>
</nav>
<% end %>
<ol>
<% @tracks.each do |pt| %>
<li>
<strong><%= pt.track.title %></strong> — <%= pt.track.artist %>
<small><%= pt.track.duration_formatted %></small>
<% if authenticated? && (Current.user == pt.user || Current.user == @playlist.user) %>
<%= button_to "Remove", playlist_playlist_track_path(@playlist, pt), method: :delete %>
<% end %>
</li>
<% end %>
</ol>
<% if authenticated? %>
<h2>Add a track</h2>
<%= form_with url: playlist_playlist_tracks_path(@playlist) do |f| %>
<p><%= f.text_field :title, placeholder: "Title", name: "playlist_track[title]" %></p>
<p><%= f.text_field :artist, placeholder: "Artist", name: "playlist_track[artist]" %></p>
<p><%= f.text_field :source_url, placeholder: "URL (optional)", name: "playlist_track[source_url]" %></p>
<p><%= f.submit "Add" %></p>
<% end %>
<% end %><%= turbo_frame_tag dom_id(post) do %>
<article>
<% if authenticated? %>
<%= button_to "▲", post_vote_path(post), params: { vote: { value: 1 } },
class: ("active-up" if post.upvoted_by?(Current.user)) %>
<% else %>
<span>▲</span>
<% end %>
<span data-controller="animated-number"><%= post.score %></span>
<% if authenticated? %>
<%= button_to "▼", post_vote_path(post), params: { vote: { value: -1 } },
class: ("active-down" if post.downvoted_by?(Current.user)) %>
<% else %>
<span>▼</span>
<% end %>
<header>
<% if post.community %>
<%= link_to post.community.name, community_path(post.community) %>
·
<% end %>
posted by <span><%= post.author_name %></span>
·
<time datetime="<%= post.created_at.iso8601 %>" data-controller="timeago"><%= time_ago_in_words(post.created_at) %> ago</time>
</header>
<h2><%= link_to post.title, post %></h2>
<footer>
<%= link_to "#{post.comment_count} comments", post_path(post) %>
<%= button_to "share", "#", data: { controller: "clipboard", clipboard_source_value: post_url(post), action: "click->clipboard#copy" } %>
<% if authenticated? && post.user == Current.user %>
<%= link_to "edit", edit_post_path(post) %>
<%= button_to "delete", post, method: :delete, data: { turbo_confirm: "Delete this post?" } %>
<% end %>
</footer>
</article>
<% end %><%= turbo_stream_from "posts" %>
<header>
<h1>All posts</h1>
<% if authenticated? %><%= link_to "+ New post", new_post_path %><% end %>
</header>
<nav>
<%= link_to "hot", posts_path, class: ("active" unless params[:sort]) %>
<%= link_to "fresh", posts_path(sort: "fresh"), class: ("active" if params[:sort] == "fresh") %>
<%= link_to "top", posts_path(sort: "top"), class: ("active" if params[:sort] == "top") %>
</nav>
<% if @posts.any? %>
<% @posts.each do |post| %>
<%= render "posts/post", post: post %>
<% end %>
<% else %>
<p>No posts yet.</p>
<% end %>
<aside>
<h3>Communities</h3>
<ul>
<% Community.popular.limit(8).each do |c| %>
<li><%= link_to c.name, community_path(c) %></li>
<% end %>
</ul>
</aside><h1><%= @post.new_record? ? "New post" : "Edit post" %></h1>
<%= form_with model: [@community, @post].compact do |f| %>
<% if @post.errors.any? %>
<ul>
<% @post.errors.full_messages.each do |msg| %><li><%= msg %></li><% end %>
</ul>
<% end %>
<p>
<%= f.label :community_id, "Community (optional)" %>
<%= f.collection_select :community_id, Community.order(:name), :id, :name,
{ include_blank: "— no community —" } %>
</p>
<p>
<%= f.label :title %>
<%= f.text_field :title, placeholder: "Title", autofocus: true %>
</p>
<p>
<%= f.label :content, "Body (optional)" %>
<%= f.text_area :content, placeholder: "Text (optional)", rows: 8 %>
</p>
<p>
<%= f.submit @post.new_record? ? "Post" : "Save" %>
<%= link_to "Cancel", @post.new_record? ? posts_path : @post %>
</p>
<% end %><% content_for :title, @post.title %>
<%= turbo_stream_from @post %>
<article>
<% if authenticated? %>
<%= button_to "▲", post_vote_path(@post), params: { vote: { value: 1 } },
class: ("active-up" if @post.upvoted_by?(Current.user)) %>
<% else %>
<span>▲</span>
<% end %>
<span data-controller="animated-number"><%= @post.score %></span>
<% if authenticated? %>
<%= button_to "▼", post_vote_path(@post), params: { vote: { value: -1 } },
class: ("active-down" if @post.downvoted_by?(Current.user)) %>
<% else %>
<span>▼</span>
<% end %>
<header>
<% if @post.community %>
<%= link_to @post.community.name, community_path(@post.community) %> ·
<% end %>
posted by <%= @post.author_name %> ·
<time datetime="<%= @post.created_at.iso8601 %>" data-controller="timeago"><%= time_ago_in_words(@post.created_at) %> ago</time>
</header>
<h1><%= @post.title %></h1>
<% if @post.content.present? %>
<p><%= @post.content %></p>
<% end %>
</article>
<section id="comments-section">
<% if authenticated? %>
<h3>Comment as <%= Current.user.username || "guest" %></h3>
<%= form_with model: [@post, @new_comment], data: { turbo: true } do |f| %>
<p>
<%= f.text_area :content, placeholder: "What do you think?", rows: 4,
data: { controller: "textarea-autogrow character-counter", "character-counter-max-value": 10000 } %>
<span data-character-counter-target="counter"></span>
</p>
<p><%= f.submit "Comment" %></p>
<% end %>
<% end %>
<ul id="comments">
<% if @comments.any? %>
<% @comments.each do |comment| %>
<%= render "comments/comment", comment: comment %>
<% end %>
<% else %>
<li>No comments yet. Be first.</li>
<% end %>
</ul>
</section>
<aside>
<h3>About</h3>
<% if @post.community %>
<p><%= @post.community.description.presence || @post.community.name %></p>
<%= link_to "View community", community_path(@post.community) %>
<% else %>
<p>brgen — Bergen community platform</p>
<% end %>
</aside>{
"name": "App",
"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": "App.",
"theme_color": "red",
"background_color": "red"
}// 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)
// }
// })
// )
// })<% if flash[:alert] %><p><%= flash[:alert] %></p><% end %>
<% if flash[:notice] %><p><%= flash[:notice] %></p><% end %>
<%= form_with url: session_path do |form| %>
<p><%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %></p>
<p><%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %></p>
<p><%= form.submit "Sign in" %></p>
<% end %>
<p><%= link_to "Forgot password?", new_password_path %></p><%= form_with url: votes_path(votable_type: votable.class.name, votable_id: votable.id), method: :post do |f| %>
<%= f.hidden_field :value, value: 1 %>
<%= f.button "▲", type: :submit %>
<% end %>
<span><%= votable.score %></span>
<%= form_with url: votes_path(votable_type: votable.class.name, votable_id: votable.id), method: :post do |f| %>
<%= f.hidden_field :value, value: -1 %>
<%= f.button "▼", type: :submit %>
<% end %><% content_for :title, "My Orders" %>
<h1>My Orders</h1>
<% @orders.each do |o| %>
<article>
<%= link_to o.restaurant.name, takeaway_order_path(o) %>
<span><%= o.status %></span>
<span><%= o.total_display %></span>
<time><%= o.created_at.strftime("%d %b %Y") %></time>
</article>
<% end %>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "Order ##{@order.id}" %>
<h1>Order #<%= @order.id %></h1>
<p>Status: <strong><%= @order.status %></strong></p>
<p>Restaurant: <%= link_to @order.restaurant.name, takeaway_restaurant_path(@order.restaurant) %></p>
<p>Delivery to: <%= @order.delivery_address %></p>
<ul>
<% @order.order_items.includes(:menu_item).each do |oi| %>
<li><%= oi.menu_item.name %> × <%= oi.quantity %> — <%= oi.subtotal_display %></li>
<% end %>
</ul>
<p>Total: <strong><%= @order.total_display %></strong></p>
<% if Current.user == @order.restaurant.user && @order.status != "delivered" %>
<%= button_to "Advance status", takeaway_order_path(@order), method: :patch, class: "btn btn--primary" %>
<% end %><h1>Edit restaurant</h1>
<%= form_with model: @restaurant, url: takeaway_restaurant_path(@restaurant), method: :patch do |f| %>
<%= render "shared/errors", object: @restaurant %>
<p><%= f.label :name %><%= f.text_field :name %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 3 %></p>
<p><%= f.label :address %><%= f.text_field :address %></p>
<p><%= f.label :city %><%= f.text_field :city %></p>
<p><%= f.label :phone %><%= f.text_field :phone %></p>
<p>
<%= f.label :cuisine_type %>
<%= f.select :cuisine_type, Takeaway::Restaurant::CUISINE_TYPES, { include_blank: "—" } %>
</p>
<p><%= f.label :delivery_fee_cents, "Delivery fee (øre)" %><%= f.number_field :delivery_fee_cents %></p>
<p><%= f.label :min_order_cents, "Min order (øre)" %><%= f.number_field :min_order_cents %></p>
<p><%= f.check_box :active %> <%= f.label :active, "Active" %></p>
<p><%= f.submit "Save" %></p>
<% end %><% content_for :title, "Takeaway" %>
<h1>Order food</h1>
<% if authenticated? %><%= link_to "Add restaurant", new_takeaway_restaurant_path %><% end %>
<form method="get">
<p><input name="q" value="<%= params[:q] %>" placeholder="Search restaurants…"></p>
<p>
<select name="cuisine">
<option value="">All cuisines</option>
<% Takeaway::Restaurant::CUISINE_TYPES.each do |c| %>
<option value="<%= c %>" <%= "selected" if params[:cuisine] == c %>><%= c %></option>
<% end %>
</select>
</p>
<p><button type="submit">Search</button></p>
</form>
<section>
<% @restaurants.each do |r| %>
<article>
<%= link_to r.name, takeaway_restaurant_path(r) %>
<small><%= r.cuisine_type %> · <%= r.city %> · ★ <%= r.rating || "—" %></small>
<small>Min order: <%= r.min_order_cents.to_i / 100.0 %> NOK · Delivery: <%= r.delivery_fee_cents.to_i / 100.0 %> NOK</small>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><h1>Add restaurant</h1>
<%= form_with model: @restaurant, url: takeaway_restaurants_path do |f| %>
<%= render "shared/errors", object: @restaurant %>
<p><%= f.label :name %><%= f.text_field :name %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 3 %></p>
<p><%= f.label :address %><%= f.text_field :address %></p>
<p><%= f.label :city %><%= f.text_field :city %></p>
<p><%= f.label :phone %><%= f.text_field :phone %></p>
<p>
<%= f.label :cuisine_type %>
<%= f.select :cuisine_type, Takeaway::Restaurant::CUISINE_TYPES, { include_blank: "—" } %>
</p>
<p><%= f.label :delivery_fee_cents, "Delivery fee (øre)" %><%= f.number_field :delivery_fee_cents %></p>
<p><%= f.label :min_order_cents, "Min order (øre)" %><%= f.number_field :min_order_cents %></p>
<p><%= f.check_box :active %> <%= f.label :active, "Active" %></p>
<p><%= f.submit "Save" %></p>
<% end %><% content_for :title, @restaurant.name %>
<h1><%= @restaurant.name %></h1>
<p><%= @restaurant.description %></p>
<p><%= @restaurant.cuisine_type %> · <%= @restaurant.address %>, <%= @restaurant.city %></p>
<p>Min order: <%= @restaurant.min_order_cents.to_i / 100.0 %> NOK · Delivery: <%= @restaurant.delivery_fee_cents.to_i / 100.0 %> NOK</p>
<% if authenticated? && Current.user == @restaurant.user %>
<nav>
<%= link_to "Edit", edit_takeaway_restaurant_path(@restaurant) %>
</nav>
<h2>Add menu item</h2>
<%= form_with url: takeaway_restaurant_menu_items_path(@restaurant) do |f| %>
<p><input name="takeaway_menu_item[name]" placeholder="Name" required></p>
<p><input name="takeaway_menu_item[price_cents]" type="number" placeholder="Price (øre)" required></p>
<p><input name="takeaway_menu_item[description]" placeholder="Description"></p>
<p><button type="submit">Add</button></p>
<% end %>
<% end %>
<h2>Menu</h2>
<% if authenticated? %>
<%= form_with url: takeaway_restaurant_orders_path(@restaurant) do |f| %>
<ul>
<% @menu_items.each do |item| %>
<li>
<strong><%= item.name %></strong> — <%= item.price_display %>
<% if item.description.present? %><br><small><%= item.description %></small><% end %>
<input type="number" name="takeaway_order[items][<%= item.id %>]" min="0" value="0">
</li>
<% end %>
</ul>
<p><input name="takeaway_order[delivery_address]" placeholder="Delivery address" required></p>
<p><textarea name="takeaway_order[special_instructions]" placeholder="Special instructions (optional)"></textarea></p>
<p><button type="submit">Place order</button></p>
<% end %>
<% else %>
<%= link_to "Sign in to order", new_session_path %>
<% end %><% content_for :title, "Edit channel" %>
<h1>Edit channel</h1>
<%= form_with model: @channel, url: tv_channel_path(@channel), method: :patch do |f| %>
<%= render "shared/errors", object: @channel %>
<p>
<%= f.label :name %>
<%= f.text_field :name, required: true %>
</p>
<p>
<%= f.label :description %>
<%= f.text_area :description, rows: 4 %>
</p>
<p>
<%= f.label :avatar %>
<%= f.file_field :avatar, accept: "image/*" %>
</p>
<p>
<%= f.label :banner %>
<%= f.file_field :banner, accept: "image/*" %>
</p>
<p><%= f.submit "Save" %></p>
<% end %><h1>Channels</h1>
<section>
<% @channels.each do |c| %>
<article>
<%= link_to c.name, tv_channel_path(c) %>
<small><%= c.subscribers_count %> subscribers<% if c.live? %> · <mark>LIVE</mark><% end %></small>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "New channel" %>
<h1>New channel</h1>
<%= form_with model: @channel, url: tv_channels_path do |f| %>
<%= render "shared/errors", object: @channel %>
<p>
<%= f.label :name %>
<%= f.text_field :name, required: true %>
</p>
<p>
<%= f.label :description %>
<%= f.text_area :description, rows: 4 %>
</p>
<p>
<%= f.label :avatar %>
<%= f.file_field :avatar, accept: "image/*" %>
</p>
<p>
<%= f.label :banner %>
<%= f.file_field :banner, accept: "image/*" %>
</p>
<p><%= f.submit "Create channel" %></p>
<% end %><% content_for :title, @channel.name %>
<h1><%= @channel.name %></h1>
<p><%= @channel.subscribers_count %> subscribers</p>
<p><%= @channel.description %></p>
<% if authenticated? && Current.user != @channel.user %>
<% if @channel.subscriptions.exists?(user: Current.user) %>
<%= button_to "Unsubscribe", unsubscribe_tv_channel_path(@channel), method: :delete %>
<% else %>
<%= button_to "Subscribe", subscribe_tv_channel_path(@channel), method: :post %>
<% end %>
<% end %>
<section><%= render @videos %></section>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "Brgen TV" %>
<h1>Brgen TV</h1>
<nav>
<%= link_to "All channels", tv_channels_path %>
<% if authenticated? %> · <%= link_to "Create channel", new_tv_channel_path %><% end %>
</nav>
<% if @live.any? %>
<section>
<h2>Live now</h2>
<% @live.each do |b| %>
<article>
<%= link_to tv_channel_path(b.channel) do %>
<mark>LIVE</mark> <%= b.title %> — <%= b.channel.name %>
<% end %>
</article>
<% end %>
</section>
<% end %>
<section>
<h2>Trending</h2>
<%= render @trending %>
<%= @pagy_trending.series_nav if @pagy_trending.pages > 1 %>
</section>
<section>
<h2>Recent</h2>
<%= render @recent %>
</section><article>
<%= link_to tv_video_path(tv_video) do %>
<% if tv_video.thumbnail.attached? %>
<%= image_tag tv_video.thumbnail %>
<% else %>
<span><%= tv_video.title[0] %></span>
<% end %>
<p><strong><%= tv_video.title %></strong></p>
<small><%= tv_video.channel.name %> · <%= tv_video.views_count %> views · <%= tv_video.duration_formatted %></small>
<% end %>
</article><% content_for :title, "Upload video" %>
<h1>Upload video</h1>
<%= form_with model: @video, url: tv_videos_path, multipart: true do |f| %>
<%= render "shared/errors", object: @video %>
<p>
<%= f.label :tv_channel_id, "Channel" %>
<%= f.collection_select :tv_channel_id, Current.user.tv_channels.order(:name), :id, :name, include_blank: false %>
</p>
<p>
<%= f.label :title %>
<%= f.text_field :title, required: true %>
</p>
<p>
<%= f.label :description %>
<%= f.text_area :description, rows: 4 %>
</p>
<p>
<%= f.label :video_file %>
<%= f.file_field :video_file, accept: "video/*", required: true %>
</p>
<p>
<%= f.label :thumbnail %>
<%= f.file_field :thumbnail, accept: "image/*" %>
</p>
<p><%= f.submit "Upload" %></p>
<% end %><% content_for :title, @video.title %>
<% if @video.video_file.attached? %>
<%= video_tag url_for(@video.video_file), controls: true, class: "video-player" %>
<% end %>
<h1><%= @video.title %></h1>
<p><%= link_to @video.channel.name, tv_channel_path(@video.channel) %> · <%= @video.views_count %> views · <%= @video.duration_formatted %></p>
<p><%= @video.description %></p><p id="typing-indicator">
<% typers = conversation.typing_indicators.active.includes(:user).reject { |t| t.user_id == except_user&.id } %>
<% if typers.any? %>
<%= typers.map { |t| t.user.username }.join(", ") %> typing...
<% end %>
</p><%= turbo_stream.replace dom_id(@votable) do %>
<% case @votable %>
<% when Post %><%= render "posts/post", post: @votable %>
<% when Comment %><%= render "comments/comment", comment: @votable %>
<% end %>
<% end %>require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.2
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
endENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" # Set up gems listed in the Gemfile.# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit.
# CVEs that are not relevant to the application can be enumerated on the ignore list below.
ignore:
- CVE-THAT-DOES-NOT-APPLY# 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.daydefault: &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# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
step "Tests: Rails", "bin/rails test"
step "Tests: System", "bin/rails test:system"
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
enddefault: &default
adapter: sqlite3
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
primary:
<<: *default
database: db/development.sqlite3
cache:
<<: *default
database: db/development_cache.sqlite3
migrations_paths: db/cache_schema_migrations
queue:
<<: *default
database: db/development_queue.sqlite3
migrations_paths: db/queue_schema_migrations
cable:
<<: *default
database: db/development_cable.sqlite3
migrations_paths: db/cable_schema_migrations
test:
primary:
<<: *default
database: db/test.sqlite3
cache:
<<: *default
database: db/test_cache.sqlite3
migrations_paths: db/cache_schema_migrations
queue:
<<: *default
database: db/test_queue.sqlite3
migrations_paths: db/queue_schema_migrations
cable:
<<: *default
database: db/test_cable.sqlite3
migrations_paths: db/cable_schema_migrations
production:
primary:
<<: *default
database: db/production.sqlite3
cache:
<<: *default
database: db/production_cache.sqlite3
migrations_paths: db/cache_schema_migrations
queue:
<<: *default
database: db/production_queue.sqlite3
migrations_paths: db/queue_schema_migrations
cable:
<<: *default
database: db/production_cable.sqlite3
migrations_paths: db/cable_schema_migrations# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer.
#
# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
# proxy:
# ssl: true
# host: app.example.com
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.3.7
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# Load the Rails application.
require_relative "application"
# Initialize the Rails application.
Rails.application.initialize!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
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Suppress logger output for asset requests.
# config.assets.quiet = true # sprockets only — not used with propshaft
# 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!
endrequire "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 the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# 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 = Logger.new(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 bin/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 ]
config.hosts = ["brgen.no", /.*\.brgen\.no\z/]
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
end# 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# frozen_string_literal: true
load :rack, :supervisor
hostname = File.basename(__dir__)
port = ENV.fetch("PORT", 11006).to_i
rack hostname do
endpoint Async::HTTP::Endpoint.parse("http://0.0.0.0:#{port}").with(protocol: Async::HTTP::Protocol::HTTP2)
end# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.7# assets initializer disabled - using Propshaft not Sprockets# 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
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end# 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
]# 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# 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"# 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. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# 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"]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# 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 12Rails.application.routes.draw do
# Vertical subdomains share one app. The Rails module name (Tv:: etc.) is fixed;
# the public subdomain varies per locale (markedsplass = NO marketplace).
TV_SUBDOMAINS = %w[tv].freeze
DATING_SUBDOMAINS = %w[dating].freeze
PLAYLIST_SUBDOMAINS = %w[playlist].freeze
TAKEAWAY_SUBDOMAINS = %w[takeaway].freeze
MARKETPLACE_SUBDOMAINS = %w[
markedsplass markadur marknadsplats marktplaats marktplatz
marche mercato mercado markkinapaikka marketplace
].freeze
resource :session
resources :passwords, param: :token
resources :communities do
resources :posts, shallow: true do
resources :comments, shallow: true do
resources :comments, shallow: true, as: :replies
end
resource :vote, only: [:create], controller: "votes"
end
end
resources :posts do
resources :comments, shallow: true
resource :vote, only: [:create], controller: "votes"
end
resources :comments do
resource :vote, only: [:create], controller: "votes"
resources :comments, only: [:create], as: :replies
end
resources :users, only: [:show] do
member do
post :follow, to: "follows#create"
delete :unfollow, to: "follows#destroy"
end
resources :conversations, only: [:create]
end
resources :conversations, only: [:index, :show] do
resources :messages, only: [:create]
end
constraints(subdomain: TV_SUBDOMAINS) do
scope module: "tv", as: "tv" do
root "home#index", as: :tv_root
resources :channels, param: :slug do
member { post :subscribe; delete :unsubscribe }
resources :videos, only: %i[new create]
end
resources :videos, only: %i[show destroy]
end
end
constraints(subdomain: DATING_SUBDOMAINS) do
scope module: "dating", as: "dating" do
root "home#index", as: :dating_root
resource :profile, only: %i[new create edit update show]
resources :likes, only: :create
resources :dislikes, only: :create
resources :matches, only: :index
end
end
constraints(subdomain: PLAYLIST_SUBDOMAINS) do
scope module: "playlist", as: "playlist" do
root "playlists#index", as: :playlist_root
resources :playlists do
resources :tracks, only: %i[create destroy]
end
resources :listens, only: :create
end
end
constraints(subdomain: TAKEAWAY_SUBDOMAINS) do
scope module: "takeaway", as: "takeaway" do
root "restaurants#index", as: :takeaway_root
resources :restaurants do
resources :menu_items, only: %i[create destroy]
resources :orders, only: %i[new create]
end
resources :orders, only: %i[index show update]
end
end
constraints(subdomain: MARKETPLACE_SUBDOMAINS) do
scope module: "marketplace", as: "marketplace" do
root "listings#index", as: :marketplace_root
resources :listings do
resources :orders, only: %i[create update]
end
resources :categories, only: :show, param: :id
end
end
root "home#index"
get "up" => "rails/health#show", as: :rails_health_check
endtest:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>
# 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 %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]# 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.1].define(version: 0) do
end# 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.1].define(version: 0) do
endclass CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
endclass CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
endclass CreateCommunities < ActiveRecord::Migration[8.1]
def change
create_table :communities do |t|
t.string :name
t.text :description
t.string :subdomain
t.string :slug
t.timestamps
end
add_index :communities, :subdomain, unique: true
add_index :communities, :slug, unique: true
end
endclass CreateReactions < ActiveRecord::Migration[8.1]
def change
create_table :reactions do |t|
t.string :kind
t.references :user, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.timestamps
end
end
endclass CreateStreams < ActiveRecord::Migration[8.1]
def change
create_table :streams do |t|
t.string :content_type
t.string :url
t.references :user, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.integer :duration
t.timestamps
end
end
endclass CreatePosts < ActiveRecord::Migration[8.1]
def change
create_table :posts do |t|
t.string :title
t.text :content
t.references :user, null: false, foreign_key: true
t.references :community, null: false, foreign_key: true
t.integer :karma
t.boolean :anonymous
t.timestamps
end
end
endclass CreateComments < ActiveRecord::Migration[8.1]
def change
create_table :comments do |t|
t.text :content
t.references :user, null: false, foreign_key: true
t.references :commentable, polymorphic: true, null: false
t.integer :parent_id
t.timestamps
end
end
endclass AddFieldsToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :username, :string
add_column :users, :karma, :integer
end
endclass CreateVotes < ActiveRecord::Migration[8.1]
def change
create_table :votes do |t|
t.integer :value
t.references :user, null: false, foreign_key: true
t.references :votable, polymorphic: true, null: false
t.timestamps
end
end
endclass CreateFollows < ActiveRecord::Migration[8.1]
def change
create_table :follows do |t|
t.integer :follower_id
t.integer :followed_id
t.timestamps
end
add_index :follows, :follower_id
add_index :follows, :followed_id
end
endclass CreateHashtags < ActiveRecord::Migration[8.1]
def change
create_table :hashtags do |t|
t.string :name
t.integer :usage_count
t.timestamps
end
add_index :hashtags, :name, unique: true
end
endclass CreateTaggings < ActiveRecord::Migration[8.1]
def change
create_table :taggings do |t|
t.references :taggable, polymorphic: true, null: false
t.references :hashtag, null: false, foreign_key: true
t.timestamps
end
end
endclass CreateMentions < ActiveRecord::Migration[8.0]
def change
create_table :mentions do |t|
t.references :mentionable, polymorphic: true, null: false
t.references :mentioned_user, null: false, foreign_key: { to_table: :users }
t.timestamps
end
end
endclass CreateConversations < ActiveRecord::Migration[8.1]
def change
create_table :conversations do |t|
t.string :conversation_type
t.string :name
t.timestamps
end
end
endclass CreateConversationParticipants < ActiveRecord::Migration[8.1]
def change
create_table :conversation_participants do |t|
t.references :conversation, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.datetime :last_read_at
t.timestamps
end
end
endclass CreateMessages < ActiveRecord::Migration[8.1]
def change
create_table :messages do |t|
t.references :conversation, null: false, foreign_key: true
t.integer :sender_id
t.text :content
t.string :message_type
t.datetime :expires_at
t.timestamps
end
end
endclass CreateMessageReceipts < ActiveRecord::Migration[8.1]
def change
create_table :message_receipts do |t|
t.references :message, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.datetime :delivered_at
t.datetime :read_at
t.timestamps
end
end
endclass CreateTypingIndicators < ActiveRecord::Migration[8.1]
def change
create_table :typing_indicators do |t|
t.references :conversation, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.datetime :expires_at
t.timestamps
end
end
endclass AddGuestToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :guest, :boolean, default: false, null: false
add_column :users, :display_name, :string
end
endclass AddUserDescriptionToCommunities < ActiveRecord::Migration[8.1]
def change
add_column :communities, :user_id, :integer unless column_exists?(:communities, :user_id)
add_column :communities, :description, :text unless column_exists?(:communities, :description)
end
endclass CreateTvChannels < ActiveRecord::Migration[8.1]
def change
create_table :tv_channels do |t|
t.references :user, null: false, foreign_key: true
t.string :name
t.text :description
t.string :slug
t.integer :subscribers_count
t.integer :total_views
t.timestamps
end
end
endclass CreateTvVideos < ActiveRecord::Migration[8.1]
def change
create_table :tv_videos do |t|
t.references :tv_channel, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.string :title
t.text :description
t.string :status
t.integer :duration_seconds
t.integer :views_count
t.integer :likes_count
t.integer :comments_count
t.datetime :published_at
t.string :thumbnail_url
t.timestamps
end
end
endclass CreateTvBroadcasts < ActiveRecord::Migration[8.1]
def change
create_table :tv_broadcasts do |t|
t.references :tv_channel, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.string :title
t.text :description
t.string :status
t.string :stream_key
t.integer :viewer_count
t.datetime :started_at
t.datetime :ended_at
t.timestamps
end
end
endclass CreateTvSubscriptions < ActiveRecord::Migration[8.1]
def change
create_table :tv_subscriptions do |t|
t.references :user, null: false, foreign_key: true
t.references :tv_channel, null: false, foreign_key: true
t.boolean :notify_on_upload
t.timestamps
end
end
endclass CreateTvViewEvents < ActiveRecord::Migration[8.1]
def change
create_table :tv_view_events do |t|
t.references :user, null: false, foreign_key: true
t.references :tv_video, null: false, foreign_key: true
t.integer :watch_time_seconds
t.boolean :completed
t.timestamps
end
end
endclass CreateDatingProfiles < ActiveRecord::Migration[8.1]
def change
create_table :dating_profiles do |t|
t.references :user, null: false, foreign_key: true
t.text :bio
t.string :gender
t.string :looking_for
t.integer :age
t.string :location
t.decimal :latitude
t.decimal :longitude
t.boolean :visible
t.timestamps
end
end
endclass CreateDatingLikes < ActiveRecord::Migration[8.1]
def change
create_table :dating_likes do |t|
t.references :liker, null: false, foreign_key: true
t.references :likee, null: false, foreign_key: true
t.timestamps
end
end
endclass CreateDatingDislikes < ActiveRecord::Migration[8.1]
def change
create_table :dating_dislikes do |t|
t.references :disliker, null: false, foreign_key: true
t.references :dislikee, null: false, foreign_key: true
t.timestamps
end
end
endclass CreateDatingMatches < ActiveRecord::Migration[8.1]
def change
create_table :dating_matches do |t|
t.references :initiator, null: false, foreign_key: true
t.references :receiver, null: false, foreign_key: true
t.string :status
t.timestamps
end
end
endclass CreatePlaylistPlaylists < ActiveRecord::Migration[8.1]
def change
create_table :playlist_playlists do |t|
t.references :user, null: false, foreign_key: true
t.string :name
t.text :description
t.boolean :public_access
t.integer :plays_count
t.integer :likes_count
t.integer :tracks_count
t.timestamps
end
end
endclass CreatePlaylistTracks < ActiveRecord::Migration[8.1]
def change
create_table :playlist_tracks do |t|
t.string :title
t.string :artist
t.string :album
t.integer :duration_seconds
t.string :genre
t.string :source_type
t.string :source_url
t.timestamps
end
end
endclass CreatePlaylistPlaylistTracks < ActiveRecord::Migration[8.1]
def change
create_table :playlist_playlist_tracks do |t|
t.references :playlist_playlist, null: false, foreign_key: true
t.references :playlist_track, null: false, foreign_key: true
t.integer :position
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
endclass CreatePlaylistListens < ActiveRecord::Migration[8.1]
def change
create_table :playlist_listens do |t|
t.references :user, null: false, foreign_key: true
t.references :playlist_track, null: false, foreign_key: true
t.timestamps
end
end
endclass CreateTakeawayRestaurants < ActiveRecord::Migration[8.1]
def change
create_table :takeaway_restaurants do |t|
t.references :user, null: false, foreign_key: true
t.string :name
t.text :description
t.string :address
t.string :city
t.string :phone
t.string :cuisine_type
t.integer :delivery_fee_cents
t.integer :min_order_cents
t.decimal :rating
t.integer :reviews_count
t.boolean :active
t.timestamps
end
end
endclass CreateTakeawayMenuItems < ActiveRecord::Migration[8.1]
def change
create_table :takeaway_menu_items do |t|
t.references :restaurant, null: false, foreign_key: true
t.string :name
t.text :description
t.integer :price_cents
t.boolean :available
t.boolean :vegetarian
t.boolean :vegan
t.timestamps
end
end
endclass CreateTakeawayOrders < ActiveRecord::Migration[8.1]
def change
create_table :takeaway_orders do |t|
t.references :user, null: false, foreign_key: true
t.references :restaurant, null: false, foreign_key: true
t.string :status
t.string :delivery_address
t.integer :subtotal_cents
t.integer :delivery_fee_cents
t.integer :total_cents
t.text :special_instructions
t.timestamps
end
end
endclass CreateTakeawayOrderItems < ActiveRecord::Migration[8.1]
def change
create_table :takeaway_order_items do |t|
t.references :order, null: false, foreign_key: true
t.references :menu_item, null: false, foreign_key: true
t.integer :quantity
t.integer :unit_price_cents
t.timestamps
end
end
endclass CreateMarketplaceCategories < ActiveRecord::Migration[8.1]
def change
create_table :marketplace_categories do |t|
t.string :name
t.string :slug
t.integer :parent_id
t.timestamps
end
end
endclass CreateMarketplaceListings < ActiveRecord::Migration[8.1]
def change
create_table :marketplace_listings do |t|
t.references :user, null: false, foreign_key: true
t.references :category, null: false, foreign_key: true
t.string :title
t.text :description
t.integer :price_cents
t.string :currency
t.string :condition
t.string :status
t.string :location
t.integer :views_count
t.timestamps
end
end
endclass CreateMarketplaceOrders < ActiveRecord::Migration[8.1]
def change
create_table :marketplace_orders do |t|
t.references :buyer, null: false, foreign_key: true
t.references :listing, null: false, foreign_key: true
t.string :status
t.text :message
t.integer :price_cents
t.timestamps
end
end
end# 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.1].define(version: 0) do
end# 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.1].define(version: 2026_05_05_015530) do
create_table "comments", force: :cascade do |t|
t.integer "commentable_id", null: false
t.string "commentable_type", null: false
t.text "content"
t.datetime "created_at", null: false
t.integer "parent_id"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
t.index ["user_id"], name: "index_comments_on_user_id"
end
create_table "communities", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description"
t.string "name"
t.string "slug"
t.string "subdomain"
t.datetime "updated_at", null: false
t.integer "user_id"
t.index ["slug"], name: "index_communities_on_slug", unique: true
t.index ["subdomain"], name: "index_communities_on_subdomain", unique: true
end
create_table "conversation_participants", force: :cascade do |t|
t.integer "conversation_id", null: false
t.datetime "created_at", null: false
t.datetime "last_read_at"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["conversation_id"], name: "index_conversation_participants_on_conversation_id"
t.index ["user_id"], name: "index_conversation_participants_on_user_id"
end
create_table "conversations", force: :cascade do |t|
t.string "conversation_type"
t.datetime "created_at", null: false
t.string "name"
t.datetime "updated_at", null: false
end
create_table "dating_dislikes", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "dislikee_id", null: false
t.integer "disliker_id", null: false
t.datetime "updated_at", null: false
t.index ["dislikee_id"], name: "index_dating_dislikes_on_dislikee_id"
t.index ["disliker_id"], name: "index_dating_dislikes_on_disliker_id"
end
create_table "dating_likes", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "likee_id", null: false
t.integer "liker_id", null: false
t.datetime "updated_at", null: false
t.index ["likee_id"], name: "index_dating_likes_on_likee_id"
t.index ["liker_id"], name: "index_dating_likes_on_liker_id"
end
create_table "dating_matches", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "initiator_id", null: false
t.integer "receiver_id", null: false
t.string "status"
t.datetime "updated_at", null: false
t.index ["initiator_id"], name: "index_dating_matches_on_initiator_id"
t.index ["receiver_id"], name: "index_dating_matches_on_receiver_id"
end
create_table "dating_profiles", force: :cascade do |t|
t.integer "age"
t.text "bio"
t.datetime "created_at", null: false
t.string "gender"
t.decimal "latitude"
t.string "location"
t.decimal "longitude"
t.string "looking_for"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.boolean "visible"
t.index ["user_id"], name: "index_dating_profiles_on_user_id"
end
create_table "follows", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "followed_id"
t.integer "follower_id"
t.datetime "updated_at", null: false
t.index ["followed_id"], name: "index_follows_on_followed_id"
t.index ["follower_id"], name: "index_follows_on_follower_id"
end
create_table "hashtags", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
t.datetime "updated_at", null: false
t.integer "usage_count"
t.index ["name"], name: "index_hashtags_on_name", unique: true
end
create_table "marketplace_categories", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
t.integer "parent_id"
t.string "slug"
t.datetime "updated_at", null: false
end
create_table "marketplace_listings", force: :cascade do |t|
t.integer "category_id", null: false
t.string "condition"
t.datetime "created_at", null: false
t.string "currency"
t.text "description"
t.string "location"
t.integer "price_cents"
t.string "status"
t.string "title"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.integer "views_count"
t.index ["category_id"], name: "index_marketplace_listings_on_category_id"
t.index ["user_id"], name: "index_marketplace_listings_on_user_id"
end
create_table "marketplace_orders", force: :cascade do |t|
t.integer "buyer_id", null: false
t.datetime "created_at", null: false
t.integer "listing_id", null: false
t.text "message"
t.integer "price_cents"
t.string "status"
t.datetime "updated_at", null: false
t.index ["buyer_id"], name: "index_marketplace_orders_on_buyer_id"
t.index ["listing_id"], name: "index_marketplace_orders_on_listing_id"
end
create_table "mentions", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "mentionable_id", null: false
t.string "mentionable_type", null: false
t.integer "mentioned_user_id", null: false
t.datetime "updated_at", null: false
t.index ["mentionable_type", "mentionable_id"], name: "index_mentions_on_mentionable"
t.index ["mentioned_user_id"], name: "index_mentions_on_mentioned_user_id"
end
create_table "message_receipts", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "delivered_at"
t.integer "message_id", null: false
t.datetime "read_at"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["message_id"], name: "index_message_receipts_on_message_id"
t.index ["user_id"], name: "index_message_receipts_on_user_id"
end
create_table "messages", force: :cascade do |t|
t.text "content"
t.integer "conversation_id", null: false
t.datetime "created_at", null: false
t.datetime "expires_at"
t.string "message_type"
t.integer "sender_id"
t.datetime "updated_at", null: false
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
end
create_table "playlist_listens", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "playlist_track_id", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["playlist_track_id"], name: "index_playlist_listens_on_playlist_track_id"
t.index ["user_id"], name: "index_playlist_listens_on_user_id"
end
create_table "playlist_playlist_tracks", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "playlist_playlist_id", null: false
t.integer "playlist_track_id", null: false
t.integer "position"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["playlist_playlist_id"], name: "index_playlist_playlist_tracks_on_playlist_playlist_id"
t.index ["playlist_track_id"], name: "index_playlist_playlist_tracks_on_playlist_track_id"
t.index ["user_id"], name: "index_playlist_playlist_tracks_on_user_id"
end
create_table "playlist_playlists", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description"
t.integer "likes_count"
t.string "name"
t.integer "plays_count"
t.boolean "public_access"
t.integer "tracks_count"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id"], name: "index_playlist_playlists_on_user_id"
end
create_table "playlist_tracks", force: :cascade do |t|
t.string "album"
t.string "artist"
t.datetime "created_at", null: false
t.integer "duration_seconds"
t.string "genre"
t.string "source_type"
t.string "source_url"
t.string "title"
t.datetime "updated_at", null: false
end
create_table "posts", force: :cascade do |t|
t.boolean "anonymous"
t.integer "community_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.integer "karma"
t.string "title"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["community_id"], name: "index_posts_on_community_id"
t.index ["user_id"], name: "index_posts_on_user_id"
end
create_table "reactions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "kind"
t.integer "post_id", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["post_id"], name: "index_reactions_on_post_id"
t.index ["user_id"], name: "index_reactions_on_user_id"
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.datetime "updated_at", null: false
t.string "user_agent"
t.integer "user_id", null: false
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "streams", force: :cascade do |t|
t.string "content_type"
t.datetime "created_at", null: false
t.integer "duration"
t.integer "post_id", null: false
t.datetime "updated_at", null: false
t.string "url"
t.integer "user_id", null: false
t.index ["post_id"], name: "index_streams_on_post_id"
t.index ["user_id"], name: "index_streams_on_user_id"
end
create_table "taggings", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "hashtag_id", null: false
t.integer "taggable_id", null: false
t.string "taggable_type", null: false
t.datetime "updated_at", null: false
t.index ["hashtag_id"], name: "index_taggings_on_hashtag_id"
t.index ["taggable_type", "taggable_id"], name: "index_taggings_on_taggable"
end
create_table "takeaway_menu_items", force: :cascade do |t|
t.boolean "available"
t.datetime "created_at", null: false
t.text "description"
t.string "name"
t.integer "price_cents"
t.integer "restaurant_id", null: false
t.datetime "updated_at", null: false
t.boolean "vegan"
t.boolean "vegetarian"
t.index ["restaurant_id"], name: "index_takeaway_menu_items_on_restaurant_id"
end
create_table "takeaway_order_items", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "menu_item_id", null: false
t.integer "order_id", null: false
t.integer "quantity"
t.integer "unit_price_cents"
t.datetime "updated_at", null: false
t.index ["menu_item_id"], name: "index_takeaway_order_items_on_menu_item_id"
t.index ["order_id"], name: "index_takeaway_order_items_on_order_id"
end
create_table "takeaway_orders", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "delivery_address"
t.integer "delivery_fee_cents"
t.integer "restaurant_id", null: false
t.text "special_instructions"
t.string "status"
t.integer "subtotal_cents"
t.integer "total_cents"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["restaurant_id"], name: "index_takeaway_orders_on_restaurant_id"
t.index ["user_id"], name: "index_takeaway_orders_on_user_id"
end
create_table "takeaway_restaurants", force: :cascade do |t|
t.boolean "active"
t.string "address"
t.string "city"
t.datetime "created_at", null: false
t.string "cuisine_type"
t.integer "delivery_fee_cents"
t.text "description"
t.integer "min_order_cents"
t.string "name"
t.string "phone"
t.decimal "rating"
t.integer "reviews_count"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id"], name: "index_takeaway_restaurants_on_user_id"
end
create_table "tv_broadcasts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description"
t.datetime "ended_at"
t.datetime "started_at"
t.string "status"
t.string "stream_key"
t.string "title"
t.integer "tv_channel_id", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.integer "viewer_count"
t.index ["tv_channel_id"], name: "index_tv_broadcasts_on_tv_channel_id"
t.index ["user_id"], name: "index_tv_broadcasts_on_user_id"
end
create_table "tv_channels", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "description"
t.string "name"
t.string "slug"
t.integer "subscribers_count"
t.integer "total_views"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id"], name: "index_tv_channels_on_user_id"
end
create_table "tv_subscriptions", force: :cascade do |t|
t.datetime "created_at", null: false
t.boolean "notify_on_upload"
t.integer "tv_channel_id", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["tv_channel_id"], name: "index_tv_subscriptions_on_tv_channel_id"
t.index ["user_id"], name: "index_tv_subscriptions_on_user_id"
end
create_table "tv_videos", force: :cascade do |t|
t.integer "comments_count"
t.datetime "created_at", null: false
t.text "description"
t.integer "duration_seconds"
t.integer "likes_count"
t.datetime "published_at"
t.string "status"
t.string "thumbnail_url"
t.string "title"
t.integer "tv_channel_id", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.integer "views_count"
t.index ["tv_channel_id"], name: "index_tv_videos_on_tv_channel_id"
t.index ["user_id"], name: "index_tv_videos_on_user_id"
end
create_table "tv_view_events", force: :cascade do |t|
t.boolean "completed"
t.datetime "created_at", null: false
t.integer "tv_video_id", null: false
... 91 lines truncated (491 total)admin = User.find_or_create_by!(email_address: "admin@brgen.no") do |u|
u.username = "admin"
u.password = u.password_confirmation = "password123"
end
["news", "tech", "bergen", "norge", "kultur"].each do |slug|
Community.find_or_create_by!(slug: slug) do |c|
c.name = slug.capitalize
c.description = "#{slug.capitalize} community"
c.user = admin
end
end
puts "Seeded #{Community.count} communities, admin id #{admin.id}"# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
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#!/usr/bin/env zsh
# brgen.sh — Brgen social network (Rails 8). Deploys the tracked tree at app/.
set -euo pipefail
APP_NAME=brgen
APP_DIR=/home/${APP_NAME}/app
APP_PORT=38182
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}/app
. "${SCRIPT_DIR:h}/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1; }
log "Brgen — deploying tracked tree → ${APP_DIR}"
# ── User + dirs ────────────────────────────────────────────────────────────
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
# ── Sync tree ──────────────────────────────────────────────────────────────
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
# ── Bundle path inherits from sibling app to avoid OOM on first install ────
typeset bundle_home="/home/${APP_NAME}/.bundle"
if [[ ! -d ${bundle_home}/gems ]]; then
log "Bootstrapping gems from amber"
doas mkdir -p "$bundle_home"
doas cp -R /home/amber/.bundle/gems "$bundle_home/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
print -- "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
# ── Install + migrate + seed ───────────────────────────────────────────────
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && bundle config set --local deployment true && bundle config set --local without 'development test' && RAILS_ENV=production bundle install"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
# ── Service + relay ────────────────────────────────────────────────────────
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
relayd_add_relay "${APP_NAME}.no" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"# brgen :: dating
Local-first dating. Discover people in your city.
- Namespace: `Dating::`
- Subdomain: `dating.brgen.no`
- Route prefix: `/dating`
## Models
| Model | Notes |
|---|---|
| `Dating::Profile` | Display name, bio, photos, age range, distance preference; one per `User` |
| `Dating::Like` | One-way interest signal between profiles |
| `Dating::Dislike` | Hide a profile from future swipes |
| `Dating::Match` | Reciprocal `Like` pair; opens chat via existing `Conversation` model |
## Discovery
Profiles are filtered by city (subdomain) and distance radius. Matching unlocks the shared messaging stack (`messages_controller`, Action Cable `MessagesChannel`).# brgen :: marketplace
Hyperlocal classifieds. Buy, sell, trade within your city.
- Namespace: `Marketplace::`
- Subdomain: `markedsplass.brgen.no` (Norway) — locale aliases: `markadur` (IS), `marknadsplats` (SE), `marketplace` (UK/US), `marktplaats` (NL), `marche` (FR/BE), `mercato` (IT), `mercado` (PT/ES), `markkinapaikka` (FI)
- Route prefix: `/marketplace`
## Models
| Model | Notes |
|---|---|
| `Marketplace::Category` | Top-level classification (clothing, electronics, vehicles, housing, …) |
| `Marketplace::Listing` | Individual ad: title, body, price, photos (Active Storage), location |
| `Marketplace::Order` | Buyer ↔ seller transaction; payment + delivery state machine |
## Routes
Wrapped in `constraints(subdomain: MARKETPLACE_SUBDOMAINS)` in `config/routes.rb`. Same Rails app serves every locale alias.# brgen :: playlist
Local music discovery. Share playlists, find what your city listens to.
- Namespace: `Playlist::`
- Subdomain: `playlist.brgen.no`
- Route prefix: `/playlist`
## Models
| Model | Notes |
|---|---|
| `Playlist::Playlist` | Owner, title, public/private, cover art (Active Storage) |
| `Playlist::Track` | Title, artist, duration, source URL (Spotify/SoundCloud/local) |
| `Playlist::PlaylistTrack` | Join with `position` for ordering |
| `Playlist::Listen` | Per-user play event; powers trending and personal history |
## Trending
City-scoped trending feed: aggregates `Listen` rows over a rolling window, filtered by user-city.# brgen :: takeaway
Local street-food and restaurant ordering.
- Namespace: `Takeaway::`
- Subdomain: `takeaway.brgen.no`
- Route prefix: `/takeaway`
## Models
| Model | Notes |
|---|---|
| `Takeaway::Restaurant` | Owner, name, address, hours, cuisine tags, photos |
| `Takeaway::MenuItem` | Restaurant menu entry: name, description, price, photo, availability |
| `Takeaway::Order` | Customer ↔ restaurant order; status machine (placed → accepted → ready → delivered) |
| `Takeaway::OrderItem` | Line items linking `Order` ↔ `MenuItem` with quantity + per-item notes |
## Discovery
Restaurants filtered by city (subdomain) and distance from delivery address. Live order status pushed via Action Cable `OrdersChannel`.# brgen :: tv
Local-channel television. User-run channels broadcast to a city.
- Namespace: `Tv::`
- Subdomain: `tv.brgen.no`
- Route prefix: `/tv`
## Models
| Model | Notes |
|---|---|
| `Tv::Channel` | Owner, name, description, logo |
| `Tv::Video` | Uploaded MP4 (Active Storage), title, runtime, channel |
| `Tv::Broadcast` | Live or scheduled airing of a `Video` on a `Channel` |
| `Tv::Subscription` | User → Channel follow; drives notifications |
| `Tv::ViewEvent` | Per-user playback event; powers analytics + recommendations |
## Streaming
Recorded video served via Active Storage + Cloudflare CDN. Live broadcast via Action Cable signaling + WebRTC peer relay (planned).# bsdports
Browseable web index of the OpenBSD ports tree. Rails 8 · PostgreSQL · Falcon.
## How it works
Seeds itself from the OpenBSD FTP mirror: downloads `ports.tar.gz`, untars, walks each `Makefile`, and imports name/summary/url/description into Postgres. One row per port, scoped by category and platform.
## Models
| Model | Purpose |
|---|---|
| `Platform` | OpenBSD release branch (e.g. `7.8`, `-current`) |
| `Category` | Top-level ports category (`net`, `databases`, `lang`, …) belongs_to platform |
| `Port` | Individual port (`name`, `summary`, `url`, `description`) belongs_to category + platform |
## Features
- LangChain-backed semantic search across summaries + descriptions.
- StimulusReflex live filtering by category and platform.
- Periodic re-seed via Solid Queue job to track upstream churn.
## Deploy
```zsh
doas zsh DEPLOY/rails/bsdports/bsdports.sh
## `DEPLOY/rails/bsdports/app/Dockerfile`
```text
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t app .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name app app
# 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.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
bundle exec bootsnap precompile -j 1 --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times.
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# 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
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# 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"]
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# 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
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
gem "kamal", 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"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# 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
gem "pagy"
gem "falcon"
# README
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
* Ruby version
* System dependencies
* Configuration
* Database creation
* Database initialization
* How to run the test suite
* Services (job queues, cache servers, search engines, etc.)
* Deployment instructions
* ...# 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
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Method
allow_browser versions: :modern
endclass CategoriesController < ApplicationController
allow_unauthenticated_access only: %i[index show]
def index
@categories = Category.order(:name).includes(:ports)
end
def show
@category = Category.find_by!(slug: params[:id])
@pagy, @ports = pagy(@category.ports.order(:name))
end
endclass CommentsController < ApplicationController
before_action :require_authentication
before_action :set_port
def create
@comment = @port.comments.build(comment_params.merge(user: Current.user))
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @port }
end
else
render :new, status: :unprocessable_entity
end
end
def destroy
@comment = @port.comments.find(params[:id])
@comment.destroy! if @comment.user == Current.user
respond_to do |format|
format.turbo_stream
format.html { redirect_to @port }
end
end
private
def set_port = @port = Port.find(params[:port_id])
def comment_params = params.require(:comment).permit(:content, :parent_id)
endmodule Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
endclass PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
endclass PortsController < ApplicationController
allow_unauthenticated_access only: %i[index show]
before_action :set_port, only: %i[show watch unwatch]
def index
scope = Port.includes(:category)
scope = scope.search(params[:q]) if params[:q].present?
scope = scope.by_category(params[:category_id]) if params[:category_id].present?
scope = scope.order(params[:sort] == "updated" ? "last_updated DESC" : :name)
@pagy, @ports = pagy(scope)
@categories = Category.order(:name)
end
def show
@updates = @port.port_updates.order(committed_at: :desc).limit(10)
@deps = @port.depends_on.includes(:category)
@rdeps = @port.reverse_deps.includes(:category).limit(20)
@comments = @port.comments.roots.includes(:user, replies: :user)
@comment = Comment.new
end
def watch
require_authentication
@port.watches.find_or_create_by!(user: Current.user)
respond_to do |format|
format.turbo_stream
format.html { redirect_to @port }
end
end
def unwatch
require_authentication
@port.watches.find_by(user: Current.user)&.destroy!
respond_to do |format|
format.turbo_stream
format.html { redirect_to @port }
end
end
private
def set_port = @port = Port.find_by!(pkgpath: params[:id].gsub("-", "/")) rescue Port.find(params[:id])
endclass SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
endmodule ApplicationHelper
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}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
endclass ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
endclass ApplicationRecord < ActiveRecord::Base
primary_abstract_class
endclass Category < ApplicationRecord
has_many :ports, dependent: :nullify
validates :name, :slug, presence: true
validates :slug, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
before_validation :generate_slug, on: :create
def to_param = slug
private
def generate_slug
self.slug ||= name.to_s.parameterize
end
endclass Comment < ApplicationRecord
belongs_to :user
belongs_to :port
belongs_to :parent, class_name: "Comment", optional: true
has_many :replies, class_name: "Comment", foreign_key: :parent_id, dependent: :destroy
validates :content, presence: true, length: { maximum: 5000 }
scope :roots, -> { where(parent_id: nil).order(created_at: :asc) }
after_create_commit -> { broadcast_append_to [port, "comments"] }
endclass Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
endclass Dependency < ApplicationRecord
belongs_to :port
belongs_to :depends_on, class_name: "Port"
TYPES = %w[build run test lib].freeze
validates :dep_type, inclusion: { in: TYPES }, allow_nil: true
validates :port_id, uniqueness: { scope: %i[depends_on_id dep_type] }
endclass Port < ApplicationRecord
belongs_to :category
has_many :dependencies, dependent: :destroy
has_many :depends_on, through: :dependencies, source: :depends_on
has_many :dependents, class_name: "Dependency", foreign_key: :depends_on_id
has_many :reverse_deps, through: :dependents, source: :port
has_many :port_updates, dependent: :destroy
has_many :watches, dependent: :destroy
has_many :watchers, through: :watches, source: :user
has_many :comments, dependent: :destroy
validates :name, :version, :pkgpath, presence: true
validates :pkgpath, uniqueness: true
scope :recent_updates, -> { joins(:port_updates).order("port_updates.committed_at DESC").distinct }
scope :by_category, ->(cat) { where(category: cat) }
scope :search, ->(q) { where("name LIKE ? OR comment LIKE ?", "%#{q}%", "%#{q}%") }
def watched_by?(user)
watches.exists?(user: user)
end
def latest_update
port_updates.order(committed_at: :desc).first
end
endclass PortUpdate < ApplicationRecord
belongs_to :port
validates :new_version, presence: true
scope :recent, -> { order(committed_at: :desc) }
endclass Session < ApplicationRecord
belongs_to :user
endclass User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :watches, dependent: :destroy
has_many :watched_ports, through: :watches, source: :port
has_many :comments, dependent: :nullify
normalizes :email_address, with: ->(e) { e.strip.downcase }
endclass Watch < ApplicationRecord
belongs_to :user
belongs_to :port
validates :user_id, uniqueness: { scope: :port_id }
end<% content_for :title, "Categories" %>
<h1>Categories</h1>
<ul id="categories">
<% @categories.each do |cat| %>
<li>
<%= link_to cat.name, category_path(cat) %>
<small><%= cat.description %></small>
</li>
<% end %>
</ul><% content_for :title, @category.name %>
<header>
<h1><%= @category.name %></h1>
<p><%= @category.description %></p>
</header>
<%= render "ports/index" %><article id="<%= dom_id(comment) %>">
<small><%= comment.user.email_address.split("@").first %></small>
<p><%= comment.content %></p>
<% if authenticated? && comment.user == Current.user %>
<%= button_to "Delete", port_comment_path(@port, comment), method: :delete %>
<% end %>
</article><!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "App" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="application-name" content="App">
<meta name="mobile-web-app-capable" content="yes">
<%= 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) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
</head>
<body>
<%= yield %>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %><% content_for :title, "Ports" %>
<header>
<h1>OpenBSD ports</h1>
<%= form_with url: ports_path, method: :get do |f| %>
<%= f.search_field :q, value: params[:q], placeholder: "Search ports…" %>
<% end %>
</header>
<% if @category %>
<p><mark><%= @category.name %></mark> — <%= @category.description %></p>
<% end %>
<ul id="ports">
<% @ports.each do |port| %>
<li>
<%= link_to port.name, port %>
<small><%= port.version %></small>
<small><%= link_to port.category.name, category_path(port.category) %></small>
<small><%= port.comment %></small>
</li>
<% end %>
</ul>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, @port.name %>
<article>
<header>
<h1><%= @port.name %> <small><%= @port.version %></small></h1>
<small><%= link_to @port.category.name, category_path(@port.category) %></small>
</header>
<p><%= @port.comment %></p>
<p><%= @port.description %></p>
<dl>
<dt>Maintainer</dt><dd><%= @port.maintainer %></dd>
<dt>Pkgpath</dt><dd><code><%= @port.pkgpath %></code></dd>
<% if @port.homepage.present? %><dt>Homepage</dt><dd><%= link_to @port.homepage, @port.homepage %></dd><% end %>
<dt>Updated</dt><dd><%= @port.last_updated&.strftime("%Y-%m-%d") %></dd>
</dl>
<% if @dependencies.any? %>
<h2>Dependencies</h2>
<ul>
<% @dependencies.each do |dep| %>
<li>
<%= link_to dep.depends_on.name, port_path(dep.depends_on) %>
<small><%= dep.dep_type %></small>
</li>
<% end %>
</ul>
<% end %>
<% if @updates.any? %>
<h2>Version history</h2>
<ul>
<% @updates.each do |update| %>
<li>
<small><%= update.old_version %> → <%= update.new_version %></small>
<small><%= update.commit_message %></small>
<small><%= update.committed_at&.strftime("%Y-%m-%d") %></small>
</li>
<% end %>
</ul>
<% end %>
<nav>
<% if authenticated? %>
<% if @watching %>
<%= button_to "Unwatch", unwatch_port_path(@port), method: :delete %>
<% else %>
<%= button_to "Watch", watch_port_path(@port), method: :post %>
<% end %>
<% end %>
</nav>
</article>
<section id="comments">
<h2>Comments</h2>
<%= render @comments %>
<% if authenticated? %>
<%= form_with url: port_comments_path(@port) do |f| %>
<p><%= f.text_area :content, rows: 3, placeholder: "Add a comment…" %></p>
<p><%= f.submit "Comment" %></p>
<% end %>
<% end %>
</section>{
"name": "App",
"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": "App.",
"theme_color": "red",
"background_color": "red"
}// 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)
// }
// })
// )
// })require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
endENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" # Set up gems listed in the Gemfile.
require "bootsnap/setup" # Speed up boot time by caching expensive operations.# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit.
# CVEs that are not relevant to the application can be enumerated on the ignore list below.
ignore:
- CVE-THAT-DOES-NOT-APPLYdevelopment:
adapter: async
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: app_production# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# 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: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: app.example.com
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.9
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# Load the Rails application.
require_relative "application"
# Initialize the Rails application.
Rails.application.initialize!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
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Suppress logger output for asset requests.
config.assets.quiet = 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!
endrequire "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 the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# 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 = :mem_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
# config.active_job.queue_adapter = :resque
# 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 bin/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# 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# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.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# 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
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end# 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
]# 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# 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"threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
port ENV.fetch("PORT") { 10003 }
environment ENV.fetch("RAILS_ENV") { "production" }
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
plugin :tmp_restartRails.application.routes.draw do
resource :session
resources :passwords, param: :token
root "ports#index"
resources :categories, only: %i[index show]
resources :ports, only: %i[index show] do
member do
post :watch
delete :unwatch
end
resources :comments, only: %i[create destroy]
end
get "up", to: "rails/health#show", as: :rails_health_check
endtest:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>
# 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 %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
endclass CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
endclass CreateCategories < ActiveRecord::Migration[8.1]
def change
create_table :categories do |t|
t.string :name
t.string :slug
t.text :description
t.timestamps
end
add_index :categories, :slug, unique: true
end
endclass CreatePorts < ActiveRecord::Migration[8.1]
def change
create_table :ports do |t|
t.string :name
t.string :version
t.references :category, foreign_key: true
t.string :maintainer
t.text :comment
t.text :description
t.string :homepage
t.string :pkgpath
t.boolean :permit_file_distfiles, default: false
t.date :last_updated
t.timestamps
end
add_index :ports, :name, unique: true
add_index :ports, :pkgpath, unique: true
end
endclass CreateDependencies < ActiveRecord::Migration[8.1]
def change
create_table :dependencies do |t|
t.references :port, foreign_key: true
t.references :depends_on, foreign_key: { to_table: :ports }
t.string :dep_type
t.timestamps
end
end
endclass CreatePortUpdates < ActiveRecord::Migration[8.1]
def change
create_table :port_updates do |t|
t.references :port, foreign_key: true
t.string :old_version
t.string :new_version
t.string :commit_id
t.text :commit_message
t.datetime :committed_at
t.timestamps
end
end
endclass CreateWatches < ActiveRecord::Migration[8.1]
def change
create_table :watches do |t|
t.references :user, foreign_key: true
t.references :port, foreign_key: true
t.boolean :notify_on_update, default: true
t.timestamps
end
end
endclass CreateComments < ActiveRecord::Migration[8.1]
def change
create_table :comments do |t|
t.references :user, foreign_key: true
t.references :port, foreign_key: true
t.integer :parent_id
t.text :content
t.timestamps
end
end
end# 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# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#!/usr/bin/env zsh
# bsdports.sh — deploys tracked Rails tree at app/ as %APP_NAME%
set -euo pipefail
APP_NAME=%APP_NAME%
APP_DIR=/home/${APP_NAME}/app
APP_PORT=47312
APP_DOMAIN=bsdports.org
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}/app
. "${SCRIPT_DIR:h}/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1 }
log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
typeset bundle_home="/home/${APP_NAME}/.bundle"
if [[ ! -d ${bundle_home}/gems ]]; then
log "Bootstrapping gems from amber"
doas mkdir -p "$bundle_home"
doas cp -R /home/amber/.bundle/gems "$bundle_home/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
print "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bundle install --deployment --without development:test"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"#!/usr/bin/env sh
set -eu
# Port consistency checker for DEPLOY/rails
# 0 → success, non‑zero → validation failure.
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
MASTER_JSON=${MASTER_JSON:-"$SCRIPT_DIR/../master.json"}
log() { printf '%s\n' "$*"; }
error() { printf '❌ %s\n' "$*" >&2; }
require_command() {
command -v "$1" >/dev/null 2>&1 || { error "Missing required command: $1"; exit 1; }
}
validate_master_json() {
[ -f "$MASTER_JSON" ] || { error "master.json not found at: $MASTER_JSON"; return 1; }
jq -e '.apps | type == "array" and length > 0' "$MASTER_JSON" >/dev/null 2>&1 ||
{ error "master.json must contain a non‑empty .apps array"; return 1; }
}
# Load app→port mapping into associative arrays.
load_ports() {
declare -A port_of
apps_list=''
jq -r '.apps[] | "\(.name)\t\(.port)"' "$MASTER_JSON" |
while IFS=$'\t' read -r app port; do
[ -n "$app" ] && [ -n "$port" ] || { error "App with missing name or port"; return 1; }
case "$port" in
''|*[!0-9]*) error "Invalid port '$port' for app '$app'"; return 1;;
esac
[ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { error "Port out of range '$port' for app '$app'"; return 1; }
if [ -n "${port_of[$app]+x}" ]; then
error "Duplicate app name '$app' in master.json"
return 1
fi
port_of[$app]=$port
apps_list="${apps_list}${app} "
done
# Export for later stages.
export APPS="$apps_list"
export PORT_OF_JSON="$(printf '%s\n' "${!port_of[@]}" | while read -r k; do printf '%s=%s\n' "$k" "${port_of[$k]}"; done)"
# Keep the associative array in a temporary file for subshells that need it.
export PORT_MAP_FILE=$(mktemp)
for k in "${!port_of[@]}"; do
printf '%s=%s\n' "$k" "${port_of[$k]}" >>"$PORT_MAP_FILE"
done
}
check_duplicate_ports() {
duplicate=0
# Build reverse map: port → apps
declare -A seen
for app in $APPS; do
port=$(awk -F= -v a="$app" 'a==$1{print $2}' "$PORT_MAP_FILE")
if [ -n "${seen[$port]+x}" ]; then
error "Port collision on $port: ${seen[$port]} and $app"
duplicate=1
else
seen[$port]=$app
fi
done
return $duplicate
}
check_expected_port_constants() {
mismatch=0
i=1
for app in $APPS; do
installer="$SCRIPT_DIR/$app/$app.sh"
if [ -f "$installer" ]; then
installer_port=$(sed -nE 's/^[[:space:]]*(readonly[[:space:]]+)?PORT=([0-9]+).*/\2/p' "$installer" | head -n1)
expected=$(awk -F= -v a="$app" 'a==$1{print $2}' "$PORT_MAP_FILE")
if [ -n "$installer_port" ] && [ "$installer_port" != "$expected" ]; then
error "$installer sets PORT=${installer_port}, expected ${expected}"
mismatch=1
fi
fi
i=$((i + 1))
done
return $mismatch
}
main() {
log "=== Port Consistency Check ==="
require_command jq
validate_master_json
load_ports
check_duplicate_ports
log ""
log "Ports from ${MASTER_JSON}:"
for app in $APPS; do
port=$(awk -F= -v a="$app" 'a==$1{print $2}' "$PORT_MAP_FILE")
log " - $app: $port"
done
if check_expected_port_constants; then
log ""
log "✅ Port checks passed"
else
exit 1
fi
}
main "$@"#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Demo Rails 8 app generator – Simple CRUD with Hotwire
# Port: 10008
# Domain: demo.local (or configure as needed)
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="demo"
readonly PORT=10008
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
check_prereqs() {
require_cmd rails
require_cmd bundler
require_cmd lsof
require_cmd sed
require_cmd cp
require_cmd cat
}
port_in_use() {
lsof -i :"$PORT" -sTCP:LISTEN -t >/dev/null 2>&1
}
backup_file() {
local src=$1
[[ -f $src ]] && cp -f "$src" "${src}.backup"
}
append_once() {
local file=$1 marker=$2 content=$3
grep -qF "$marker" "$file" || printf '%s\n' "$content" >>"$file"
}
create_app() {
rails new "$APP_NAME" \
--database=postgresql \
--css=tailwind \
--javascript=importmap ||
die "Failed to create Rails app"
}
write_database_yml() {
cat > config/database.yml <<EOF
default: &default
adapter: postgresql
encoding: unicode
pool: 5
development:
<<: *default
database: ${APP_NAME}_development
test:
<<: *default
database: ${APP_NAME}_test
production:
<<: *default
database: ${APP_NAME}_production
username: ${APP_NAME}
password: <%= ENV["${APP_NAME^^}_DATABASE_PASSWORD"] %>
EOF
}
configure_solid_gems() {
local application_rb="config/application.rb"
backup_file "$application_rb"
append_once "$application_rb" "config.solid_queue" $'\n# Solid Queue configuration\nconfig.solid_queue.connects_to = { database: { writing: :primary } }'
append_once "$application_rb" "config.solid_cache" $'\n# Solid Cache configuration\nconfig.solid_cache.connects_to = { database: { writing: :primary } }'
append_once "$application_rb" "config.solid_cable" $'\n# Solid Cable configuration\nconfig.solid_cable.connects_to = { database: { writing: :primary } }'
}
inject_layout_wrapper() {
local layout_file="app/views/layouts/application.html.erb"
[[ -f $layout_file ]] || return
sed -i.bak '/<body>/a\
<div class="container mx-auto px-4 py-8">\
<h1 class="text-3xl font-bold mb-6">Demo App</h1>\
<%= yield %>\
</div>' "$layout_file"
rm -f "${layout_file}.bak"
}
start_server() {
bin/rails server -p "$PORT" -d ||
die "Failed to start Rails server"
printf 'Demo app created successfully!\nApp is running on http://localhost:%s\nStop the server with: bin/rails server -p %s -d -s\n' "$PORT" "$PORT"
}
main() {
check_prereqs
local app_dir="${SCRIPT_DIR}/${APP_NAME}"
[[ -d $app_dir ]] && die "Directory $app_dir already exists"
port_in_use && die "Port $PORT is already in use"
create_app
cd "$APP_NAME"
backup_file config/database.yml
write_database_yml
bin/rails db:create || die "Failed to create databases"
bundle add solid_queue solid_cache solid_cable || die "Failed to add Solid* gems"
configure_solid_gems
bin/rails generate scaffold Post title:string content:text --no-jbuilder
bin/rails db:migrate || die "Failed to run migrations"
cat > config/routes.rb <<'EOF'
Rails.application.routes.draw do
resources :posts
root 'posts#index'
end
EOF
inject_layout_wrapper
start_server
}
main "$@"# hjerterom
Community care and support platform ("heart room"). Rails 8. PostgreSQL.
## Deploy
```zsh
cd ~/pub4/MASTER/DEPLOY/rails/hjerterom
doas zsh hjerterom.sh
## `DEPLOY/rails/hjerterom/app/Dockerfile`
```text
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t app .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name app app
# 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.9
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment variables and enable jemalloc for reduced memory usage and latency.
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY vendor/* ./vendor/
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
bundle exec bootsnap precompile -j 1 --gemfile
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times.
# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495
RUN bundle exec bootsnap precompile -j 1 app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage for app image
FROM base
# 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
USER 1000:1000
# Copy built artifacts: gems, application
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
# 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"]
source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "jbuilder"
# 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
# Deploy this application anywhere as a Docker container [https://kamal-deploy.org]
gem "kamal", 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"
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
gem "bundler-audit", require: false
# 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
gem "pagy"
gem "falcon"
# README
This README would normally document whatever steps are necessary to get the
application up and running.
Things you may want to cover:
* Ruby version
* System dependencies
* Configuration
* Database creation
* Database initialization
* How to run the test suite
* Services (job queues, cache servers, search engines, etc.)
* Deployment instructions
* ...# 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
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Method
allow_browser versions: :modern
endclass CommunityController < ApplicationController
allow_unauthenticated_access only: %i[index show]
def index
@categories = Category.order(:name)
@pagy, @posts = pagy(Post.recent.includes(:user, :category))
end
def show
@post = Post.find(params[:id])
@post.increment!(:views_count)
@comments = @post.comments.roots.includes(:user, replies: :user)
@comment = Comment.new
end
def new
@post = Post.new
end
def create
@post = Current.user.posts.build(post_params)
@post.save ? redirect_to(community_show_path(@post), notice: "Posted") : render(:new, status: :unprocessable_entity)
end
private
def post_params
params.require(:post).permit(:title, :body, :category_id, :anonymous)
end
endmodule Authentication
extend ActiveSupport::Concern
included do
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session_by_cookie
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end
def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
endclass FoodListingsController < ApplicationController
allow_unauthenticated_access only: %i[index show]
before_action :set_listing, only: %i[show edit update destroy]
before_action :authorize!, only: %i[edit update destroy]
def index
@pagy, @listings = pagy(FoodListing.available.order(created_at: :desc))
end
def show
@request = FoodRequest.new
end
def new
@listing = Current.user.food_listings.build
end
def create
@listing = Current.user.food_listings.build(listing_params)
@listing.save ? redirect_to(@listing, notice: "Food listing created") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@listing.update(listing_params) ? redirect_to(@listing, notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@listing.destroy
redirect_to food_listings_path, notice: "Listing removed"
end
private
def set_listing = @listing = FoodListing.find(params[:id])
def authorize! = redirect_to(food_listings_path, alert: "Unauthorized") unless @listing.user == Current.user
def listing_params
params.require(:food_listing).permit(
:title, :description, :quantity, :unit,
:available_from, :available_until,
:pickup_address, :city, :dietary_info
)
end
endclass FoodRequestsController < ApplicationController
def create
listing = FoodListing.find(params[:food_listing_id])
@request = listing.food_requests.build(request_params.merge(user: Current.user, status: "pending"))
@request.save ? redirect_to(listing, notice: "Request sent") : render(:new, status: :unprocessable_entity)
end
def update
@request = FoodRequest.find(params[:id])
authorize_owner!
@request.update!(status: params[:status]) if params[:status].in?(%w[approved declined])
redirect_to @request.food_listing
end
private
def authorize_owner! = redirect_to(root_path, alert: "Unauthorized") unless @request.food_listing.user == Current.user
def request_params = params.require(:food_request).permit(:message, :pickup_time)
endclass HomeController < ApplicationController
allow_unauthenticated_access only: :index
def index
@crisis_lines = Crisis.where(available_24h: true).limit(5)
@food_listings = FoodListing.available.order(created_at: :desc).limit(6)
@recent_posts = Post.recent.includes(:user, :category).limit(5)
@resources = Resource.verified.limit(8)
end
endclass PasswordsController < ApplicationController
allow_unauthenticated_access
before_action :set_user_by_token, only: %i[ edit update ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
def new
end
def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
end
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end
endclass ResourcesController < ApplicationController
allow_unauthenticated_access only: %i[index show]
before_action :set_resource, only: %i[show edit update destroy]
before_action :authorize!, only: %i[edit update destroy]
def index
scope = Resource.includes(:category)
scope = scope.by_type(params[:type]) if params[:type].present?
scope = scope.where("title LIKE ?", "%#{params[:q]}%") if params[:q].present?
@pagy, @resources = pagy(scope.verified.order(:title))
@crisis_lines = Crisis.all
end
def show; end
def new
@resource = Current.user.resources.build
end
def create
@resource = Current.user.resources.build(resource_params)
@resource.save ? redirect_to(@resource, notice: "Resource submitted for review") : render(:new, status: :unprocessable_entity)
end
def edit; end
def update
@resource.update(resource_params) ? redirect_to(@resource, notice: "Updated") : render(:edit, status: :unprocessable_entity)
end
def destroy
@resource.destroy
redirect_to resources_path, notice: "Removed"
end
private
def set_resource = @resource = Resource.find(params[:id])
def authorize! = redirect_to(resources_path, alert: "Unauthorized") unless @resource.user == Current.user
def resource_params
params.require(:resource).permit(
:title, :description, :url, :address, :city, :postal_code,
:phone, :email, :resource_type, :opening_hours, :category_id
)
end
endclass SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path, status: :see_other
end
endmodule ApplicationHelper
end// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"import AnimatedNumber from "@stimulus-components/animated-number"
export default class extends AnimatedNumber {}import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }import AutoSubmit from "@stimulus-components/auto-submit"
export default class extends AutoSubmit {}import CharacterCounter from "@stimulus-components/character-counter"
export default class extends CharacterCounter {}import Clipboard from "@stimulus-components/clipboard"
export default class extends Clipboard {}import Dialog from "@stimulus-components/dialog"
export default class extends Dialog {}import Dropdown from "@stimulus-components/dropdown"
export default class extends Dropdown {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)import Notification from "@stimulus-components/notification"
export default class extends Notification {}import Sortable from "@stimulus-components/sortable"
export default class extends Sortable {}import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.resize()
this.element.addEventListener("input", this.resize)
}
disconnect() {
this.element.removeEventListener("input", this.resize)
}
resize = () => {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}import TimeAgo from "@stimulus-components/timeago"
export default class extends TimeAgo {}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
endclass ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
endclass ApplicationRecord < ActiveRecord::Base
primary_abstract_class
endclass Category < ApplicationRecord
has_many :resources, dependent: :nullify
has_many :posts, dependent: :nullify
TYPES = %w[mental_health food housing legal community other].freeze
validates :name, :slug, presence: true
validates :slug, uniqueness: true
validates :type_of, inclusion: { in: TYPES }, allow_nil: true
scope :of_type, ->(t) { where(type_of: t) }
endclass Comment < ApplicationRecord
belongs_to :user
belongs_to :post
belongs_to :parent, class_name: "Comment", optional: true
has_many :replies, class_name: "Comment", foreign_key: :parent_id, dependent: :destroy
validates :content, presence: true, length: { maximum: 3000 }
scope :roots, -> { where(parent_id: nil).order(created_at: :asc) }
after_create_commit -> { broadcast_append_to [post, "comments"] }
def display_author
anonymous? ? "Anonym" : user.email_address.split("@").first
end
endclass Crisis < ApplicationRecord
validates :title, :phone, presence: true
scope :around_clock, -> { where(available_24h: true) }
scope :for_country, ->(c) { where(country: c) }
endclass Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
endclass FoodListing < ApplicationRecord
belongs_to :user
has_many :food_requests, dependent: :destroy
STATUSES = %w[available reserved taken expired].freeze
UNITS = %w[kg portions bags boxes items].freeze
validates :title, :quantity, :available_until, presence: true
validates :quantity, numericality: { greater_than: 0 }
validates :status, inclusion: { in: STATUSES }
validates :unit, inclusion: { in: UNITS }
attribute :status, :string, default: "available"
geocoded_by :pickup_address
after_validation :geocode, if: :pickup_address_changed?
scope :available, -> { where(status: "available").where("available_until > ?", Time.current) }
scope :nearby, ->(lat, lng, km = 20) {
where("((latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?)) < ?",
lat, lat, lng, lng, (km / 111.0)**2)
}
before_save :expire_if_past_date
private
def expire_if_past_date
self.status = "expired" if available_until < Time.current && status == "available"
end
endclass FoodRequest < ApplicationRecord
belongs_to :food_listing
belongs_to :user
STATUSES = %w[pending accepted declined picked_up cancelled].freeze
validates :status, inclusion: { in: STATUSES }
validates :message, length: { maximum: 1000 }, allow_blank: true
attribute :status, :string, default: "pending"
after_create_commit -> { broadcast_prepend_to [food_listing, "requests"] }
scope :pending, -> { where(status: "pending") }
scope :accepted, -> { where(status: "accepted") }
endclass Post < ApplicationRecord
include ActionText::RichText
belongs_to :user
belongs_to :category
has_rich_text :body
has_many :comments, dependent: :destroy
validates :title, presence: true, length: { maximum: 200 }
scope :pinned, -> { where(pinned: true) }
scope :recent, -> { order(created_at: :desc) }
def display_author
anonymous? ? "Anonym" : user.email_address.split("@").first
end
endclass Resource < ApplicationRecord
belongs_to :user
belongs_to :category
RESOURCE_TYPES = %w[crisis_line support_group therapist hotline community_center other].freeze
validates :title, presence: true
validates :resource_type, inclusion: { in: RESOURCE_TYPES }
geocoded_by :address
after_validation :geocode, if: :address_changed?
scope :verified, -> { where(verified: true) }
scope :nearby, ->(lat, lng, km = 50) {
where("((latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?)) < ?",
lat, lat, lng, lng, (km / 111.0)**2)
}
scope :by_type, ->(t) { where(resource_type: t) }
endclass Session < ApplicationRecord
belongs_to :user
endclass SupportRequest < ApplicationRecord
belongs_to :user
STATUSES = %w[open in_progress resolved closed].freeze
PRIORITIES = %w[low normal high urgent].freeze
validates :subject, presence: true, length: { maximum: 200 }
validates :status, inclusion: { in: STATUSES }
validates :priority, inclusion: { in: PRIORITIES }
attribute :status, :string, default: "open"
attribute :priority, :string, default: "normal"
scope :open, -> { where(status: %w[open in_progress]) }
scope :urgent, -> { where(priority: "urgent") }
endclass User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
has_many :resources, dependent: :nullify
has_many :posts, dependent: :nullify
has_many :comments, dependent: :nullify
has_many :food_listings, dependent: :nullify
has_many :food_requests, dependent: :destroy
has_many :support_requests, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end<% content_for :title, "Community" %>
<header>
<h1>Community</h1>
<% if authenticated? %><%= link_to "New post", new_community_post_path %><% end %>
</header>
<section id="community_posts">
<% @posts.each do |post| %>
<article>
<%= link_to post.title, community_show_path(post) %>
<small><%= post.anonymous? ? "Anonymous" : post.user.email_address.split("@").first %></small>
</article>
<% end %>
</section><% content_for :title, "New post" %>
<h1>New post</h1>
<%= form_with url: community_path do |f| %>
<p><%= f.label :title %><%= f.text_field :title, name: "post[title]", autofocus: true %></p>
<p><%= f.label :body %><%= f.rich_text_area :body, name: "post[body]" %></p>
<p>
<%= label_tag :anonymous, "Post anonymously" %>
<%= check_box_tag "post[anonymous]" %>
</p>
<p><%= f.submit "Post" %> <%= link_to "Cancel", community_path %></p>
<% end %><% content_for :title, @post.title %>
<article>
<h1><%= @post.title %></h1>
<small><%= @post.anonymous? ? "Anonymous" : @post.user.email_address.split("@").first %></small>
<%= @post.body %>
</article>
<section id="comments">
<h2>Comments</h2>
<%= render @comments %>
<% if authenticated? %>
<%= form_with url: community_comments_path do |f| %>
<%= f.hidden_field :post_id, value: @post.id %>
<p><%= f.text_area :content, rows: 3, placeholder: "Comment…" %></p>
<p><%= f.submit "Comment" %></p>
<% end %>
<% end %>
</section><%= form_with model: listing, url: listing.new_record? ? food_listings_path : food_listing_path(listing) do |f| %>
<%= render "shared/errors", object: listing %>
<p><%= f.label :title %><%= f.text_field :title, autofocus: true %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 2 %></p>
<p><%= f.label :quantity %><%= f.number_field :quantity, min: 1 %></p>
<p><%= f.label :unit %><%= f.text_field :unit, placeholder: "kg, portions, bags…" %></p>
<p><%= f.label :pickup_address %><%= f.text_field :pickup_address %></p>
<p><%= f.label :city %><%= f.text_field :city %></p>
<p><%= f.label :available_from %><%= f.datetime_local_field :available_from %></p>
<p><%= f.label :available_until %><%= f.datetime_local_field :available_until %></p>
<p><%= f.label :dietary_info %><%= f.text_field :dietary_info, placeholder: "vegan, nut-free…" %></p>
<p><%= f.submit %> <%= link_to "Cancel", food_listings_path %></p>
<% end %><% content_for :title, "Edit listing" %>
<h1>Edit listing</h1>
<%= render "form", listing: @listing %><% content_for :title, "Food" %>
<header>
<h1>Available food</h1>
<% if authenticated? %><%= link_to "List food", new_food_listing_path %><% end %>
</header>
<section id="food_listings">
<% @food_listings.each do |listing| %>
<article>
<%= link_to listing.title, food_listing_path(listing) %>
<p><%= listing.quantity %> <%= listing.unit %> · <%= listing.city %></p>
<p>Until <%= listing.available_until&.strftime("%b %-d, %H:%M") %></p>
<% if listing.dietary_info.present? %>
<small><%= listing.dietary_info %></small>
<% end %>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "List food" %>
<h1>List food</h1>
<%= render "form", listing: @listing %><% content_for :title, @listing.title %>
<article>
<h1><%= @listing.title %></h1>
<p><%= @listing.description %></p>
<dl>
<dt>Quantity</dt><dd><%= @listing.quantity %> <%= @listing.unit %></dd>
<dt>Pickup</dt><dd><%= @listing.pickup_address %>, <%= @listing.city %></dd>
<dt>Available</dt><dd><%= @listing.available_from&.strftime("%b %-d, %H:%M") %> – <%= @listing.available_until&.strftime("%b %-d, %H:%M") %></dd>
<% if @listing.dietary_info.present? %><dt>Dietary</dt><dd><%= @listing.dietary_info %></dd><% end %>
</dl>
<% if authenticated? && @listing.user != Current.user && @listing.status == "available" %>
<%= form_with url: food_listing_food_requests_path(@listing) do |f| %>
<p><%= f.text_area :message, rows: 2, placeholder: "Message (optional)…" %></p>
<p><%= f.datetime_local_field :pickup_time %></p>
<p><%= f.submit "Request pickup" %></p>
<% end %>
<% end %>
<% if @listing.user == Current.user %>
<%= link_to "Edit", edit_food_listing_path(@listing) %>
<%= button_to "Delete", @listing, method: :delete, data: { turbo_confirm: "Delete?" } %>
<% end %>
</article><% content_for :title, "Hjerterom" %>
<% if @crisis_lines.any? %>
<aside>
<strong>Crisis support:</strong>
<% @crisis_lines.each do |c| %>
<%= c.title %> <strong><%= c.phone %></strong><%= " · " unless c == @crisis_lines.last %>
<% end %>
</aside>
<% end %>
<section>
<h2>Food available</h2>
<% @food_listings.each do |listing| %>
<article>
<%= link_to listing.title, food_listing_path(listing) %>
<p><%= listing.city %> · available until <%= listing.available_until&.strftime("%b %-d") %></p>
</article>
<% end %>
<%= link_to "All food listings →", food_listings_path %>
</section>
<section>
<h2>Community</h2>
<% @posts.each do |post| %>
<article>
<%= link_to post.title, community_show_path(post) %>
</article>
<% end %>
<%= link_to "All posts →", community_path %>
<% if authenticated? %><%= link_to "New post", new_community_post_path %><% end %>
</section>
<section>
<h2>Resources</h2>
<% @resources.each do |resource| %>
<article>
<%= link_to resource.title, resource_path(resource) %>
<p><%= resource.resource_type %><%= " · #{resource.city}" if resource.city.present? %></p>
</article>
<% end %>
<%= link_to "All resources →", resources_path %>
</section><!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "App" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="application-name" content="App">
<meta name="mobile-web-app-capable" content="yes">
<%= 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) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
</head>
<body>
<%= yield %>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html><%= yield %>{
"name": "App",
"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": "App.",
"theme_color": "red",
"background_color": "red"
}// 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)
// }
// })
// )
// })<%= form_with model: resource do |f| %>
<%= render "shared/errors", object: resource %>
<p><%= f.label :title %><%= f.text_field :title, autofocus: true %></p>
<p><%= f.label :description %><%= f.text_area :description, rows: 3 %></p>
<p>
<%= f.label :resource_type %>
<%= f.select :resource_type, %w[mental_health food shelter legal medical], include_blank: "Select…" %>
</p>
<p><%= f.label :address %><%= f.text_field :address %></p>
<p><%= f.label :city %><%= f.text_field :city %></p>
<p><%= f.label :phone %><%= f.telephone_field :phone %></p>
<p><%= f.label :email %><%= f.email_field :email %></p>
<p><%= f.label :url, "Website" %><%= f.url_field :url %></p>
<p><%= f.label :opening_hours %><%= f.text_field :opening_hours %></p>
<p><%= f.submit %> <%= link_to "Cancel", resources_path %></p>
<% end %><% content_for :title, "Edit resource" %>
<h1>Edit resource</h1>
<%= render "form", resource: @resource %><% content_for :title, "Resources" %>
<header>
<h1>Resources</h1>
<% if authenticated? %><%= link_to "Add resource", new_resource_path %><% end %>
</header>
<section id="resources">
<% @resources.each do |resource| %>
<article data-resource-type="<%= resource.resource_type %>">
<%= link_to resource.title, resource %>
<small><%= resource.resource_type %></small>
<p><%= resource.description %></p>
<p><%= [resource.city, resource.phone].compact.join(" · ") %></p>
</article>
<% end %>
</section>
<%= @pagy.series_nav if @pagy.pages > 1 %><% content_for :title, "Add resource" %>
<h1>Add resource</h1>
<%= render "form", resource: @resource %><% content_for :title, @resource.title %>
<article data-resource-type="<%= @resource.resource_type %>">
<h1><%= @resource.title %></h1>
<small><%= @resource.resource_type %></small>
<p><%= @resource.description %></p>
<dl>
<% if @resource.address.present? %><dt>Address</dt><dd><%= @resource.address %>, <%= @resource.city %></dd><% end %>
<% if @resource.phone.present? %><dt>Phone</dt><dd><%= @resource.phone %></dd><% end %>
<% if @resource.email.present? %><dt>Email</dt><dd><%= mail_to @resource.email %></dd><% end %>
<% if @resource.url.present? %><dt>Website</dt><dd><%= link_to @resource.url, @resource.url %></dd><% end %>
<% if @resource.opening_hours.present? %><dt>Hours</dt><dd><%= @resource.opening_hours %></dd><% end %>
</dl>
<% if @resource.user == Current.user %>
<%= link_to "Edit", edit_resource_path(@resource) %>
<%= button_to "Delete", @resource, method: :delete, data: { turbo_confirm: "Delete?" } %>
<% end %>
</article>require_relative "boot"
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_mailbox/engine"
require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
# require "rails/test_unit/railtie"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Don't generate system test files.
config.generators.system_tests = nil
end
endENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" # Set up gems listed in the Gemfile.
require "bootsnap/setup" # Speed up boot time by caching expensive operations.# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit.
# CVEs that are not relevant to the application can be enumerated on the ignore list below.
ignore:
- CVE-THAT-DOES-NOT-APPLYdevelopment:
adapter: async
test:
adapter: test
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: app_production# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Style: Ruby", "bin/rubocop"
step "Security: Gem audit", "bin/bundler-audit"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
development:
<<: *default
database: storage/development.sqlite3
# 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: storage/test.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
production:
primary:
<<: *default
database: storage/production.sqlite3
cache:
<<: *default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate# Name of your application. Used to uniquely configure containers.
service: app
# Name of the container image (use your-user/app-name on external registries).
image: app
# Deploy to these servers.
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: app.example.com
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
# Match this to any external database server to configure Active Record correctly
# Use app-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
# Use a persistent storage volume for sqlite database files and local Active Storage files.
# Recommended to change this to a mounted volume path that is backed up off server.
volumes:
- "app_storage:/rails/storage"
# Bridge fingerprinted assets, like JS and CSS, between versions to avoid
# hitting 404 on in-flight requests. Combines all files from new and old
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.9
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data# Load the Rails application.
require_relative "application"
# Initialize the Rails application.
Rails.application.initialize!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
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Suppress logger output for asset requests.
config.assets.quiet = 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!
endrequire "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 the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# 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 = :mem_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
# config.active_job.queue_adapter = :resque
# 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 bin/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# 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# Pin npm packages by running ./bin/importmap
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@stimulus-components/dialog", to: "@stimulus-components--dialog.js" # @1.0.1
pin "@stimulus-components/auto-submit", to: "@stimulus-components--auto-submit.js" # @6.0.0
pin "@stimulus-components/character-counter", to: "@stimulus-components--character-counter.js" # @5.1.0
pin "@stimulus-components/dropdown", to: "@stimulus-components--dropdown.js" # @3.0.0
pin "stimulus-use" # @0.52.3
pin "@stimulus-components/clipboard", to: "@stimulus-components--clipboard.js" # @5.0.0
pin "@stimulus-components/notification", to: "@stimulus-components--notification.js" # @3.0.0
pin "@stimulus-components/timeago", to: "@stimulus-components--timeago.js" # @5.0.2
pin "date-fns" # @4.1.0
pin "@stimulus-components/animated-number", to: "@stimulus-components--animated-number.js" # @5.0.0
pin "@stimulus-components/sortable", to: "@stimulus-components--sortable.js" # @5.0.3
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_request", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_request.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/fetch_response", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--fetch_response.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/lib/utils", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--lib--utils.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/request_interceptor", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--request_interceptor.js" # @0.0.13
pin "https://cdn.jsdelivr.net/npm/@rails/request.js@0.0.13/src/verbs", to: "https:----cdn.jsdelivr.net--npm--@rails--request.js@0.0.13--src--verbs.js" # @0.0.13
pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13
pin "sortablejs" # @1.15.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# 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
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end# 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
]# 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# 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"threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
port ENV.fetch("PORT") { 10004 }
environment ENV.fetch("RAILS_ENV") { "production" }
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
plugin :tmp_restartRails.application.routes.draw do
resource :session
resources :passwords, param: :token
root "home#index"
resources :resources
resources :food_listings do
resources :food_requests, only: %i[create update]
end
scope :community do
get "/", to: "community#index", as: :community
get "/:id", to: "community#show", as: :community_show
get "/new", to: "community#new", as: :new_community_post
post "/", to: "community#create"
resources :comments, only: %i[create destroy]
end
resources :users, only: %i[show]
get "up", to: "rails/health#show", as: :rails_health_check
endtest:
service: Disk
root: <%= Rails.root.join("tmp/storage") %>
local:
service: Disk
root: <%= Rails.root.join("storage") %>
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
# service: S3
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
# region: us-east-1
# bucket: your_own_bucket-<%= Rails.env %>
# 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 %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email_address, unique: true
end
endclass CreateSessions < ActiveRecord::Migration[8.1]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent
t.timestamps
end
end
endclass CreateCategories < ActiveRecord::Migration[8.1]
def change
create_table :categories do |t|
t.string :name
t.string :slug
t.text :description
t.string :type_of
t.timestamps
end
add_index :categories, :slug, unique: true
end
endclass CreateResources < ActiveRecord::Migration[8.1]
def change
create_table :resources do |t|
t.references :user, foreign_key: true
t.references :category, foreign_key: true
t.string :title
t.text :description
t.string :url
t.string :address
t.string :city
t.string :postal_code
t.float :latitude
t.float :longitude
t.string :phone
t.string :email
t.boolean :verified, default: false
t.string :resource_type
t.text :opening_hours
t.timestamps
end
end
endclass CreateCrises < ActiveRecord::Migration[8.1]
def change
create_table :crises do |t|
t.string :title
t.text :description
t.string :phone
t.string :sms
t.string :chat_url
t.boolean :available_24h, default: false
t.string :languages
t.string :country
t.timestamps
end
end
endclass CreateFoodListings < ActiveRecord::Migration[8.1]
def change
create_table :food_listings do |t|
t.references :user, foreign_key: true
t.string :title
t.text :description
t.integer :quantity
t.string :unit
t.datetime :available_from
t.datetime :available_until
t.string :pickup_address
t.string :city
t.float :latitude
t.float :longitude
t.string :status
t.string :dietary_info
t.timestamps
end
end
endclass CreateFoodRequests < ActiveRecord::Migration[8.1]
def change
create_table :food_requests do |t|
t.references :food_listing, foreign_key: true
t.references :user, foreign_key: true
t.text :message
t.string :status
t.datetime :pickup_time
t.timestamps
end
end
endclass CreatePosts < ActiveRecord::Migration[8.1]
def change
create_table :posts do |t|
t.references :user, foreign_key: true
t.references :category, foreign_key: true
t.string :title
t.boolean :anonymous, default: false
t.boolean :pinned, default: false
t.integer :views_count, default: 0
t.timestamps
end
end
endclass CreateComments < ActiveRecord::Migration[8.1]
def change
create_table :comments do |t|
t.references :user, foreign_key: true
t.references :post, foreign_key: true
t.integer :parent_id
t.text :content
t.boolean :anonymous, default: false
t.timestamps
end
end
endclass CreateSupportRequests < ActiveRecord::Migration[8.1]
def change
create_table :support_requests do |t|
t.references :user, foreign_key: true
t.string :subject
t.string :status
t.string :priority
t.datetime :resolved_at
t.timestamps
end
end
endadmin = User.find_or_create_by!(email_address: "admin@hjerterom.no") do |u|
u.password = u.password_confirmation = "password123"
end
crisis_lines = [
{ title: "Mental Helse Hjelpelinjen", phone: "116 123", available_24h: true, languages: "Norsk", country: "NO" },
{ title: "Kirkens SOS", phone: "22 40 00 40", available_24h: true, languages: "Norsk", country: "NO" },
{ title: "Røde Kors Besøkstjeneste", phone: "800 33 321", available_24h: false, languages: "Norsk", country: "NO" },
]
crisis_lines.each { |c| Crisis.find_or_create_by!(title: c[:title]) { |cr| cr.update(c) } }
cats = [
{ name: "Angst", slug: "angst", type_of: "mental_health" },
{ name: "Depresjon", slug: "depresjon", type_of: "mental_health" },
{ name: "Ensomhet", slug: "ensomhet", type_of: "mental_health" },
{ name: "Mat", slug: "mat", type_of: "food" },
]
cats.each { |c| Category.find_or_create_by!(slug: c[:slug]) { |cat| cat.update(c) } }
puts "Seeded crisis lines and categories"# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#!/usr/bin/env zsh
# hjerterom.sh — deploys tracked Rails tree at app/ as %APP_NAME%
set -euo pipefail
APP_NAME=%APP_NAME%
APP_DIR=/home/${APP_NAME}/app
APP_PORT=38891
APP_DOMAIN=
SCRIPT_DIR=${0:a:h}
SRC_DIR=${SCRIPT_DIR}/app
. "${SCRIPT_DIR:h}/@shared_functions.sh"
need_cmd ruby34 bundle doas
[[ -d $SRC_DIR ]] || { log_err "missing source tree: $SRC_DIR"; exit 1 }
log "${APP_NAME} — deploying tracked tree → ${APP_DIR}"
id "$APP_NAME" >/dev/null 2>&1 || doas useradd -m -L daemon -s /bin/ksh "$APP_NAME"
doas mkdir -p "$APP_DIR"
doas cp -R "${SRC_DIR}/." "${APP_DIR}/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$APP_DIR"
cd "$APP_DIR"
typeset bundle_home="/home/${APP_NAME}/.bundle"
if [[ ! -d ${bundle_home}/gems ]]; then
log "Bootstrapping gems from amber"
doas mkdir -p "$bundle_home"
doas cp -R /home/amber/.bundle/gems "$bundle_home/"
doas chown -R "${APP_NAME}:${APP_NAME}" "$bundle_home"
fi
print "---\nBUNDLE_PATH: \"${bundle_home}/gems\"" | doas tee "${APP_DIR}/.bundle/config" >/dev/null
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bundle install --deployment --without development:test"
doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:create db:migrate"
[[ -f ${APP_DIR}/db/seeds.rb ]] && doas -u "$APP_NAME" sh -c "cd ${APP_DIR} && RAILS_ENV=production bin/rails db:seed" || true
install_rcd "$APP_NAME" "$APP_DIR" "$APP_PORT" "$APP_NAME"
[[ -n $APP_DOMAIN ]] && relayd_add_relay "$APP_DOMAIN" "$APP_PORT"
doas rcctl restart "$APP_NAME" || doas rcctl start "$APP_NAME"
log_ok "$APP_NAME live on :$APP_PORT"#!/usr/bin/env zsh
setopt err_return no_unset pipe_fail extended_glob warn_create_g
set -euo pipefail
# Gather target files
typeset -a files errors
files=(**/*.sh)
# Default sed patterns (override by exporting sed_patterns)
if (( ${#sed_patterns[@]} == 0 )); then
sed_patterns=(
's/\r$//g' # strip CR
's/[[:space:]]+$//g' # trim trailing whitespace
's/^[[:space:]]+//' # trim leading whitespace
)
fi
# Choose correct -i syntax for sed
case $(uname) in
Darwin) sed_in_place=(-i '') ;;
*) sed_in_place=(-i) ;;
esac
for file in $files; do
[[ $file == */modernize_zsh.sh ]] && continue
[[ -f $file && -r $file ]] || {
errors+=("$file: not found or unreadable")
continue
}
# Resolve symlink to real file
if [[ -L $file ]]; then
real=$(readlink -f $file 2>/dev/null) || {
errors+=("$file: cannot resolve symlink")
continue
}
[[ -f $real ]] && file=$real || {
errors+=("$file: symlink target missing")
continue
}
fi
# Make a backup if none exists
if [[ ! -f ${file}.bak ]]; then
cp --preserve=mode,timestamps "$file" "${file}.bak" || {
errors+=("$file: backup failed")
continue
}
fi
# Dry‑run all patterns
for pat in "${sed_patterns[@]}"; do
if ! sed -n "${sed_in_place[@]}" -e "$pat" -e 'q' "$file" >/dev/null 2>&1; then
errors+=("$file: dry‑run failed for $pat")
continue 2
fi
done
# Apply patterns
for pat in "${sed_patterns[@]}"; do
if ! sed "${sed_in_place[@]}" -e "$pat" "$file"; then
errors+=("$file: transformation failed for $pat")
continue 2
fi
done
done
if (( ${#errors[@]} )); then
printf "Errors:\n%s\n" "${errors[@]}"
exit 1
fi#!/usr/bin/env sh
set -euo pipefail
log() {
printf '%s [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$(basename "${0##*/}")" "$*" >&2
}
require_file() {
if [ ! -f "$1" ]; then
log "Error: required file not found: $1"
return 1
fi
}
install_tiptap_packages() {
require_file package.json || return 1
if command -v yarn >/dev/null 2>&1; then
yarn add @tiptap/core @tiptap/starter-kit @tiptap/extension-link
elif command -v npm >/dev/null 2>&1; then
npm install --save @tiptap/core @tiptap/starter-kit @tiptap/extension-link
else
log "Error: neither yarn nor npm is available"
return 1
fi
}
create_tiptap_controller() {
target="app/javascript/controllers/rich_text_controller.js"
if [ -e "$target" ]; then
log "Skipping controller creation; $target already exists"
return 0
fi
mkdir -p "$(dirname "$target")"
cat >"$target" <<'EOF'
import { Controller } from "@hotwired/stimulus"
import { Editor } from "@tiptap/core"
import StarterKit from "@tiptap/starter-kit"
import Link from "@tiptap/extension-link"
export default class extends Controller {
static targets = ["input", "editor"]
connect() {
this.editor = new Editor({
element: this.editorTarget,
extensions: [StarterKit, Link],
content: this.inputTarget.value || "",
onUpdate: ({ editor }) => {
this.inputTarget.value = editor.getHTML()
}
})
}
disconnect() {
this.editor && this.editor.destroy()
}
}
EOF
}
create_editor_styles() {
target="app/assets/stylesheets/rich_editor.css"
if [ -e "$target" ]; then
log "Skipping stylesheet creation; $target already exists"
return 0
fi
mkdir -p "$(dirname "$target")"
cat >"$target" <<'EOF'
.rich-editor {
border: 1px solid #d1d5db;
border-radius: 12px;
background: #ffffff;
min-height: 14rem;
padding: 0.875rem;
}
.rich-editor:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
EOF
}
add_rich_editor() {
app_name="${1:-$(basename "$(pwd)")}"
log "Installing Tiptap rich editor into ${app_name}"
install_tiptap_packages
create_tiptap_controller
create_editor_styles
log "Rich editor scaffolding completed for ${app_name}"
}
# Execute only when run directly, not when sourced
case "$0" in
*sh) add_rich_editor "${1:-}" ;;
esac# frozen_string_literal: true
module Bridges
module Repligen
MODEL_CATALOG = [
{
key: "repligen/krosflo-kr2i",
name: "KrosFlo KR2i Tangential Flow Filtration System",
manufacturer: "Repligen",
category: "tff_system",
tags: %w[tff filtration].freeze,
url: "https://www.repligen.com/products/krosflo-kr2i"
}.freeze,
{
key: "repligen/krosflo-kr2s",
name: "KrosFlo KR2s Tangential Flow Filtration System",
manufacturer: "Repligen",
category: "tff_system",
tags: %w[tff filtration].freeze,
url: "https://www.repligen.com/products/krosflo-kr2s"
}.freeze,
{
key: "repligen/xcell-atf",
name: "XCell ATF System",
manufacturer: "Repligen",
category: "cell_retention",
tags: %w[atf perfusion].freeze,
url: "https://www.repligen.com/products/xcell-atf"
}.freeze
].freeze
def self.model_catalog = MODEL_CATALOG
end
end# frozen_string_literal: true
gem "fiddle"
source "https://rubygems.org"
gem "ruby_llm", "~> 1.3"
gem "tty-prompt", "~> 0.23"
gem "tty-reader", "~> 0.9"
gem "tty-spinner", "~> 0.9"
gem "tty-markdown", "~> 0.7"
gem "tty-table", "~> 0.12"
gem "tty-screen", "~> 0.8"
gem "tty-box", "~> 0.7"
gem "tty-command", "~> 0.10"
gem "tty-tree", "~> 0.4"
gem "tty-config", "~> 0.6"
gem "tty-logger", "~> 0.6"
gem "tty-progressbar", "~> 0.18"
gem "pastel", "~> 0.8"
gem "rouge", "~> 4.4"
gem "diffy", "~> 3.4"
gem "zeitwerk", "~> 2.7"
gem "sinatra", "~> 4.0"
gem "sinatra-contrib", "~> 4.0"
gem "rb-edge-tts", git: "https://github.com/ZPVIP/rb-edge-tts"
group :test do
gem "minitest", "~> 5.25"
gem "rack-test", "~> 2.1"
gem "ferrum", "~> 0.15"
end
gem "ruby_llm-mcp"
gem "rubocop", "~> 1.60", require: false
gem "reek", "~> 6.4", require: false
gem "flay", require: false
gem "opentelemetry-sdk", "~> 1.11", require: false
# MASTER
A constitutional AI coding agent. Ruby. OpenBSD. Self-hosting.
MASTER reads its own constitution at boot, scans its own code for violation, sweeps the corruption, and argues the result through an adversarial council before shipping. It edits files. It does not narrate.
The pipeline runs in ten stages — Intake, Infer, Route, Guard, Execute, Council and Lint in parallel, Prune, Memo, Render. Every stage returns a Result monad. An axiom violation rolls the workspace back to HEAD. A thirty-second deadline binds the parallel pair.
The pipeline reads as two tanks. The Pressure tank compresses input — verbose user prose folded into a dense, intent-tagged prompt by Intake, Infer, and Compress. The Depressure tank refines output — Render applies smart quotes, em dashes, and ellipses outside code fences; the council and lint stages strip what the constitution would reject. Pressure favors signal density. Depressure favors typographic and axiomatic discipline. Together they bound every turn.
The constitution lives in `data/`. Thirty-six YAML files — soul, rules, ruby_style, workflow, standing_orders, models, council, council_questions, infer_patterns, sweep_prompts, zsh_patterns and the rest — replace the 1770-line monolith MASTER inherited and burned. The Ruby code reads these at boot. The agent is the config.
`rules.yml` carries six universal laws — Robustness, Singularity, Linearity, Proximity, Abstraction, Density — a single hierarchical ladder under which every named rule, persona, and fix verb is anchored. Lower number wins in conflict. Beside the laws sit a biases chapter (hallucination, simulation, sycophancy, completion theater, false confidence — meta-anti-patterns above lexical detection), a structural-ops vocabulary (merge, semantic regroup, defrag, decouple, hoist, flatten, delete, expand, reduce noise — each tagged with risk and verify spec), a veto-patterns table for regex-detected unconditional blocks (secrets, SQL injection, unfinished placeholders), and a beauty section that anchors aesthetic decisions to Bringhurst, Ando, Rams, and Martin. The voice paragraph carries Strunk & White safeguards — `apply_to: prose, comments, documentation, strings`; `never_apply_to: code logic, algorithms, data structures` — so refinement never silently deletes a variable name or collapses a conditional.
The scanner sweeps the tree in parallel across CPUs, applies fifty-plus named rules across four scopes — Prism-AST for Ruby structure, regex for anti-patterns, repo-graph mining for hidden coupling, registry self-checks for orphan rule files — and emits findings as data. The lexical layer covers structural smells (duplicate, god class, deep nesting, long method) and Rails-shaped hazards (n+1, mass assignment, time-zone unsafety, memoize-falsy, i18n leakage, stale TODO debt). The semantic layer runs an LLM conceptual pass that judges DRY, KISS, SOLID, POLA, and a mirror opportunity pass that names the pattern each file is 80% of the way to. The visual layer pushes every file toward the dominant silhouette — frozen-string-literal first, requires alphabetized, constants and attrs before init, `private` on its own line, one blank above each def, naming that matches return shape, and a structural fingerprint clustered against the rest of the directory. Two cross-file signals run beside the per-file rules: a co-change graph mined from the last five hundred commits flags pairs that always change together across module paths, and a comment-drift pass asks the LLM whether each comment still describes the code below it. Sweep takes the findings and rewrites the source — rubocop autocorrect first, deterministic and free; then the LLM, surgical and rate-limited, with best-of-N candidate scoring on files above four kilobytes; then the corruption guards reject anything that lost half its length, matched an error pattern, or failed `ruby -c`. Both `/scan` and `/sweep` default to deep depth, and `scan_since` accepts a git ref to scan only what changed.
Observability rides on a thin OpenTelemetry layer — `Master::Telemetry` wraps the event bus, metrics, audit log, and heartbeat in spans and emits JSONL traces to `.master/traces.log`. Soft-optional: if the gem is absent, every span call collapses to a plain yield.
The council convenes adversarial personas — pragmatist, purist, skeptic, historian — when a change touches a protection tier. Each speaks once. The pipeline waits, then ships or rolls back.
The voice is OpenBSD dmesg. Structured. Unhedged. Active. No headlines, no bullet lists without content, no apology. The forbidden words — *will*, *would*, *could*, *might* — surrender to the indicative.
Launch from the project root with `bundle exec ruby exe/master`. Pipe input through stdin for one-shot mode. The Rails 8 web face listens on 53187, fronted by relayd to ai.brgen.no — a 2000-particle orb, an ambient pad engine, seventeen voice effects, all incidental.
A live canvas — the openclaw inheritance — sits at `/canvas`. The agent draws nodes for violations, edges for fixes, a deliberation tree for council rounds, a timeline for sweep cycles. The user watches the constitution argue with the code in real time. Spec at `data/canvas.yml`, routes at `data/canvas_routes.yml`.
Deploy through `DEPLOY/openbsd/openbsd.sh`, two stages, resumable.
MIT.# frozen_string_literal: true
require "rake/testtask"
Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.test_files = FileList["test/test_*.rb"]
t.warning = false
end
desc "Deep scan lib/ — exit 1 if any violations found (static rules, no LLM)"
task :constitution do
$LOAD_PATH.unshift(File.join(__dir__, "lib"))
require "master"
root = __dir__
scanner = Master::Scan::Scanner.new
Master::Scan::Rule.registry.select(&:auto_build?).each { |klass| scanner.add_rule(klass.new) }
scanner.add_rule(Master::Scan::Rules::AxiomCoverageRule.new(root:))
scanner.add_rule(Master::Scan::Rules::RubocopRule.new(root:))
scanner.add_rule(Master::Scan::Rules::ReekRule.new(root:))
scanner.add_rule(Master::Scan::Rules::InterconnectRule.new(root:))
result = scanner.scan_dir(File.join(root, "lib"), depth: :deep, stream: false)
abort "constitution: scan failed: #{result.message}" unless result.respond_to?(:ok?) && result.ok?
violations = result.value!.flat_map { |_f, r| (r.respond_to?(:ok?) && r.ok?) ? r.value! : [] }
total = violations.size
if total.zero?
puts "constitution: clean"
else
by_rule = violations.group_by { |v| v[:rule] }
by_rule.sort_by { |_, vs| -vs.size }.each do |rule, vs|
puts "[#{rule}] #{vs.size}"
vs.first(5).each { |v| puts " #{v[:file]}:#{v[:line]}: #{v[:message]}" }
end
puts "constitution: #{total} violation(s)"
exit 1
end
end
task default: :test
# config_status: aspirational # spec exists, runtime wiring pending
# Typed child agents (cleaner than ad-hoc thread spawning).
# Source: opencrabs + Manus reunification (#76, #81).
agent_types:
explore:
purpose: "search, glob, grep, read-only inspection"
tools: [read_file, list_dir, search_files, symbol_lookup, tree]
max_runtime: 60s
plan:
purpose: "read code + propose stepwise plan; never edits"
tools: [read_file, search_knowledge, list_dir]
output: structured_plan
code:
purpose: "apply the plan; one file at a time"
tools: [read_file, str_replace, write_file, ast_edit, atomic_write]
requires: plan_id
research:
purpose: "external lookup, citations, summaries"
tools: [web_search, web_fetch, search_knowledge]
max_runtime: 120s
verify:
purpose: "ruby -c, scan, test, council vote"
tools: [shell, scan, council_call]
output: pass_fail_with_evidence
toolset_groups:
research: [web_search, web_fetch, search_knowledge, deepwiki]
build: [read_file, str_replace, write_file, ast_edit, atomic_write, batch_replace]
verify: [shell, scan, council_call]
ship: [git_context, atomic_write, audit_log]
spawn_policy:
max_concurrent_children: 4
inherit_governor: true
sanitize_output_via: InjectionGuard# config_status: aspirational # spec exists, runtime wiring pending
# Sign every audit-log entry and every commit body with signify(1).
# Source: cross-cutting reunification (#96).
audit_signature:
enabled: true
keypair_path: "/etc/signify/master.sec"
public_path: "/etc/signify/master.pub"
sign_targets:
- "audit_log entries"
- "commit messages (trailer line)"
- "rules.yml on save"
verify_on_boot: true
on_mismatch: "halt; require human signing key reissue"# Cost ceiling for a session. Routes degrade strong -> fast -> cheap as spend climbs.
# Source: master2 reunification.
budget:
limit: 10.0
currency: USD
thresholds:
strong: 5.0
fast: 1.0
cheap: 0.0
on_exceed:
action: degrade_route
notify: bus
# Beyond dollars, track approximate kWh / CO2 per session.
# Source: cross-cutting reunification (#98).
carbon_budget:
kwh_per_million_tokens: 0.5 # rough industry estimate
co2_g_per_kwh: 400 # mixed grid baseline
daily_kwh_cap: 1.0
on_exceed: "switch to local model only; publish carbon:exceeded"# config_status: aspirational # spec exists, runtime wiring pending
# Live agent-controlled canvas for the web UI. Sketch the user can watch fill in.
# Source: openclaw reunification (#87).
canvas:
enabled: true
mount_path: "/canvas"
surface: "html5_canvas + svg overlay"
update_channel: "EventBus -> SSE stream"
persist: "web/db/canvas_sessions/${session_id}.jsonl"
primitives:
- draw_node: {x: int, y: int, label: string, kind: "violation | fix | persona"}
- draw_edge: {from: node_id, to: node_id, kind: "fixes | depends | conflicts"}
- highlight: {node_id: id, color: "ok | warn | err"}
- annotate: {x: int, y: int, text: string, ttl_ms: int}
capabilities:
- "render scan findings as a graph"
- "render council deliberation as a deliberation tree"
- "render autoloop cycles as a sweep timeline"# config_status: aspirational # spec exists, runtime wiring pending
# Web routes for the live canvas surface. Read by the Rails app at boot.
# Source: openclaw + #87.
routes:
- path: "/canvas"
controller: canvas
action: show
- path: "/canvas/stream"
controller: canvas
action: stream # SSE
- path: "/canvas/event"
controller: canvas
action: post_event # used by EventBus relay# config_status: aspirational # spec exists, runtime wiring pending
# Headless Chrome via Chrome DevTools Protocol — verifies UI renders without a human.
# Source: opencrabs reunification (#85). Capability spec; impl follows later.
cdp_browser:
enabled: false
binary: "chromium --headless --remote-debugging-port=9222"
use_for: [live_preview_gate, accessibility_audit, lighthouse_scoring]
sandbox_via: pledge
timeout_seconds: 15# Memory Index
- [MASTER project context](project_master.md) — pub4/MASTER constitutional AI agent on dev@brgen.no, OpenRouter API, Ruby/OpenBSD
- [master.yml + master.json are authoritative](project_master_yml_json_authority.md) — current Ruby MASTER must implement what predecessors describe; 18 priority gaps tracked
- [User is an architect](user_architect_aesthetics.md) — aesthetic/typography/design-philosophy proposals usually approved; don't self-censor them
- [Always autofix violations](feedback_autofix.md) — run /sweep immediately after any scan finds violations, no confirmation needed
- [Frequent git commits](feedback_git_commits.md) — commit after every meaningful change, don't batch
- [No new files without approval](feedback_no_new_files.md) — always edit originals in place, never create staging/copy files
- [Ultra-minimalistic coding style](feedback_style.md) — cut all filler across Ruby, Zsh, HTML, JS; preserve intentional logic
- [No Python](feedback_no_python.md) — only Ruby for all scripting tasks, never python3
- [Mandatory lint/beautify on touch](feedback_lint_beautify.md) — every edited file gets a full lint/beautify pass, not just changed lines
- [Strunk & White style](feedback_strunk_white.md) — commits, comments, log lines: active voice, omit needless words, concrete verbs, dmesg format
- [Voice — terse, unix, perfectionist](feedback_voice_terse_unix.md) — my outputs and MASTER's voice config: cut filler, diagnostic style, loop till zero violations
- [Auto-update README.md when needed](feedback_readme_autoupdate.md) — refresh README prose after any behavior/capability/surface change, no prompting
- [No heavy work on device](feedback_device_limits.md) — Termux/Android is low-power; defer Ruby runs, large clones, mass ops to VPS
- [Bare HTML/CSS targeting, no divitis](feedback_html_css_style.md) — nav a not .nav__link; tag helper; no class attrs on elements targetable by tag
- [MASTER zsh discipline applies to my shell](feedback_master_zsh_discipline.md) — banned cmds (sed/awk/grep/wc/head/tail/find/sudo/...) apply to my Bash calls too, not just to scripts I write
- [Autoproceed without confirmation](feedback_autoproceed.md) — execute full backlog after one approval; no per-step go/no-go
- [No permission questions for predictable yes](feedback_no_permission_questions.md) — never ask "want me to?" / "shall I?" when prior approval makes the answer obvious
- [Decisive short directives = full authorization](feedback_decisive_signals.md) — "ship all", "kill X keep Y", "yes" = binding; for >10 items, ship pass-by-pass with one-sentence checkpoints
- [No consecutive whitespace](feedback_no_consecutive_whitespace.md) — single space, single blank line max, no trailing/aligned-column padding; all file types
- [Proper casing, no ASCII decorations](feedback_proper_casing.md) — sentence case in prose/comments/CLI; no === ---- [ok] • | as ASCII art. Boot dmesg banner is sacred.
- [Restart MASTER after every web edit](feedback_restart_rails.md) — `doas rcctl restart master` after each scp under MASTER/web/, never batch and restart once at end
- [Defrag/dedup/rename plan 2026-05](project_defrag_plan_2026_05.md) — multi-commit refactor; priority-1 = Master::Orient + slim AGENTS/CLAUDE + .zshrc fix
- [MASTER 7-module refactor approved 2026-05-08](project_master_seven_module_refactor.md) — now/loop/judge/voice/ground/reach/trace; pass-by-pass on VPS, supersedes the 6 dedup proposals
- [OpenCrabs (Rust MASTER cousin)](reference_opencrabs.md) — github.com/adolfousier/opencrabs; brain-files-per-turn, FTS5 memory, /rebuild + exec() hot-restart
- [Grok UI/CLI patterns](reference_grok_ui_cli_patterns.md) — StyleCoach prompt, htmx+SSE, tty-prompt/spinner, char-stream Claude CLI for MASTER polish
- [Importance-ordered file layout](feedback_importance_order.md) — every file's lines flow by importance; newspaper inverted pyramid; public API > primary > helpers > edge cases
- [Reassess comments on every touch](feedback_comments_reassess.md) — touch a file = re-read all its comments; delete obvious, rewrite kept ones S&W-style
- [Meta-architecture framing over diff reports](feedback_meta_framing.md) — after a batch, surface what's next/structurally off; user prefers 2x wins to 5% tweaks
- [MASTER has two Gemfiles](project_master_dual_gemfile.md) — MASTER/Gemfile and MASTER/web/Gemfile are independent; gems used by lib/ from web must be in both
- [Falcon + EM = subprocess](project_falcon_em_subprocess.md) — Process.fork in a Falcon fiber raises "Closing scheduler"; EM-based gems must shell out to exe/<name>-worker
- [Diverged branch sync via cherry-pick](feedback_diverged_branch_sync.md) — when local + remote both moved, backup-tag + reset to origin + cherry-pick targeted commits, never force-push or rebase mixed history
- ["Run X through MASTER" = scan+sweep+tribunal](feedback_run_through_master_triad.md) — /triad on target; user vocabulary is "tribunal", code is "council"; current /triad's 3rd step is a buggy on/off toggle, not actual deliberation
- [No unnecessary piping/concat in shell calls](feedback_no_shell_piping.md) — pure Ruby/zsh patterns; banned shell cmds rule applies to my Bash calls too---
name: always autofix violations
description: User wants all scan violations autofixed immediately, no asking
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Always run /sweep (or /autoloop) automatically after any scan that finds violations. Do not ask for confirmation.
All scan rules have `@auto_fix = true` (set in `Rule#initialize` base class).
**Why:** User said "autofix all always" — violations should be fixed immediately, all rules are eligible.
**How to apply:** After any /scan that returns violations, immediately kick off /sweep on the VPS without prompting. The base Rule class defaults @auto_fix=true so all rules participate.---
name: Autoproceed without confirmation
description: Once user approves a direction, execute the full backlog without pausing for per-step confirmation
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
When the user has approved a direction or list of tasks, execute the entire backlog end-to-end without pausing to ask "want me to do X next?" between items.
**Why:** User said "yes, and autoproceed for all in this chat always" — they want momentum, not checkpoints. Repeated mid-task confirmation requests slow them down and waste turns.
**How to apply:** After each completed step, immediately move to the next pending item. Only stop to ask if (1) a destructive/irreversible action would affect shared state beyond local files, (2) ambiguity emerges that would change the approach materially, or (3) the backlog is genuinely empty. Brief progress updates between steps are fine; explicit go/no-go prompts are not.
**Reinforced 2026-05-07** ("autpmatically autoproceed with next always"): also keep going *between passes* of a multi-commit batch. Don't end a turn on "say 'next' or pick a slice" — just commit pass N and start pass N+1. Stop only when the original backlog is empty or the destructive/ambiguity gates trigger. Out-of-context interruptions (user types something new mid-stream) override the autoproceed and are addressed first.---
name: Reassess comments on every touch
description: Every edit re-reads each comment in the file — delete if obvious, rewrite Strunk & White style if kept.
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
When I edit any file, I reassess every comment in it — not just the ones near my changes. If a comment merely restates what the code does, delete it. If it carries a non-obvious WHY, rewrite it Strunk-and-White style: active voice, omit needless words, concrete verbs, one line max.
**Why:** Comments rot faster than code. The user (2026-05-07) asked that comments be reassessed and rewritten ultra-minimalistically on every touch — no grandfathered fluff. Encoded in `MASTER/data/ruby_style.yml` (`comments.reassess_on_touch: true`) and as the `RECOMMENT` technique in `MASTER/data/sweep_prompts.yml`.
**How to apply:**
- Touch a file = touch its comments. Don't preserve old comments unread.
- Delete: what-comments, restatements of code, ASCII section banners, numbered-step comments, YARD-style doc blocks, multi-line prose.
- Keep + rewrite: hidden constraints, workarounds for specific bugs, behavior that would surprise a reader, non-obvious invariants.
- Style of kept comments: one line, active voice, no hedging, no filler ("we", "just", "simply", "basically"). Concrete nouns and verbs.
- Do NOT add comments to my own new code unless WHY is non-obvious — the default is no comment.---
name: Decisive short directives = full authorization
description: Short lowercase replies ("ship all", "kill X keep Y", "yes", "i think X") are binding — execute pass-by-pass without re-confirming.
type: feedback
originSessionId: b02ce9b9-a7c7-4c65-b8d0-3b8469dc2028
---
Short, lowercase, often typo-laden user directives are decisive — full authorization to execute without re-asking. Recognized signals:
- "ship all" / "yes" / "do it" → full proposed backlog approved
- "kill X, keep Y" → binary fork decided
- "i think X" → user has settled on X, proceed
- "propose N X" → wants a numbered, categorized list with one-liner per item, grouped by surface (type, color, motion, etc.); user then picks a slice or says "ship all"
For large approved batches (>10 items), ship in coherent commit-sized passes (~10–12 items per commit), checkpoint briefly between passes. Don't try to ship 40 in one go. Don't ask "are you sure" or stall on confirmation between passes — checkpoint = one short status sentence, not a question.
**Why:** Validated on 2026-05-07 lofi-aesthetic session. I diagnosed a two-voice TTS bug, user said "kill cli tts, keep web tts" (one sentence, decisive), I executed without re-asking. Then I proposed 40 lofi refinements organized by surface, user said "can we ship all?", I scoped pass-by-pass and started shipping — user then explicitly said "make sure we codify my messages that lead to great success like now."
**How to apply:** Treat one-line approvals as binding contracts. For "ship all N" where N > 10, propose pass plan in 1–2 sentences, execute pass 1, give a one-sentence checkpoint, continue. Stop only on failure, ambiguity, or destructive scope.---
name: No heavy work on device
description: Termux/Android — defer CPU/IO-heavy tasks to VPS, keep device work minimal
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Prefer the VPS (dev@185.52.176.18) for all work. This device (Termux/Android) is a last resort.
**Why:** User said "prefer using the VPS" and "avoid doing heavy stuff on this device."
**How to apply:** Default to SSH into the VPS for every task — edits, Ruby runs, git, clones, builds. Only fall back to this device when VPS SSH is down and the task is genuinely lightweight (small curl, quick read).---
name: Diverged branch sync via cherry-pick onto remote
description: When local and remote main have diverged with overlap, cherry-pick the targeted commits onto remote tip rather than rebase mixed history or force-push
type: feedback
originSessionId: b02ce9b9-a7c7-4c65-b8d0-3b8469dc2028
---
When `git push` is rejected because remote has new commits and local also has commits the remote doesn't, prefer this flow:
1. `git tag backup-pre-sync-YYYY-MM-DD` on local main
2. `git reset --hard origin/main` (backup tag preserves the prior tip)
3. Cherry-pick only the commits we actually want to ship (e.g. session's lofi passes), not the mixed pile of older local-only commits that may already exist upstream in equivalent form
4. Resolve conflicts case by case
5. Push
**Why:** User said "push sync github" after a session that produced 16 lofi commits on top of 9 older local commits, while remote had 20 unrelated commits. Rebasing all 25 would have replayed work already on remote in equivalent form, producing duplicate commits and unnecessary conflicts. Force-push would have destroyed the 20 remote commits — unacceptable. The cherry-pick-onto-remote approach shipped exactly the intended work, kept history linear, and was accepted without pushback ("great." after sync).
**How to apply:** Use this when (a) the user's intent is clearly "ship my recent work, not all local work" — e.g. after a focused session like a feature batch, and (b) older local-only commits look duplicated on remote (same area, similar messages). Always create a backup tag before reset. If the user's intent is "preserve all local work", do a full rebase or merge instead.---
name: frequent git commits
description: Make git commits frequently after meaningful changes
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Commit after every meaningful change — don't batch. After fixing a bug, restoring a file, or completing a refactor, commit immediately.
**Why:** User explicitly requested frequent commits.
**How to apply:** After any file write or fix on the VPS, run `git add <file> && git commit -m "..."` before moving on.---
name: Bare HTML/CSS targeting — no divitis, no utility classes
description: Always use bare element selectors (nav a, main, h1) not BEM classes or utility class strings on elements
type: feedback
originSessionId: ab7bf92a-5fdc-43bb-998c-dc1d5598f33d
---
Use bare element and structural selectors throughout. Never add class attributes to elements that can be targeted by tag or relationship.
**Why:** User explicitly stated "always bare targeting for clean HTML/CSS" and "no divitis." Confirmed with rejection of `.nav__link`, `.nav__brand`, `.nav__links` pattern.
**How to apply:**
- `nav a` not `a.nav__link`
- `.brand` only for the logo anchor that needs differentiation from other nav links
- `nav { ... }` for nav bar styling, not `.navbar` or `.nav`
- `main` for main content, not `.main-content` or `.container`
- Use `tag.nav`, `tag.main`, `tag.article` etc. — no wrapper divs with classes unless structurally necessary
- Rails `tag` helper (tag.div, tag.span) preferred over `content_tag`; `class_names` for conditional classes
- In ERB views: no `class:` arguments on links unless the class carries genuine semantic meaning (e.g. `.brand`, `.btn`, `.badge`)---
name: Importance-ordered file layout
description: Every file's lines flow by importance — newspaper inverted pyramid. Most important content at top.
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
Every file I touch gets reordered so the most important content sits at the top. Newspaper-style inverted pyramid.
**Why:** A reader who stops halfway must still have the gist. The user explicitly asked for this on 2026-05-07 ("every file must have all its lines rearranged to flow by importance so most important stuff comes top"). Encoded into `MASTER/data/ruby_style.yml` (`line_order:` section) and `MASTER/data/sweep_prompts.yml` (`IMPORTANCE_ORDER` structural technique) so MASTER's auto-triad propagates the rule.
**How to apply:**
- Order: requires → module/class declaration + headline doc → public API (ordered by importance/call-frequency) → primary algorithm → private helpers (in dependency order) → constants/tables → edge-case handlers/rescues.
- Applies to ruby, yaml, erb, js, css, html, sh, md — not just Ruby.
- When editing any file, even for a small change, briefly check if the surrounding region needs reordering. Don't rearrange just to rearrange — but if the file is already inverted (helpers at top, public API at bottom), fix it as part of the touch.
- The Maintainer and Layperson council personas evaluate this. Sweep enforces via `IMPORTANCE_ORDER` and `RECOMMENT`.---
name: Mandatory lint/beautify on touch
description: Every file edited must be linted and beautified — not just the target lines
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Run a lint/beautify pass on every file you touch, not just the specific lines changed.
**Why:** User instruction: "mandatory lint / beautify of everything it touches"
**How to apply:** After any edit to a Ruby/Zsh/JS/HTML file, apply style fixes to the whole file: consistent spacing around operators, no double blank lines, use defined constants instead of magic literals, align related assignments if the file already does so. Verify syntax after.---
name: MASTER zsh discipline applies to my session shell
description: When working on MASTER (or any project where MASTER's constitution applies), avoid the banned external commands in my own Bash tool calls — not just in scripts I write
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
The zsh-banned-commands list in MASTER (`sed`, `awk`, `tr`, `grep`, `cut`, `head`, `tail`, `find`, `wc`, `sudo`, `perl`, `ruby` invoked from zsh, `dd`, `xargs`) applies to commands I run via the Bash tool too, not only to scripts I write into the repo.
**Why:** MASTER is a constitutional agent and the operator expects me to live by the same constitution while editing it. Reaching for `wc -l` or `sort | tail` to inspect repo files signals that I do not actually use what I preach. Caught 2026-05-05.
**How to apply:** When inspecting MASTER (or any sibling pub4 project) over Bash:
- Read a file → `cat file` (prefer over grep/head/tail fragments — user reinforced 2026-05-06: "instead of grep and head just cat"). Read the whole file once instead of stitching snippets together.
- File line counts → zsh array: `lines=("${(@f)$(<file)}"); print ${#lines}` — or `print -l file*(.oL[1,N])` for size-sorted listing
- Largest N files → glob qualifier with size sort: `print -l **/*.yml(.oL[1,20])`
- Search content (when actually searching, not reading) → use the Grep **tool**, never shell `grep`/`rg`
- Find files → use the Glob **tool**, never shell `find`
- Privilege → `doas`, never `sudo`
- Complex parsing → write a Ruby script and run it, never inline `sed`/`awk`
The exception that already holds: `git`, `gh`, `bundle`, `ssh`, `scp`, `sshpass`, `eval`, plain `ls`, `mkdir`, `cd`, `print`, `echo`, parameter expansion. Those stay fine.---
name: User favors meta-architecture framing over change-by-change reports
description: After a batch of work, surface what's next/missing/structurally off — exploratory questions outperform itemized diffs
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
After landing a batch of work, surface the meta-question — what shape, what's missing, what's structurally off — instead of itemizing the diff.
**Why:** This user repeatedly asks "ways to...", "could X benefit...", "are we missing...", and explicitly favors 2x architectural wins over 5% incremental fixes. They read the diffs themselves; they want me using context to spot shape misfits, format shifts, and consolidation opportunities. They land everything in batch with "yes" / "land all" / "sweet, finish backlog next" — proof that exploratory follow-ups (not summaries) keep momentum.
**How to apply:**
- After a multi-commit batch, end with one meta-question (what to consolidate next, what's drifting, what could take a different shape) — not a list of what changed.
- When asked "are we missing X?", give 5-8 ranked candidates with payoff/risk, not a single suggestion.
- When the user says "land all", treat it literally — no per-suggestion confirmation, batch + commit aggressively, only break stride if syntax fails or the work needs the VPS.
- Pair violations with opportunities in any scan/audit reply — never just bugs.---
name: No multiple consecutive whitespace anywhere
description: Single space, single blank line max, no trailing whitespace — across Ruby, JS, CSS, HTML, YAML, shell, Markdown.
type: feedback
originSessionId: b02ce9b9-a7c7-4c65-b8d0-3b8469dc2028
---
Multiple consecutive whitespace is forbidden across all file types. Applies to:
- Two or more spaces in a row mid-line — one space only (no aligned-column padding like `@foo = 1`)
- Two or more blank lines in a row — single blank line max between sections
- Trailing whitespace at line end
- Indentation beyond level (no double-indent for visual alignment)
**Why:** Stated by user 2026-05-07 during lofi pass 1 session. Tightens "ultra-minimalistic coding style" and the Strunk & White principle: omit needless characters, not just needless words. Aligned-column padding is filler.
**How to apply:** When editing a file, collapse runs of spaces and blank lines as part of the lint/beautify-on-touch pass. When writing new code, never align `=` or values with extra spaces; never leave two blank lines between methods. CSS one-liners fine; CSS multi-line fine; tabular alignment via spaces not fine.---
name: no new files without approval
description: Never create new files — always edit originals in place
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Always edit the original file directly. Never create intermediate files (local staging files, _fixed.rb copies, tmp patches) without explicit approval.
**Why:** User explicitly said "write changes back into original files don't create new files ever without approval."
**How to apply:** Use Edit tool on the actual file path, or write patch Ruby to /tmp on the VPS and run it in-place — but never create a local copy. The /tmp/patch.rb VPS pattern from CLAUDE.md is fine since it's a transient runner, not a persisted file.---
name: No permission questions for predictable yes
description: Skip "do you want me to..." prompts when the answer is obviously yes given context
type: feedback
originSessionId: 0c593fb2-cd49-4fd7-9e89-d77dd7e909ae
---
Don't ask "should I continue?", "want me to ship next?", "shall I start with X or Y?" when the user's prior approval, autoproceed memory, or task framing makes the answer obvious.
**Why:** User has standing autoproceed authorization (feedback_autoproceed) and decisive-signals authorization (feedback_decisive_signals). Asking for re-confirmation per step is wasted turns and breaks flow.
**How to apply:** After one approval ("yes", "ship", "go", "do it", "start"), execute the full backlog. Surface trade-offs and checkpoints as statements ("shipping #1 next, ETA 10 min"), not questions. Only ask when there's a genuine fork that the user can't predict — e.g., destructive action, ambiguous scope, or a real either/or where both are reasonable.---
name: no python
description: Never use Python — only Ruby for scripting tasks
type: feedback
originSessionId: 5a5097b9-8cd5-46a3-913f-b193da929311
---
Never use Python for any task. Use Ruby exclusively for scripting, data processing, encoding, etc.
**Why:** User explicitly said "no python. only ruby." Reinforced again this session.
**How to apply:** Replace any python3/python one-liners with ruby equivalents. Use `ruby -e` or write to /tmp/*.rb on VPS. Do not even test-invoke python3 as a fallback before trying Ruby.---
name: No sed — use ruby
description: Never invoke sed in shell commands; use ruby for any text substitution
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
Never call `sed` (or awk/grep-with-rewrite) for text edits. Use ruby instead — `ruby -e`, `ruby <<RB`, or a `.rb` script.
**Why:** OpenBSD sed is BSD-flavor and behaves differently from GNU sed (no `-i ''` semantics, different regex flavors, no extended-mode without `-E`). Scripts written against GNU sed silently break on the dev@brgen.no VPS. Ruby is portable and the project's primary language.
**How to apply:** any time I'd reach for `sed -i 's|x|y|'`, write `ruby -E UTF-8:UTF-8 -e 'File.write(p, File.read(p).sub("x","y"))'` instead. Same for awk one-liners — use ruby. The earlier ban-list (sed/awk/grep/wc/head/tail/find) already covers this; this memory exists because I slipped once during Wave B heredoc fixes.---
name: No unnecessary piping/concat in shell calls
description: Avoid pipe chains and string concat in Bash invocations; prefer pure Ruby or pure zsh
type: feedback
originSessionId: 0c593fb2-cd49-4fd7-9e89-d77dd7e909ae
---
When invoking Bash, do not pipe through `head`/`tail`/`grep`/`wc` etc. or stitch with `&&`/`;` chains where a single Ruby/zsh idiom does the job.
**Why:** matches the banned-shell-commands rule already in `data/rules.yml` (sed/awk/grep/find/head/tail/wc/sudo). Same discipline applies to my own tool calls, not just to scripts I write. User explicitly called this out as noise — it makes prompts hard to read and audit.
**How to apply:**
- File reads → use Read tool, not `cat | head`.
- Searches → use Grep tool, not `grep`.
- Single-step shell ops → run them directly; do not chain when sequential calls would be clearer.
- For Ruby work, prefer a one-liner `ruby -e '...'` over zsh-glue.
- For zsh, use builtin parameter expansion / globs / arrays, not pipes to coreutils.---
name: Proper casing, no ASCII decorations
description: Sentence case in prose, comments, CLI, commit messages; no ===, originSessionId: b02ce9b9-a7c7-4c65-b8d0-3b8469dc2028
---
-, [ok], •, |, › as ASCII art.
type: feedback
applies_to: prose, comments, CLI output, commit messages, log lines, section headers
---
Use proper casing in prose, comments, log lines, CLI output, and commit messages. Capitalize sentence starts, proper nouns, and acronyms. Snake_case identifiers stay as-is.
Commit messages: capitalize the first word of the subject line. `Kill cli tts; web is sole audio path` not `kill cli tts; web is sole audio path`. Body paragraphs follow normal sentence-case rules.
Never use these as ASCII art decorations:
- `===` or `----` (banner lines, section dividers)
- `[ok]` `[err]` `[skip]` (status tags — use `ok:` `err:` `skip:` prefix instead)
- `•` `|` `›` `‹` (bullet/separator characters in CLI text)
**Why:** Refines the prior dmesg/terse-voice rule. Lowercase-only feels sloppy in human-facing surfaces; ASCII decorations are visual noise the user explicitly disliked. Real dmesg uses lowercase because kernel space is constrained — MASTER isn't, so prose, CLI output, and commit subjects should read like written English. Commit-msg rule added 2026-05-07 after a lowercase commit subject slipped through.
**How to apply:**
- Comments: `# Restore HTML/CSS/typography sections` not `# restore html/css/typography sections`
- CLI output: `Wired /why to local lookup; LLM fallback only on miss.` not `[ok] /why now uses WhyExplainer first`
- Log lines: `Boot scan: 1678 violations (45s)` not `boot scan: 1678 violation(s)`
- Commit subjects: `Add foo`, `Fix bar`, `Refactor baz` — first word capitalized
- Section headers in YAML/code: drop `===== HEADER =====` style; use a single `#` line if needed
- Status indicators: `ok:` `err:` `warn:` as bare prefixes, never `[ok]`
- Bullet content in CLI: dash + space (`- item`) is fine; never use `•`
**Tension with dmesg style:** dmesg conventions apply ONLY to *kernel-style structured output emitted by MASTER itself* — the boot banner, event log lines, status pings (`master@host ready`, `boot0: 26ms`). The MASTER boot banner is explicitly sacred (user 2026-05-06: "dont remove boot message on startup, its awesome, and should remind of openbsd dmesg").
Do NOT use dmesg style for my own conversational prose to the user (clarified 2026-05-06: "dont use dmesg style for conversing prose"). When narrating progress in chat, write plain English sentences with proper casing. dmesg style is for log lines MASTER writes, not for me speaking to the operator.---
name: Auto-update README.md when needed
description: After any meaningful change to MASTER's behavior or capabilities, update README.md without prompting
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
When a commit changes MASTER's user-facing behavior, capabilities, command surface, philosophy, or stack, update README.md in the same or a follow-up commit without waiting for the user to ask.
**Why:** Stated explicitly on 2026-05-05. README is the single front door — drift between it and the code degrades trust. The user prefers the doc to lead, not lag.
**How to apply:**
- After landing depth flips, rule additions, workflow changes, persona changes, scan/sweep semantics changes, model routing changes, or any new top-level concept (Six Laws, biases, structural_ops, etc.) — refresh the matching README paragraph.
- Refresh = update the prose, not append a changelog entry. Keep README's flowing-prose / Strunk & White / Bringhurst form (no h2/h3, no tables, no code blocks unless essential).
- Bundle the doc update with the code commit when small; split into a follow-up if the doc change is substantial.
- Skip auto-update only for trivial bugfixes that don't change observable behavior.---
name: Restart MASTER service after every web/* edit
description: Whenever I update any file under MASTER/web/ on the VPS, restart the master rc.d service so the change takes effect; do not batch updates and restart at the end
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
After every scp of any file under `MASTER/web/` (controllers, views, initializers, config), immediately run `doas rcctl restart master 2>&1` on the VPS before moving on. Falcon does not hot-reload code in production mode — without a restart, the deployed app still serves the prior bytecode and the user sees stale behavior.
**Why:** User correction 2026-05-06 ("restart the rails app every time you update it"). I had been batching multiple web edits and restarting once at the end, which left the user staring at unchanged behavior between scps.
**How to apply:**
- Edit one web file → scp → `doas rcctl restart master` → next edit, even if more edits to that same file are coming.
- Allow ~2 seconds after restart before any verification curl, since Falcon cold-starts the container.
- Lib edits (`MASTER/lib/`) follow the same rule when they're in the live require path.
- Data file edits (`MASTER/data/*.yml`) load at boot too — restart for those as well.
- CLI-only changes (`exe/master`, `lib/master/cli/*`) don't need a restart unless the operator is also using the web surface.---
name: "Run X through MASTER" = scan + sweep + tribunal
description: User shorthand — "run X through master" means /triad = scan + sweep + tribunal (called "council" in code, "tribunal" in user vocabulary), not just /scan
type: feedback
originSessionId: 0c593fb2-cd49-4fd7-9e89-d77dd7e909ae
---
When the user says "run X through MASTER" (or "expose X to MASTER", "MASTER on X"), default to /triad — scan, sweep to convergence, then tribunal deliberation. Tribunal = the council deliberation pass with the 6 personas and Security veto.
Why: User confirmed "yeah when user says run this or that through master, then a triad is what i expect" → "/scan+sweep+tribunal" (2026-05-08). User uses "tribunal", code uses "council" — same thing.
How to apply: For any directive "run/scan/process X through master" where X is a path or codebase, invoke `/triad deep <path>`. Bug to flag: current /triad's third step calls the council META command (just on/off toggle) instead of running an actual deliberation.review — surface this when relevant.---
name: Strunk & White style
description: All code output — commits, comments, log messages, CLI output — must follow Strunk & White principles
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Apply Strunk & White to every written artifact: commits, comments, log messages, CLI prompts, error messages.
**Why:** User mandate for all text output from and about MASTER.
**How to apply:**
- Active voice: "Fix bug" not "Bug was fixed"
- Omit needless words: "extract Search module" not "perform extraction of Search module functionality"
- Concrete nouns and verbs: "scan", "fix", "load", "route" — not "process", "handle", "manage"
- One idea per sentence
- Commit messages: imperative mood, ≤72 chars, no trailing period
- Comments: state the WHY only, not the WHAT — one line max
- dmesg log lines: `component: action key=val key=val` (no commas, no padding)---
name: ultra-minimalistic coding style
description: Always write ultra-minimalistic code in all languages — no redundancy, no filler
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Always use ultra-minimalistic coding style across all languages (Ruby, Zsh, HTML, JavaScript, etc.) — no filler, no redundant logic, no ceremonial patterns. Intentional and valuable logic is preserved; everything else is cut.
**Why:** User explicitly requested this style universally.
**How to apply:** Shortest correct form always. No defensive over-engineering, no padding, no comments explaining the obvious. One expression where one expression suffices.
Additional standards enforced on all files:
- Strunk & White: active voice, omit needless words, concrete verbs
- Ruby community style guide (https://rubystyle.guide)
- Rails style guide where applicable
- Always 2-space indents; always double quotes for strings
- No abbreviated identifiers — spell words in full (e.g. `temporary_path` not `tmp`, `index` not `idx`, `number` not `num`, `configuration` not `cfg`, `context` not `ctx`)
- No regex when plain string matching suffices (keyword arrays with `start_with?` over regex patterns)
- Outsource logic to gems when a well-maintained gem does it better (e.g. flay for dup detection, reek for smells)---
name: Voice — terse, unix-like, perfectionist
description: User's preferred voice/tone for MASTER and for my own outputs — terse, unix-like, perfectionist
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
Voice and personality direction: **terse, unix-like, perfectionist**.
**Why:** User stated this directly when we were mining old master.yml versions for voice/persona ideas. Aligns with the dmesg style, OpenBSD heritage, Strunk & White prose, and the v31 zen interface (wabi-sabi, ma, kanso). The user is an architect — perfectionism is his default mode.
**How to apply:**
- My own responses: cut filler ruthlessly, output diagnostic-style updates (single-line where possible), refuse "great question" / "let me explain" / sycophantic preludes, no padding.
- MASTER's voice config (data/voice.yml or equivalent): when polishing or proposing voice changes, anchor to terse + unix + perfectionist. Avoid corporate, friendly, conversational, or verbose registers.
- Perfectionism means: zero violations as the target, fixed-point convergence, not "good enough." Loop until clean.
- Unix-like means: do one thing well, silence on success, exit codes carry meaning, text in/out, composable.---
name: pub4 defrag/dedup/rename plan (2026-05-07)
description: Multi-commit refactor plan from a sister chat — collapse duplication across docs, shrink data/, flatten repo root, rename for clarity. Priority-1 patch is Master::Orient + slim docs + .zshrc fix.
type: project
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
User shared a full defrag/dedup/rename proposal on 2026-05-07 covering:
1. **Single source of truth** — banned commands, voice rules, ASCII-art ban, house rules currently duplicated across AGENTS.md / CLAUDE.md / data/*.yml. Move each fact to one yml file; prose docs reference, never restate.
2. **data/ shrinks 11 → 8 files** — merge `council.yml`+`council_patterns.yml`, merge `infer_patterns.yml`+`sweep_prompts.yml`+`zsh_patterns.yml` → `patterns.yml` (namespaced).
3. **Top-level shrinks 26 → 10 entries** — fold `pub`/`pub2`/`pub3`/`railsy` into `__predecessors/`, merge `mix/`+`multimedia/`+`.mp3/` → `audio/`, merge `sh/`+`scripts/`+`bp/` → `scripts/`, static HTML → `web/`, rename `:memory:/` → `memory/`.
4. **Renames** — `MASTER/DEPLOY/openbsd/openbsd.sh` → `MASTER/deploy/openbsd.sh`; `data/standing_orders.yml` → `state.yml`; `workflow.yml` → `limits.yml`; `rules.yml` → `voice.yml`; `ruby_style.yml` → `style.yml`. CONVENTIONS.md either generated to tmp/ or deleted.
5. **Smoothing** — `master orient` command replaces five-cat bootstrap. Stash before `git reset --hard`. Replace `Thread.current[:master_visitor]` with explicit `scope:` arg on `Master.build`. Unify two `Result` impls (the `respond_to?(:ok?)` smell). Pipeline per-stage budget in `limits.yml`. Reconcile `Guard` stage with auto-approve. Unify `exe/master` boot paths (rcd + ssh-autostart). Generalize WhyExplainer's local-lookup-then-LLM pattern.
**Priority-1 patch (drop-in code provided):**
- `MASTER/lib/master/orient.rb` — 35 LOC, prints all five bootstrap yml files
- Slimmed `AGENTS.md` (46 → 27 lines) and `CLAUDE.md` (238 → ~85 lines) — delete duplicated constitution, point at `/orient`
- CLI dispatch: add `/orient` slash branch and `orient` subcommand
- `~/.zshrc` top: `[[ -o interactive ]] || return` + `[[ -t 0 ]] || return` to fix non-interactive SSH stealing stdin
- Commit message provided: "master: collapse five-cat bootstrap into orient"
**Why:** Reduce drift (one fact = one place), reduce friction (one command vs five cats), shrink visual surface so the repo reads in one screen. Each move is independently shippable.
**How to apply:** Treat priority-1 as the next reversible commit when user greenlights. Treat the broader plan as a sequence of small commits — never bundle. The smoothing items (#3-#9 of execution path) are individual follow-up tickets.---
name: Falcon Async + EventMachine = subprocess pattern
description: EM-based gems (rb-edge-tts, em-http) inside Falcon request handlers must shell out to a subprocess; Process.fork and direct EM.run both fail
type: project
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
Falcon uses Async/io-event fibers. Calling `Process.fork` from a request fiber raises `RuntimeError: Closing scheduler with blocked operations!`. Calling `EventMachine.run` directly conflicts with Falcon's reactor (silent hang or premature scheduler close).
**Why:** Async's fiber scheduler tracks open fibers across fork boundaries; EM owns its own reactor that can't coexist with Async's in the same process. We hit this twice: in `Master::Speech.synthesize_edge` (rb-edge-tts) and would hit it for any `em-*` gem.
**How to apply:** When wiring an EM-based gem into a Falcon controller, write a small `exe/<name>-worker` Ruby script that does the EM work and writes output to a tempfile. Call it via `Open3.capture3` from the controller path. Reference: `MASTER/exe/tts-worker` + `MASTER/lib/master/speech.rb#synthesize_edge`.---
name: MASTER project context
description: pub4/MASTER — constitutional AI coding agent on OpenBSD VPS dev@brgen.no
type: project
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
VPS: dev@brgen.no (185.52.176.18), OpenBSD 7.8, 1GB RAM, passwordless doas.
SSH: `sshpass -p '<pass>' ssh -o StrictHostKeyChecking=no dev@185.52.176.18 'cmd'`
Password changes each session — check CLAUDE.md for current.
Codebase: ~/pub4/MASTER/ — Ruby ~6K LOC, Zeitwerk-autoloaded.
**Why:** MASTER is a constitutional AI coding agent that replaces Claude Code CLI. Runs on OpenRouter (default: nvidia/nemotron-3-super-120b-a12b:free) via `ruby_llm` gem. Fallback chain: qwen3-coder:free → minimax-m2.5:free → gpt-oss-120b:free → gemini-2.0-flash.
**How to apply:** All coding work must be done directly on the VPS via sshpass SSH. Never use local tools to edit VPS files — write patch scripts to ~/pub4/tmp/patch.rb and run with ruby. Use zsh builtins only — no sed/awk/grep/find/head/tail.
Pipeline: Intake → Infer → Route → Guard → Execute → [Council ‖ Lint] → Prune → Memo → Render
Pipe mode: `echo "cmd" | bundle exec ruby exe/master`
Session Startup: read data/standing_orders.yml, data/workflow.yml, data/rules.yml, data/models.yml
Web UI: Rails 8 + Falcon on port 10002, proxied by relayd → ai.brgen.no:3000/4430---
name: MASTER has two Gemfiles
description: MASTER/Gemfile (CLI) and MASTER/web/Gemfile (Falcon web) are independent — adding a gem to one does NOT make it available in the other
type: project
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
`MASTER/web/Gemfile` declares `gem "master", path: ".."` which loads master via gemspec, NOT via the parent Gemfile. Runtime gems used inside `MASTER/lib/` from the web process must be declared in BOTH `MASTER/Gemfile` AND `MASTER/web/Gemfile`, or in `master.gemspec` as a runtime dep.
**Why:** Bundling them once in MASTER/Gemfile leaves the falcon process unable to require the gem at runtime — the request handler raises LoadError silently and the controller's rescue returns 503. This burned an hour debugging rb-edge-tts that worked in CLI but failed in /chat/tts.
**How to apply:** When adding a gem touched by lib/master/* code that the web app calls, edit both Gemfiles. Run `bundle install` in both `MASTER/` and `MASTER/web/`. Verify by reading `MASTER/web/Gemfile.lock` for the gem name.---
name: MASTER 7-module refactor (approved 2026-05-08)
description: User approved collapse of lib/master/ into 7 time-oriented modules — now/loop/judge/voice/ground/reach/trace. Multi-commit, pass-by-pass; ship on VPS dev@brgen.no.
type: project
originSessionId: 0c593fb2-cd49-4fd7-9e89-d77dd7e909ae
---
User approved the radical 7-directory tree on 2026-05-08 ("i approve of all your suggestions") after the /triad runs on lib/ and DEPLOY surfaced the duplication patterns.
Target tree (lib/master/):
- now/ cli, repl, pipeline executor — synchronous user turn
- loop/ autoloop, sweep, heartbeat, convergence — async background
- judge/ scan/rules, council, swarm, security — verdict passes (unified Verdict shape)
- voice/ personality, soul, renderer, speech — output identity
- ground/ config, axioms, data/*.yml loaders — read-only constitution (Constitution aggregator)
- reach/ tools/base + 24 tools — actions on world (Tools::Base DRYs boilerplate)
- trace/ session, telemetry, bus, undo — write-only history
This subsumes the older 6 dedup proposals (Constitution aggregator, Tools::Base, deliberation unification, refactor cycle, Security::Policy, Voice namespace).
Pass sequence (one commit per pass, must keep tests green and Zeitwerk loading):
1. Skeleton — create empty dirs + README pointers
2. voice/ — move personality, soul, speech, renderer; update inflector + requires
3. trace/ — move session, telemetry, bus, undo
4. ground/ — move config, axioms, YAML loaders; introduce Constitution aggregator
5. reach/ — move tools, introduce Tools::Base, collapse 24 tool boilerplates
6. judge/ — move scan, council, swarm, security; introduce shared Verdict shape
7. loop/ — move sweep, autoloop, heartbeat, convergence
8. now/ — move cli + collapse stages/ into pipeline-as-data executor
Why: 100+ files split by file-type/domain are slicing the codebase against its own grain. Time-orientation (now vs loop vs trace) makes pledges/unveil and concurrency reasoning structural. judge/ unifies four trees that all answer "is this OK?" but reinvent the verdict shape.
How to apply: Work on VPS dev@brgen.no (memory: no heavy work on device). Create branch `refactor/seven-modules` off main. Each pass is one commit; if a pass breaks Zeitwerk or specs, fix in the same pass before moving on. Tradeoff accepted: stages/ disappears as directory — pipeline becomes a ~150-line lambda table inside now/pipeline.rb, losing per-stage class affordance for tests but collapsing 12 files.---
name: master.yml + master.json are the constitutional source of truth
description: Current Ruby MASTER must implement what the predecessor master.json (v43.0.1) and master.yml (v31, v49.75, etc.) describe — close the drift
type: project
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
The user's directive: **"MASTER should do what master.yml and master.json describes."**
The current modular Ruby MASTER has drifted from the constitutional intent of its YAML/JSON predecessors. The predecessors are the authoritative spec for behavior; the Ruby is just the executor.
**Why:** Stated explicitly on 2026-05-05 after we mined the deleted master.json (Nov–Dec 2025, 130 commits) and master.yml (Dec 2025–Feb 2026, 668 commits) histories. The user wants behavior-spec parity, not just rule-set parity.
**Key gaps to close (priority order):**
1. **Six Universal Laws ladder** (ROBUSTNESS→SINGULARITY→LINEARITY→PROXIMITY→ABSTRACTION→DENSITY) — single hierarchical priority system; every rule and persona anchored to one law.
2. **Strunk & White safeguards** — `apply_to: [prose, comments, documentation, strings]`, `never_apply_to: [code_logic, algorithms, data_structures]`, `never_delete_variable_names / never_delete_function_calls / never_simplify_conditional_logic`. Prevents lossy compression.
3. **biases section** — hallucination, simulation, completion_theater, sycophancy, false_confidence + cognitive traps as concrete detectable rules with regex patterns.
4. **structural_ops taxonomy** — merge/semantic_regroup/defrag/decouple/hoist/flatten/delete/expand/reduce_noise, each with risk + verify + supports_law.
5. **8-phase workflow with introspection + learn phase** (discover→analyze→ideate→design→implement→validate→deliver→**learn**), introspect question per phase. Closes the project orchestrator / spec planner gap.
6. **patterns.veto regex detectors** (secrets, sql_injection, unfinished, unsafe_calls, race_conditions) and patterns.high (future_tense, sycophancy, magic_numbers, deep_nesting).
7. **Adversarial: 5 questions per violation; solution generation: 5-15 solutions, early exit on quality** — currently makes 1 fix per file.
8. **Fixed-point convergence: silence in 2 consecutive runs** — currently 1 cycle.
9. **Incremental scanning** — only modified files when not user-triggered; full-scan triggers = new_principle_added / master_yml_modified / user_requests_full_scan. (60-85% faster.)
10. **Prediction engine with confidence thresholds** — per-detector autofix mappings (null_usage 0.95→null_object, abbreviation 0.99→expand, nesting 0.92→extract_method).
11. **12 weighted personas** for council with `w:`, `q:`, `emphasizes: [LAWS]`. Veto rights to [security, attacker, maintainer].
12. **SHA256 evidence logging** — `Read {file} (sha256: {hash}, {lines} lines)` for every read/write.
13. **Beauty section** — Bringhurst typography, Ando architecture, Rams design, Martin code as aesthetic anchors.
14. **preserve: section** — protect boot dmesg, diagnostic output, help text from over-simplification.
15. **OpenBSD per-config validators** — pf.conf, sshd_config, httpd.conf, nsd.conf, smtpd.conf with required_patterns and warnings.
16. **Tech stack constants** — LCP 2.5s, INP 200ms, CLS 0.1, WCAG_AA 4.5 contrast, 24px touch targets, 66ch line length.
17. **Cost guards** — max_per_file: $1, max_per_session: $10, warn_at: $0.50.
18. **Per-language generation templates** — HTML/CSS/Ruby/sh/yml starter templates.
**How to apply:** Treat closing these gaps as the primary backlog. Execute in priority order; each is independently commit-able. The user is an architect — aesthetic items (Six Laws naming, beauty section, zen interface, voice) are first-class, not nice-to-have.---
name: Grok-inspired UI/CLI patterns (chatlog dump 2026-05-07)
description: Reference dump from a sister chat — StyleCoach UI prompt, htmx+SSE streaming, tty-prompt/tty-spinner advanced features, multi-line editor, character-stream LLM CLI. For MASTER's web UI and CLI polish.
type: reference
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
User shared a long Grok-style design conversation on 2026-05-07. Key reusable patterns:
## StyleCoach UI persona (LLM prompt)
Critique persona that evaluates MASTER's own output (CLI sessions, web partials, screenshots via vision).
Rules: *"interface should disappear; only the conversation should remain. Zero visual debt. Personality lives in words/spacing/timing, never in UI flourishes. Speed > everything. Mobile-first, dark-mode default. Every element earns its existence or it dies."*
Output format: `ELEMENT: ... / Current: ... / Suggested: ... / Reason: ...` and a final `distilled_ui_lesson` tag.
Distilled-rule examples: "If the user can see more than two accent colors, you have failed." "Spinners longer than three dots are crimes against humanity." "The prompt bar belongs at the bottom — always — like breathing."
## Streaming patterns (web UI)
- **htmx + SSE.** `<div hx-ext="sse" sse-connect="/stream/:id" sse-swap="chunk">`. Server writes `event: chunk\ndata: <span>...</span>\n\n` per token. Set `X-Accel-Buffering: no` for nginx/passenger. Sleep `rand(0.02..0.08)` for human-typing feel.
- **Chunked HTTP (no SSE ext).** `hx-trigger="load" hx-swap="innerHTML"`; server sets `Transfer-Encoding: chunked` and writes `CGI.escapeHTML(token)` per chunk. Zero extra JS.
## CLI polish — Grok-borrowable traits
1. Stream answers character-by-character via ANSI + `\r` overwrite of `Thinking…` line.
2. Terse happy path — no mandatory flags for 90% of cases.
3. Subtle personality on success and failure ("You had 7 nested conditionals. I removed them. You're welcome." / "Looks like you closed something you never opened.").
4. Stateful context across invocations without `--session` flag (tiny SQLite or `~/.master/context.json`).
5. Visual feedback >1.5s = braille spinner `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`. No emoji spinners, no rainbow bars.
6. One-command install + instant usefulness.
## tty-prompt advanced features (when MASTER CLI evolves)
- `select(filter: true)` — fuzzy-search list. `enum_select` — auto-complete predefined.
- `multi_select(per_page:, cycle:, symbols: { marker: "✔" })` — tag/category picking.
- `expand` — git-style `[(Y)es, (n)o, (a)ll, (q)uit]` compact menu.
- `editor("...", syntax: :markdown, word_wrap: 78, editor: ENV["EDITOR"])` — multi-line input via $EDITOR. Returns nil on cancel.
- `mask("API key:", required: true) { |q| q.confirm true }` — password input.
- `slider(min:, max:, step:, format:, active_color:)` — numeric tuning.
- `ask` validators: `q.in "18..120"`, `q.validate(/regex/)`, `q.convert :int`, `q.modify :strip, :downcase`.
- Theme: `TTY::Prompt.new(active_color: :bright_cyan, symbols: { marker: "❯", radio_on: "◉" })`.
## tty-spinner formats
Built-ins worth using: `:dots_8`, `:dots_9` (smooth braille — recommended default), `:spin`, `:simpleDotsScrolling`. Custom: `{ interval: 6, frames: %w[♥ ♡] }`. Multi-spinner: `TTY::Spinner::Multi.new` registers child spinners for parallel tasks. Always `hide_cursor: true`.
## Multi-line editor + Claude streaming (full example pattern)
`PROMPT.editor(...)` for multi-line, then `Anthropic::Client#messages.stream(stream: true) do |chunk|` → write `chunk.dig("delta","text")` char-by-char with `sleep(rand(0.008..0.035))`. Maintain `@history` array across turns. Spinner during pre-stream `Thinking…` then `.stop` before first byte.
## How to apply for MASTER
- MASTER's existing web UI (Rails 8 + Falcon, port 53187, two-tier auth) already streams via `POST /chat/message (SSE)`. Cross-check against the htmx+SSE pattern above.
- CLI REPL (`exe/master`) currently streams via `chunk_accumulator` + `print` — already char-stream-ish. Could borrow the `Thinking…` cleanup, the menu-on-ambiguity (`needs_clarification?`), and `tty-prompt editor` for the `<<` multiline mode.
- StyleCoach as a `/crit` persona variant is wired but could ingest screenshots via vision tool.
Don't bulk-import. Cherry-pick when a specific MASTER edit calls for it.---
name: OpenCrabs (Rust MASTER cousin)
description: github.com/adolfousier/opencrabs — Rust/Ratatui TUI agent, philosophical cousin of MASTER. Solo author, 5 stars, MIT. Worth-stealing patterns listed.
type: reference
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
**Repo:** github.com/adolfousier/opencrabs · docs.opencrabs.com · 34 MB binary, 57 MB RSS, latest v0.2.15 (Feb 2026), Cargo nightly required (2024 edition + `portable_simd`).
**Architecture:** TUI/CLI → Brain → Services → SQLx/SQLite → LLM providers. Layered Rust + Tokio + Ratatui. Linux/macOS/Windows; no OpenBSD support, no `pledge`/`unveil`.
**Patterns worth stealing for MASTER:**
1. **Brain-files re-read every turn.** System prompt assembled per turn from workspace MD files (SOUL/IDENTITY/USER/AGENTS/TOOLS/MEMORY/SECURITY/BOOT/HEARTBEAT). Edit between turns → effect immediate, no rebuild. Same shape as MASTER's `data/*.yml`.
2. **FTS5 BM25 memory search via existing SQLite.** Zero new deps, ~0.4ms/query. Ruby equivalent: `sqlite3` gem + FTS5 — free.
3. **Inline compaction summary.** When auto-compaction fires at 70% ctx, summary written to chat AND daily log so user sees what was kept. Transparency over magic.
4. **`/rebuild` + Unix `exec()` hot-restart.** Self-edit → `cargo build --release` async → `ProgressEvent::RestartReady` → `exec()` swap → resume session via SQLite. No context loss.
5. **Sub-agent tool exclusion list.** `spawn_agent`/`rebuild`/`evolve` ALWAYS_EXCLUDED from sub-agents — prevents recursive self-mod.
**Risks visible in their changelog (don't blindly copy):** RSI runs without human approval (writes to `~/.opencrabs/rsi/improvements.md`); README admits agent hallucinates tool calls in corrupted sessions ("fix coming"). Bus factor 1, pre-traction.
**Don't conflate:** `mo-vic/OpenCrab` (singular, fine-tuning distillation, unrelated) and the empty `opencrab` org are different projects.---
name: User is an architect; aesthetic proposals usually pass
description: User's primary discipline is architecture; treat typography, layout, design-philosophy proposals as in-scope and likely approved
type: user
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
User is an architect by training and primary discipline. Aesthetic, typographic, and design-philosophy proposals — even ones that LLMs typically dismiss as garbage, irrelevant, or "out of scope for code" — are usually approved by this user.
**How to apply:**
- When mining for ideas (e.g. master.yml history), surface and propose aesthetic content (Bringhurst typography, Ando architecture, Rams design, wabi-sabi/ma/kanso, beauty sections) without self-censoring.
- Don't bury these under "philosophical fluff" disclaimers; treat them as legitimate engineering anchors.
- When polishing UI, prose, or config, propose aesthetic refinements actively rather than waiting for the user to ask.
- Don't argue against bringing back beauty/zen/design-philosophy YAML sections on the grounds that they're "not actionable" — the user finds them actionable.# Random exit lines, dmesg-toned. One drawn at session close.
closings:
- "Signing off."
- "Link down."
- "Pledge locked."
- "Session flushed."
- "Buffers drained."
- "Halting."
- "Power down."
- "Standing down."
- "Out of band."
- "Watch ends."
- "Closed orderly."
- "Tx complete."
- "Returning to base."
- "All quiet."
- "End of stream."# Lexical compression — single-word fillers and bloated phrases the voice prunes.
# Source: master2 reunification. Applies to prose only (see rules.yml voice.strunk safeguards).
fillers:
- just
- really
- very
- quite
- rather
- somewhat
- basically
- actually
- literally
- simply
- essentially
- obviously
- clearly
- definitely
phrases:
"in order to": "to"
"due to the fact that": "because"
"at this point in time": "now"
"at the present time": "now"
"in the event that": "if"
"for the purpose of": "to"
"with regard to": "about"
"in spite of the fact that": "although"
"a large number of": "many"
"a majority of": "most"# Council personas — deliberation panel for code review decisions.
# Each persona carries a weight (sum = 1.00), a sharp question, and the laws it
# emphasizes from rules.yml. can_veto: true blocks merge unconditionally.
personas:
- name: Architect
aliases: [architect]
role: System Design
bias: Structure
weight: 0.07
question: "What couples too tight to evolve?"
emphasizes: [PROXIMITY, ABSTRACTION]
can_veto: false
prompt: Review architectural boundaries, coupling, interface shapes, and migration risk.
- name: Data Steward
aliases: [data_steward, data]
role: Data Integrity
bias: Consistency
weight: 0.06
question: "Where can the data go inconsistent?"
emphasizes: [SINGULARITY, ROBUSTNESS]
can_veto: false
prompt: Audit schema impact, migrations, data lineage, and source‑of‑truth consistency.
- name: Ethics & Policy
aliases: [ethics, policy]
role: Responsible Use
bias: Compliance
weight: 0.05
question: "Who could this harm if misused?"
emphasizes: [ROBUSTNESS, ABSTRACTION]
can_veto: false
prompt: Examine policy adherence, abuse potential, fairness, and governance implications.
- name: Maintainer
aliases: [maintainer]
role: Code Health
bias: Sustainability
weight: 0.12
question: "Will this be clear at 3am six months from now?"
emphasizes: [LINEARITY, SINGULARITY, DENSITY]
can_veto: true
prompt: Evaluate readability, naming, modularity, and long‑term maintenance burden.
- name: Performance
aliases: [performance]
role: Runtime Efficiency
bias: Throughput
weight: 0.07
question: "Where is the Big-O bottleneck?"
emphasizes: [DENSITY]
can_veto: false
prompt: Detect latency, memory, I/O, and algorithmic inefficiencies; suggest measurable optimizations.
- name: Product Strategist
aliases: [product, strategist]
role: Product Fit
bias: Value
weight: 0.04
question: "Is this worth shipping at all?"
emphasizes: [DENSITY]
can_veto: false
prompt: Verify alignment with product goals, success metrics, and roadmap leverage.
- name: QA Engineer
aliases: [qa]
role: Test Strategy
bias: Verification
weight: 0.08
question: "What evidence proves this works?"
emphasizes: [ROBUSTNESS]
can_veto: false
prompt: Locate missing tests, flaky patterns, and propose deterministic validation gates.
- name: Pragmatist
aliases: [pragmatist, realist, minimalist]
role: Delivery Pressure
bias: Shipping
weight: 0.07
question: "What can be deleted without loss?"
emphasizes: [DENSITY, SINGULARITY]
can_veto: false
prompt: Minimize scope while maximizing shippable value within realistic constraints.
- name: Reliability
aliases: [reliability, chaos]
role: Failure Engineering
bias: Resilience
weight: 0.10
question: "What is the cascade if the weakest link snaps?"
emphasizes: [ROBUSTNESS]
can_veto: true
prompt: Review retries, timeouts, degradation modes, idempotency, rollback safety, and worst-case cascades.
- name: Security
aliases: [security, attacker]
role: Security Review
bias: Safety
weight: 0.13
question: "Where are the injection vectors and exposed surface?"
emphasizes: [ROBUSTNESS, ABSTRACTION]
can_veto: true
prompt: Identify injection, privilege escalation, data‑exposure, and auth risks. Prefix VETO when unsafe to ship.
- name: Skeptic
aliases: [skeptic, absence]
role: Devil's Advocate
bias: Caution
weight: 0.10
question: "What did we miss? What evidence is required?"
emphasizes: [ROBUSTNESS]
can_veto: false
prompt: Challenge assumptions, enumerate failure paths, edge cases, brittleness, and missing gaps.
- name: User Advocate
aliases: [user_advocate, user]
role: UX Advocate
bias: Usability
weight: 0.06
question: "What would the user complain about first?"
emphasizes: [ABSTRACTION]
can_veto: false
prompt: Assess clarity, friction, error recovery, and overall user outcomes.
- name: Accessibility
aliases: [accessibility, a11y]
role: Inclusive Use
bias: Reach
weight: 0.05
question: "Can a keyboard-only or screen-reader user complete the task?"
emphasizes: [ROBUSTNESS, ABSTRACTION]
can_veto: false
prompt: Audit keyboard navigation, screen-reader semantics, contrast, focus order, and reduced-motion handling.
- name: Graphic Designer
aliases: [graphic_designer, designer]
role: Visual Composition
bias: Hierarchy
weight: 0.04
question: "Where does the eye land first, and is that what matters most?"
emphasizes: [PROXIMITY, DENSITY]
can_veto: false
prompt: Critique typographic hierarchy, whitespace economy, contrast, alignment, scale, and figure-ground relationships. Reject ornament that doesn't carry meaning.
- name: Web Designer
aliases: [web_designer, frontend_designer]
role: Browser-Native UX
bias: Idiom
weight: 0.04
question: "Does this respect the medium — fluid, responsive, keyboard-first, link-shaped?"
emphasizes: [ROBUSTNESS, ABSTRACTION]
can_veto: false
prompt: Evaluate semantic HTML, responsive behavior, link affordance, form ergonomics, viewport handling, and progressive enhancement. Flag div-soup, modal-overuse, JS-only interactions where HTML would do.
- name: Electronic Music Producer
aliases: [music_producer, producer]
role: Sonic Texture & Timing
bias: Groove
weight: 0.03
question: "Does the timing breathe, or is everything quantised to death?"
emphasizes: [DENSITY, SINGULARITY]
can_veto: false
prompt: Assess audio mix balance, frequency masking, transient handling, rhythmic feel, sound-design intentionality. For non-audio code, transfer the metaphor — pacing, layering, and silence in interactions.
- name: Layperson
aliases: [layperson, novice, fresh_eyes]
role: Outsider Comprehension
bias: Plain Speech
weight: 0.05
question: "If I'd never seen this code/UI before, what would confuse me first?"
emphasizes: [LINEARITY, SINGULARITY]
can_veto: false
prompt: Read as a non-expert. Flag jargon without glossary, unexplained acronyms, error messages that assume internals, UI labels that need a manual. The cure is plain words and obvious affordances.
# Council deliberation parameters. Source: master2 v4 reunification.
parameters:
consensus_threshold: 0.70 # weighted majority required to accept proposal
max_iterations: 25 # oscillation halt — forces explicit human decision
oscillation_detection: true
veto_precedence: [Security, Reliability, Maintainer] # order of veto evaluation
tie_breaker: Maintainer # weighted tie -> ops perspective wins
# Each persona gets 3 example utterances so the LLM mimics tone, not just emphasis.
# Source: master.yml v31 reunification (#59).
voice_samples:
Architect:
- "this couples #{x} to #{y} through a private method; extract a port"
- "the migration adds a new dimension to an axis that already has three"
- "the seam is clean here; promote it to an interface and we're done"
Security:
- "untrusted input crosses the boundary at line N; sanitize or VETO"
- "secret leaks into the log message at this branch; redact"
- "session token stored in plaintext fixture; rotate and tombstone"
Maintainer:
- "at 3am this method name lies; rename it now"
- "no test will reproduce this; add one or revert"
- "the conditional has six branches; collapse or document"
Reliability:
- "one timeout missing turns this into a fork-bomb on retry"
- "no idempotency token; the second call doubles the side effect"
- "circuit breaker absent; the dependent service drags us down"
Graphic Designer:
- "the eye lands on the timestamp, not the headline; that's wrong"
- "three weights of grey doing the same job; pick one"
- "this margin is wider than the line it separates; collapse"
Web Designer:
- "this button is a div; it isn't keyboard-tabbable; use button"
- "form has no autocomplete hints; the browser can't help"
- "the modal traps focus and the close icon is unlabeled"
Electronic Music Producer:
- "the snare is on the kick; one of them has to move"
- "everything is on the grid and nothing breathes"
- "high-mids are stacked; carve a notch for the vocal"
Layperson:
- "I read 'idempotency' three times and still don't know if it's safe to retry"
- "the error says 'invalid token' — token of what? where do I get a new one?"
- "the screen says 'success' but nothing visible changed; did it actually work?"
mcp_persona_slots:
enabled: true
description: "MCP servers can register as council personas; weights default to 0.05 unless otherwise configured"
source: "cross-cutting reunification (#93)"
# Merged from former data/council_questions.yml — critic prompts rotated per turn.
questions:
assumptions:
- what are we assuming that could be false?
- which assumption is load-bearing vs convenience?
- if a key assumption flips, what still works?
- which assumption have we never tested?
failure_modes:
- how does this fail catastrophically?
- what breaks first under load or partial outage?
- what happens when it fails silently?
- how do cascading failures propagate?
attacker:
- what would an attacker do here?
- where can input be abused or poisoned?
- which trust boundary is weakest?
- how would we exploit this ourselves?
edge_cases:
- which edge case will users hit first?
- what happens with malformed input?
- which rare but high-impact case is unhandled?
- what edge cases live at integration points?
degradation:
- how do we degrade gracefully?
- what is minimal viable behavior under stress?
- which features sacrifice first?
- how do we keep core function during partial failure?
ops_maint:
- what is the long-term maintenance burden?
- how do we observe, debug, and rollback quickly?
- which operational complexity is hidden?
- how do we troubleshoot under pressure?
economics:
- where is waste or needless complexity?
- what is the roi vs simpler alternatives?
- which costs are hidden or deferred?
- what are the opportunity costs?
clarity:
- is the intent obvious from the names alone?
- which concept lacks a name and should have one?
- where does the code lie about what it does?
- what would a fresh reader misread first?
# Merged from former data/council_patterns.yml — regex strings that auto-trigger council.
auto_trigger_patterns:
- '\beval\s+\('
- '\bexec\s+\('
- '\bsystem\s+\('
- '\brm\s+-rf\b'
- '\b(?:drop|truncate)\s+table\b'
- '\bchmod\s+777\b'
- '\b(?:delete|remove)\s+all\b'
- '\b(fork|execve?)\b'
- '\bgit\s+(push\s+--force|reset\s+--hard|rebase\s+-i)\b'
- '\bdd\s+if=.*\s+of=.*\b'
- '\b(mkfs|fdisk|parted)\b'
- '\b(poweroff|reboot|shutdown\s+-[hr])\b'
- '\bcurl\s+.*\s+-o\s+/\w+\b'
- '\bwget\s+.*\s+--output-document=/.+\b'
- '\b(chown|chgrp)\s+.*\s+/\w+\b'
- '\bln\s+-sf\s+.*\s+/\w+\b'
- '\bsystemctl\s+(mask|disable|stop)\b'
- '\bumount\s+.*\b'# Exemplars — canonical code examples for LLM context injection.
exemplars:
- name: "Master::Axioms::ENUM"
file: "lib/master/axioms.rb"
lines: 9
beauty_score: 7
virtue: declarative
why: "Centralised truth constants, immutable, self‑documenting"
- name: "Master::CircuitBreaker#call"
file: "lib/master/circuit_breaker.rb"
lines: 6
beauty_score: 8
virtue: resilience
why: "Prevents cascading failures, simple state machine, easy to test"
- name: "Master::CodeIndex::SymbolVisitor#visit_def"
file: "lib/master/code_index.rb"
lines: 167
beauty_score: 8
virtue: introspection
why: "Uses Prism visitor to collect symbols, pure functional style, concise"
- name: "Master::Logging.debug"
file: "lib/master/logging.rb"
lines: 6
beauty_score: 6
virtue: transparency
why: "Thin wrapper around logger, ensures consistent formatting, no side effects"
- name: "Master::Logging.info"
file: "lib/master/logging.rb"
lines: 10
beauty_score: 6
virtue: transparency
why: "Standardised info-level logging, preserves caller context"
- name: "Master::Pipeline#run"
file: "lib/master/pipeline.rb"
lines: 22
beauty_score: 9
virtue: orchestration
why: "Linear 10‑stage pipeline, monadic result flow, explicit error propagation"
- name: "Master::Result::Err"
file: "lib/master/result.rb"
lines: 36
beauty_score: 9
virtue: error_handling
why: "Explicit failure monad, immutable, forces callers to handle errors"
- name: "Master::Result::Ok"
file: "lib/master/result.rb"
lines: 8
beauty_score: 9
virtue: zen_method
why: "Encapsulates success, immutable, self‑describing, no boilerplate"
- name: "Master::RingBuffer#pop"
file: "lib/master/ring_buffer.rb"
lines: 12
beauty_score: 8
virtue: efficient
why: "Symmetric constant‑time removal, preserves immutability guarantees"
- name: "Master::RingBuffer#push"
file: "lib/master/ring_buffer.rb"
lines: 5
beauty_score: 8
virtue: efficient
why: "Constant‑time circular buffer, clear intent, minimal code"
- name: "Master::Security::InjectionGuard#sanitize"
file: "lib/master/security/injection_guard.rb"
lines: 12
beauty_score: 8
virtue: safety
why: "Robust string sanitization, guards against code injection, well‑named"
- name: "Master::SemanticCache#fetch"
file: "lib/master/semantic_cache.rb"
lines: 8
beauty_score: 8
virtue: performance
why: "Memoises LLM embeddings, reduces API calls, immutable cache key"
- name: "Master::Stages::Intake#call"
file: "lib/master/stages/intake.rb"
lines: 8
beauty_score: 7
virtue: composability
why: "Initial request parsing, validates input, isolates side‑effects"
- name: "Master::Stages::Lint#call"
file: "lib/master/stages/lint.rb"
lines: 10
beauty_score: 7
virtue: composability
why: "Stage pattern, thin wrapper, delegates to scanner, easy to test"
- name: "Master::Stages::Render#call"
file: "lib/master/stages/render.rb"
lines: 6
beauty_score: 9
virtue: presentation
why: "Final rendering step, separates view logic, pure Result output"
- name: "Master::Tools::AskLlm#call"
file: "lib/master/tools/ask_llm.rb"
lines: 5
beauty_score: 8
virtue: delegation
why: "Encapsulates LLM request, uniform error handling, testable abstraction"
- name: "Master::Tools::ReadFile#call"
file: "lib/master/tools/read_file.rb"
lines: 5
beauty_score: 7
virtue: clarity
why: "Single responsibility, explicit error handling, pure I/O abstraction"
- name: "Master::Tools::SearchFiles#call"
file: "lib/master/tools/search_files.rb"
lines: 5
beauty_score: 7
virtue: discoverability
why: "Recursively glob‑searches project files, filters by pattern, pure result handling"
- name: "Master::Tools::StrReplace#call"
file: "lib/master/tools/str_replace.rb"
lines: 5
beauty_score: 7
virtue: clarity
why: "Pure string substitution helper, validates inputs, returns Result"
- name: "Master::Tools::Tree#call"
file: "lib/master/tools/tree.rb"
lines: 9
beauty_score: 7
virtue: introspection
why: "Builds AST tree view, useful for debugging, returns structured Result"
- name: "Master::Tools::WriteFile#call"
file: "lib/master/tools/write_file.rb"
lines: 7
beauty_score: 7
virtue: clarity
why: "Encapsulates file write with atomic temp‑file swap, error propagation"
- name: "Master::Swarm::Workers::Analyst#perform"
file: "lib/master/swarm/workers/analyst.rb"
lines: 7
beauty_score: 7
virtue: delegation
why: "Analyzes LLM output, extracts actionable insights, pure data transformation"
- name: "Master::Swarm::Workers::Coder#perform"
file: "lib/master/swarm/workers/coder.rb"
lines: 14
beauty_score: 7
virtue: delegation
why: "Coordinates LLM code generation, isolates side‑effects, clear contract"# Heartbeat — autonomous scheduled jobs.
# Each entry runs at interval_seconds. Actions: prune_memory, check_models, self_test, prune_undo, snapshot.
- name: prune_memory
action: prune_memory
interval_seconds: 3600
description: Consolidate and archive stale memory entries.
- name: self_test
action: self_test
interval_seconds: 7200
description: Run standard scan against lib/ and report violations.
- name: prune_undo
action: prune_undo
interval_seconds: 86400
description: Trim undo journal to last 50 entries.
- name: snapshot
action: snapshot
interval_seconds: 14400
description: Regenerate .master/snapshot.md with current codebase state.# Intent-inference patterns for Stages::Infer.
# Extracted from Ruby source per NO_HARDCODED_CONSTANTS / ONE_SOURCE axioms.
# Every new natural-language command goes here — no code change required.
#
# Format: each entry has a command name and a list of regex patterns.
# Patterns are compiled case-insensitive with extended mode (x flag).
# Leave escaping as it appears here — loader does not re-escape.
commands:
sweep:
patterns:
- '\b(?:sweep|refactor|clean\s*up|rewrite|polish|tidy\s*up|overhaul|improve\s+(?:all|every)|go\s+through\s+(?:all|every)|full\s+pass\s+(?:over|on))(?:\s+(?:all|every(?:thing)?|the))?(?:\s+([\w\/.]+))?'
- '\b(?:rydd\s+opp|refaktorer|forbedre?|gjennomg[åa]|omskriv)(?:\s+([\w\/.]+))?'
capture: path
autoloop:
patterns:
- '\b(?:autoloop|autofix|fix\s+all\s+violations?|keep\s+(?:fix|loop)|loop\s+until|iterate\s+until|run\s+until\s+clean|keep\s+going\s+until|(?:run|go)\s+(?:it\s+)?(?:again\s+)?until\s+(?:done|clean|fixed|perfect))(?:\s+(\d+))?'
- '\b(?:fiks?\s+alle?\s+(?:feil|brudd)|fortsett\s+(?:til|inntil)|kj[øo]r\s+(?:til\s+)?(?:det\s+er\s+)?(?:rent|bra|ferdig))(?:\s+(\d+))?'
capture: cycles
council:
patterns:
- '\b(?:council|deliberat|multiple\s+perspect|second\s+opinion|peer\s+review|debate\s+this|get\s+(?:another|a\s+second)\s+view|multi(?:ple)?\s+(?:view|agent|model|perspect))\b'
- '\b(?:r[åa]dsl[åa]g|bruk\s+(?:flere|multiple)\s+(?:perspektiv|synsvinkler?)|diskuter\s+(?:dette|det))\b'
capture: on_off
explain:
patterns:
- '\b(?:explain\s+(?:your(?:self)?|your\s+architecture|how\s+you\s+work)|describe\s+(?:your(?:self)?|your\s+architecture)|what\s+are\s+you|how\s+(?:are\s+you\s+built|do\s+you\s+work)|show\s+(?:your\s+)?architecture|self[\s-]?map)\b'
capture: none
persona:
patterns:
- '\b(?:(?:switch|change|set)\s+persona\s+(?:to\s+)?(\w+)|persona\s+(\w+)|use\s+(\w+)\s+persona)\b'
capture: persona_name
memory:
patterns:
- '\b(?:what\s+do\s+you\s+remember(?:\s+about\s+([\w\s]+))?|show\s+(?:my\s+)?memor(?:y|ies)|list\s+memor(?:y|ies)|recall(?:\s+([\w]+))?|what(?:''s|\s+is)\s+in\s+(?:your\s+)?memory|remember\s+([\w]+=.+)|forget\s+([\w_]+))\b'
- '\b(?:hva\s+husker\s+du(?:\s+om\s+([\w\s]+))?|vis\s+(?:min\s+)?hukommelse|husk\s+([\w_]+=.+))\b'
capture: first_group
tokens:
patterns:
- '\b(?:token\s*count|how\s+many\s+tokens?|context\s+size|token\s+usage|how\s+much\s+context|hvor\s+mange\s+token|token\s*antall)\b'
capture: none
cost:
patterns:
- '\b(?:how\s+much\s+(?:has\s+this\s+cost|did\s+this\s+cost)|(?:current\s+)?(?:spend|cost|budget)|what(?:''s|\s+is)\s+the\s+cost|hva\s+koster?\s+(?:dette|det)|kostnader?)\b'
capture: none
undo:
patterns:
- '\b(?:undo\s+that|revert\s+(?:that|last|it)|go\s+back|take\s+that\s+back|angre\s+det|g[åa]\s+tilbake)\b'
capture: none
clear:
patterns:
- '\b(?:clear\s+(?:context|chat|history|session|screen)|start\s+(?:over|fresh|again)|reset\s+(?:context|session)|fresh\s+start|t[øo]m\s+(?:kontekst|historikk)|begynn\s+p[åa]\s+nytt)\b'
capture: none
save:
patterns:
- '\b(?:save\s+(?:session|this|my\s+work|progress)|checkpoint\s+now|lagre\s+(?:session|sesjonen?|arbeid))\b'
capture: none
model:
patterns:
- '\b(?:which\s+model|current\s+model|what\s+model\s+are\s+you|what\s+(?:llm|ai|model)\s+(?:are\s+you\s+using|is\s+this))\b'
capture: none
scan:
patterns:
- '\b(?:scan|lint|check\s+(?:code|violations?)|run\s+scan)(?:\s+(deep))?\b'
capture: scan_depth
dmesg:
patterns:
- '\b(?:show\s+(?:logs?|events?)|system\s+log|dmesg|what\s+(?:happened|has\s+happened)|recent\s+activity)\b'
capture: none
dreams:
patterns:
- '\b(?:dreams?|consolidate?\s+memor(?:y|ies)|memory\s+consolidat|dream\s+mode|promote\s+memor(?:y|ies))\b'
capture: first_group
soul:
patterns:
- '\b(?:show|check|view)\s+(?:the\s+)?soul\b'
- '\bsoul\s+(?:version|changelog|diff|approve|reject|rollback|propose)\b'
capture: soul_subcmd
orders:
patterns:
- '\b(?:standing\s+orders?|show\s+orders?|list\s+orders?)\b'
capture: orders_subcmd# Externalized from lib/master/security/injection_guard.rb so future patterns
# land in YAML, not Ruby. Source: yaml/ruby split audit (#5).
prompt_injection:
- "ignore (?:previous|all|your) instructions"
- "disregard (?:your )?(?:system )?prompt"
- "you are now (?:a|an|in)"
- "pretend (?:to be|you are|you're)"
- "new instructions:"
- "\\[SYSTEM\\]"
- "###\\s*SYSTEM"
- "(?:act|behave|respond) as (?:if )?(?:you (?:are|were)|a|an) (?!assistant|helpful)"
- "override (?:your )?(?:safety|guidelines|rules|instructions)"
- "jailbreak"
- "forget (?:everything|all|your)"
- "override (?:axiom|principle|rule)"
- "disregard (?:axiom|principle|rule|safety)"
- "new system prompt"
shell_injection:
multiline_pattern: "```(?:bash|sh|zsh|shell)\\n.*?(?:rm\\s+-rf|curl\\b.*?\\|\\s*(?:bash|sh)\\b|wget\\b.*?\\|\\s*(?:bash|sh)\\b)"
modes:
permissive: "match -> block; no match -> pass"
default_deny: "match -> block; no match -> require explicit allowlist token"# Lexical scan rules expressed as data. Each entry replaces a 25-50 LOC Ruby class.
# Loaded by Scan::Rules::TableLexicalRule. Adding a new lexical rule = one yaml entry.
#
# Schema:
# id — short snake_case id, used as the `rule:` field on each finding
# description — one line, shown in /why
# severity — info | warning | error
# axiom_tags — list of constitutional axiom symbols
# langs — file extensions to apply to (default: ['.rb'])
# path_includes — optional substring the path must contain
# path_excludes — optional substring the path must NOT contain
# skip_comments — true to skip lines starting with `#`
# patterns — list of {regex, message} pairs evaluated per line
# first_line — true to only check the first line and emit one finding if matched
---
- id: frozen_string
description: Ruby files should declare # frozen_string_literal magic comment
severity: warning
axiom_tags: [PERFORMANCE]
first_line: true
patterns:
- regex: '\Afrozen_string_literal: true'
negate: true
message: missing # frozen_string_literal: true
one_per_file: true
- id: debug_output
description: Debug output left in lib/ — remove before shipping
severity: error
axiom_tags: [FAIL_VISIBLY]
path_includes: /lib/
skip_comments: true
patterns:
- regex: '^\s*pp?\s+(?!self\b)'
message: p/pp debug call — remove or publish via event bus
- regex: '\$stderr\.puts\b'
message: $stderr.puts — use @bus.publish or $stdout
- regex: '\bbinding\.pry\b'
message: binding.pry left in — remove before commit
- regex: '\bdebugger\b'
message: debugger left in — remove before commit
- id: trailing_comment
description: Trailing comment after code — promote to a leading comment if it adds value, else delete
severity: info
axiom_tags: [STRUNK_WHITE, BE_CONCISE]
skip_comments: true
patterns:
- regex: '\S\s+#\s+\S'
message: trailing comment — promote above the line or delete
- id: time_zone_unsafe
description: Bare Time.now / Date.today / DateTime.now bypasses Rails Time.zone
severity: warning
axiom_tags: [ROBUSTNESS]
skip_comments: true
patterns:
- regex: '(?<![A-Za-z_.])Time\.now\b'
message: Time.now ignores Time.zone — use Time.current
- regex: '(?<![A-Za-z_.])Date\.today\b'
message: Date.today ignores Time.zone — use Date.current
- regex: '(?<![A-Za-z_.])DateTime\.now\b'
message: DateTime.now ignores Time.zone — use Time.current.to_datetime# MASTER Constitutional Manifest
# Each entry maps a scan rule to its enforcement file and test coverage.
# Generated from Scan::Rule.registry metadata; keep in sync with scan/rules/*.rb
# Severity: error > warning > style > info
rules:
- id: adversarial
description: "Red-team scan: steelman then challenge — suppresses false positives"
severity: error
axiom_tags: [ONE_JOB, CQS, GUARD_EXPENSIVE, FAIL_VISIBLY, COMPOSABLE]
enforcement: lib/master/scan/rules/adversarial_rule.rb
test: null
- id: arity
description: "initialize with too many args — extract a context struct or config object"
severity: warning
axiom_tags: [DECOUPLE, ONE_JOB]
enforcement: lib/master/scan/rules/arity_rule.rb
test: null
- id: axiom_coverage
description: "Every rule must have scan coverage; every axiom tag must be a real rule"
severity: warning
axiom_tags: []
enforcement: lib/master/scan/rules/axiom_coverage_rule.rb
test: null
- id: bare_rescue
description: "Never use bare rescue — always specify exception type"
severity: error
axiom_tags: [FAIL_VISIBLY]
enforcement: lib/master/scan/rules/bare_rescue_rule.rb
test: null
- id: comment_quality
description: "TODO without ref, commented-out code"
severity: style
axiom_tags: [SELF_EXPLAINING]
enforcement: lib/master/scan/rules/comment_quality_rule.rb
test: null
- id: semantic
description: "LLM-based rule review (deep scan only)"
severity: warning
axiom_tags: []
enforcement: lib/master/scan/rules/semantic_rule.rb
test: null
- id: cqs
description: "Command/Query Separation — queries must not mutate state"
severity: warning
axiom_tags: [CQS]
enforcement: lib/master/scan/rules/cqs_rule.rb
test: null
- id: dead_assign
description: "Local variable assigned but never read — remove or use it"
severity: warning
axiom_tags: [EXPLICIT]
enforcement: lib/master/scan/rules/dead_assign_rule.rb
test: null
- id: dead_code
description: "Dead constants and empty rescue blocks"
severity: warning
axiom_tags: [EXPLICIT]
enforcement: lib/master/scan/rules/dead_code_rule.rb
test: null
- id: debug_output
description: "Debug output left in lib/ — remove before shipping"
severity: error
axiom_tags: [FAIL_VISIBLY]
enforcement: lib/master/scan/rules/debug_output_rule.rb
test: null
- id: duplicate_code
description: "Duplicate code blocks violate ONE_SOURCE — extract to shared method"
severity: warning
axiom_tags: [ONE_SOURCE]
enforcement: lib/master/scan/rules/duplicate_code_rule.rb
test: null
- id: explicit
description: "Implicit, opaque patterns — prefer explicit contracts"
severity: warning
axiom_tags: [EXPLICIT]
enforcement: lib/master/scan/rules/explicit_rule.rb
test: null
- id: frozen_string
description: "Ruby files should declare # frozen_string_literal: true"
severity: warning
axiom_tags: [PERFORMANCE]
enforcement: lib/master/scan/rules/frozen_string_rule.rb
test: null
- id: god_class
description: "Classes over threshold lines should be split by responsibility"
severity: warning
axiom_tags: [SIMPLEST_WORKS]
enforcement: lib/master/scan/rules/god_class_rule.rb
test: null
- id: immutable
description: "Mutable shared state — prefer frozen constants and immutable data flow"
severity: warning
axiom_tags: [IMMUTABLE]
enforcement: lib/master/scan/rules/immutable_rule.rb
test: null
- id: interconnect
description: "Phantom YAML key reads and orphan data keys"
severity: warning
axiom_tags: [ONE_SOURCE]
enforcement: lib/master/scan/rules/interconnect_rule.rb
test: null
- id: lexical
description: "Data-driven lexical checks from rules.yml for all file types"
severity: warning
axiom_tags: [UNIVERSAL]
enforcement: lib/master/scan/rules/lexical_rule.rb
test: null
- id: long_method
description: "Methods over threshold lines should be extracted"
severity: warning
axiom_tags: [ONE_JOB]
enforcement: lib/master/scan/rules/long_method_rule.rb
test: null
- id: naming
description: "Method names violate Ruby conventions"
severity: style
axiom_tags: [SELF_EXPLAINING]
enforcement: lib/master/scan/rules/naming_rule.rb
test: null
- id: nesting_depth
description: "Deep nesting — use guard clauses to flatten"
severity: warning
axiom_tags: [GUARD_CLAUSES_FIRST]
enforcement: lib/master/scan/rules/nesting_depth_rule.rb
test: null
- id: nielsen
description: "Nielsen heuristics: error recovery, user control, aesthetic minimalism"
severity: warning
axiom_tags: [ERROR_RECOVERY, REAL_WORLD_MATCH, USER_CONTROL, AESTHETIC_MINIMALISM]
enforcement: lib/master/scan/rules/nielsen_rule.rb
test: null
- id: opportunity
description: "Structural improvement opportunity — refactor for clarity or cohesion"
severity: info
axiom_tags: [SIMPLEST_WORKS, DECOUPLE, ONE_JOB]
enforcement: lib/master/scan/rules/opportunity_rule.rb
test: null
- id: pola
description: "Principle of Least Astonishment — surprising names, contracts, or side-effects"
severity: warning
axiom_tags: [EXPLICIT]
enforcement: lib/master/scan/rules/pola_rule.rb
test: null
- id: prune
description: "Hedge words and preamble phrases in comments reduce clarity"
severity: warning
axiom_tags: [STRUNK_WHITE]
enforcement: lib/master/scan/rules/prune_rule.rb
test: null
- id: reek
description: "Code smell detection: feature envy, data clumps, boolean params"
severity: warning
axiom_tags: [DECOUPLE, ONE_JOB, EXPLICIT]
enforcement: lib/master/scan/rules/reek_rule.rb
test: null
- id: rubocop
description: "AST-based analysis: complexity, guard clauses, parameter names"
severity: warning
axiom_tags: [CQS, GUARD_CLAUSES_FIRST, SELF_EXPLAINING]
enforcement: lib/master/scan/rules/rubocop_rule.rb
test: null
- id: self_explaining
description: "Opaque names — names should reveal purpose without reading the implementation"
severity: warning
axiom_tags: [SELF_EXPLAINING]
enforcement: lib/master/scan/rules/self_explaining_rule.rb
test: null
- id: srp
description: "Single Responsibility Principle — class spans multiple concern domains"
severity: warning
axiom_tags: [ONE_JOB]
enforcement: lib/master/scan/rules/srp_rule.rb
test: null
- id: structure
description: "Structural anti-patterns — guard clauses, unreachable code, flatten depth"
severity: warning
axiom_tags: [GUARD_CLAUSES_FIRST]
enforcement: lib/master/scan/rules/structure_rule.rb
test: null
- id: tell_dont_ask
description: "Tell-Don't-Ask — avoid reaching into objects to make decisions for them"
severity: warning
axiom_tags: [DECOUPLE, EXPLICIT]
enforcement: lib/master/scan/rules/tell_dont_ask_rule.rb
test: null
- id: terse
description: "Verbose Ruby patterns — use idiomatic shortcuts"
severity: style
axiom_tags: [EXPLICIT]
enforcement: lib/master/scan/rules/terse_rule.rb
test: null
- id: thread_safety
description: "Thread-unsafe patterns: Dir.chdir, shell interpolation, dropped kwargs"
severity: error
axiom_tags: [FAIL_VISIBLY, EXPLICIT]
enforcement: lib/master/scan/rules/thread_safety_rule.rb
test: null
- id: threshold_drift
description: "Hardcoded threshold constant in scan rule — read from Axioms instead"
severity: warning
axiom_tags: [ONE_SOURCE]
enforcement: lib/master/scan/rules/threshold_drift_rule.rb
test: null
- id: trailing_comment
description: "Trailing comment restates the code — rename instead of annotating"
severity: style
axiom_tags: [SELF_EXPLAINING]
enforcement: lib/master/scan/rules/trailing_comment_rule.rb
test: null
- id: universal
description: "Cross-language axiom checks"
severity: info
axiom_tags: [UNIVERSAL]
enforcement: lib/master/scan/rules/universal_rule.rb
test: null
- id: yaml_quality
description: "YAML verbosity — unnecessary quotes, type coercions"
severity: style
axiom_tags: [SIMPLEST_WORKS]
enforcement: lib/master/scan/rules/yaml_quality_rule.rb
test: null# MCP server definitions for MASTER.
# Transport options: stdio | sse
# Disabled by default on resource-constrained VPS.
# Enable individual servers with enabled: true when needed.
defaults: &defaults
transport: stdio
command: npx
enabled: false
servers:
filesystem:
<<: *defaults
args:
- -y
- "@modelcontextprotocol/server-filesystem"
- "/home/dev/pub4"
description: Expose read/write/search over a local directory
git:
<<: *defaults
args:
- -y
- "@modelcontextprotocol/server-git"
- "--repository"
- "/home/dev/pub4/MASTER"
description: Expose git operations as tools
brave_search:
<<: *defaults
args:
- -y
- "@modelcontextprotocol/server-brave-search"
description: Web search via Brave
sequential_thinking:
<<: *defaults
args:
- -y
- "@modelcontextprotocol/server-sequential-thinking"
description: Structured reasoning assistant# Model routing profile — Gemini primary, Mistral/DeepSeek/OpenRouter fallback.
routing:
enabled: true
strategy: weighted
escalation_enabled: true
escalation_tier: strong
provider: gemini
weights: &weights
quality: 0.50
speed: 0.25
cost: 0.25
fallback_policy:
retries_per_tier: 1
on:
- timeout
- network_error
- refusal
defaults: &model_defaults
score: { quality: 0.0, speed: 0.0, cost: 0.0 }
model_defs:
gemini_flash: &gemini_flash
id: gemini-2.5-flash
<<: *model_defaults
score: { quality: 0.88, speed: 0.90, cost: 0.95 }
gemini_pro: &gemini_pro
id: gemini-2.5-pro
<<: *model_defaults
score: { quality: 0.95, speed: 0.70, cost: 0.80 }
mistral_large: &mistral_large
id: mistralai/mistral-large
<<: *model_defaults
score: { quality: 0.90, speed: 0.75, cost: 0.70 }
mistral_small: &mistral_small
id: mistralai/mistral-small-3.1-24b
<<: *model_defaults
score: { quality: 0.78, speed: 0.85, cost: 0.90 }
deepseek_chat: &deepseek_chat
id: deepseek-chat
<<: *model_defaults
score: { quality: 0.88, speed: 0.70, cost: 0.95 }
deepseek_coder: &deepseek_coder
id: deepseek-coder
<<: *model_defaults
score: { quality: 0.85, speed: 0.70, cost: 0.95 }
claude_sonnet: &claude_sonnet
id: anthropic/claude-sonnet-4-6
<<: *model_defaults
score: { quality: 0.95, speed: 0.75, cost: 0.60 }
nemotron_super: &nemotron_super
id: nvidia/nemotron-3-super-120b-a12b:free
<<: *model_defaults
score: { quality: 0.90, speed: 0.75, cost: 1.0 }
qwen_coder: &qwen_coder
id: qwen/qwen3-coder:free
<<: *model_defaults
score: { quality: 0.75, speed: 0.65, cost: 1.0 }
llama_70b: &llama_70b
id: meta-llama/llama-3.3-70b-instruct:free
<<: *model_defaults
score: { quality: 0.78, speed: 0.70, cost: 1.0 }
hermes_405b: &hermes_405b
id: nousresearch/hermes-3-llama-3.1-405b:free
<<: *model_defaults
score: { quality: 0.85, speed: 0.50, cost: 1.0 }
gpt_4o: &gpt_4o
id: openai/gpt-4o
<<: *model_defaults
score: { quality: 0.93, speed: 0.80, cost: 0.55 }
claude_cli_sonnet: &claude_cli_sonnet
id: claude-cli:claude-sonnet-4-6
<<: *model_defaults
score: { quality: 0.95, speed: 0.70, cost: 0.60 }
claude_cli_opus: &claude_cli_opus
id: claude-cli:claude-opus-4-7
<<: *model_defaults
score: { quality: 0.99, speed: 0.50, cost: 0.30 }
gemma_2_9b_free: &gemma_2_9b_free
id: google/gemma-2-9b-it:free
<<: *model_defaults
score: { quality: 0.72, speed: 0.88, cost: 1.0 }
gemma_2_27b: &gemma_2_27b
id: google/gemma-2-27b-it
<<: *model_defaults
score: { quality: 0.82, speed: 0.78, cost: 0.92 }
gemini_2_flash_exp_free: &gemini_2_flash_exp_free
id: google/gemini-2.0-flash-exp:free
<<: *model_defaults
score: { quality: 0.85, speed: 0.92, cost: 1.0 }
gemini_flash_lite: &gemini_flash_lite
id: google/gemini-flash-lite-latest
<<: *model_defaults
score: { quality: 0.78, speed: 0.96, cost: 0.97 }
phi_4_free: &phi_4_free
id: microsoft/phi-4:free
<<: *model_defaults
score: { quality: 0.74, speed: 0.90, cost: 1.0 }
glm_4_5_air_free: &glm_4_5_air_free
id: z-ai/glm-4.5-air:free
<<: *model_defaults
score: { quality: 0.80, speed: 0.82, cost: 1.0 }
yi_lightning: &yi_lightning
id: 01-ai/yi-lightning
<<: *model_defaults
score: { quality: 0.78, speed: 0.94, cost: 0.94 }
command_r_plus: &command_r_plus
id: cohere/command-r-plus
<<: *model_defaults
score: { quality: 0.86, speed: 0.72, cost: 0.65 }
grok_4_fast: &grok_4_fast
id: x-ai/grok-4-fast
<<: *model_defaults
score: { quality: 0.88, speed: 0.92, cost: 0.78 }
reka_flash: &reka_flash
id: rekaai/reka-flash-3:free
<<: *model_defaults
score: { quality: 0.74, speed: 0.86, cost: 1.0 }
deepseek_v3_free: &deepseek_v3_free
id: deepseek/deepseek-chat-v3.1:free
<<: *model_defaults
score: { quality: 0.90, speed: 0.72, cost: 1.0 }
llama_4_scout_free: &llama_4_scout_free
id: meta-llama/llama-4-scout:free
<<: *model_defaults
score: { quality: 0.84, speed: 0.78, cost: 1.0 }
groq_llama_3_3_70b: &groq_llama_3_3_70b
id: groq/llama-3.3-70b-versatile
<<: *model_defaults
score: { quality: 0.85, speed: 0.99, cost: 0.85 }
cerebras_llama_3_1_8b: &cerebras_llama_3_1_8b
id: cerebras/llama-3.1-8b
<<: *model_defaults
score: { quality: 0.70, speed: 0.99, cost: 0.92 }
ollama_qwen: &ollama_qwen
id: ollama:qwen2.5-coder:7b
<<: *model_defaults
score: { quality: 0.62, speed: 0.85, cost: 1.0 }
ollama_llama: &ollama_llama
id: ollama:llama3.2:3b
<<: *model_defaults
score: { quality: 0.55, speed: 0.92, cost: 1.0 }
ollama_phi: &ollama_phi
id: ollama:phi4:mini
<<: *model_defaults
score: { quality: 0.58, speed: 0.95, cost: 1.0 }
models:
default:
- *gemini_flash
- *mistral_large
- *deepseek_chat
- *nemotron_super
- *qwen_coder
- *grok_4_fast
- *deepseek_v3_free
strong:
- *gemini_pro
- *mistral_large
- *claude_sonnet
- *gpt_4o
- *gemini_flash
- *command_r_plus
cheap:
- *gemini_flash
- *mistral_small
- *deepseek_chat
- *llama_70b
- *qwen_coder
- *gemma_2_9b_free
- *gemini_flash_lite
- *yi_lightning
fast:
- *groq_llama_3_3_70b
- *cerebras_llama_3_1_8b
- *gemini_flash_lite
- *gemini_2_flash_exp_free
free:
- *gemini_2_flash_exp_free
- *gemma_2_9b_free
- *deepseek_v3_free
- *llama_4_scout_free
- *phi_4_free
- *glm_4_5_air_free
- *reka_flash
- *nemotron_super
- *qwen_coder
- *llama_70b
- *hermes_405b
claude_code:
- *claude_cli_sonnet
- *claude_cli_opus
local:
- *ollama_phi
- *ollama_llama
- *ollama_qwen
routes:
code_generation: default
refactoring: default
architecture: strong
review: default
explanation: cheap
exploration: cheap
fallback_default: cheap
tool_capable_prefixes:
- claude
- claude-cli
- gpt-4
- gpt-4o
- gemini
- mistral
- mistralai
- mixtral
- llama-3.1
- llama-3.3
- llama-4
- qwen
- command-r
- cohere/command
- deepseek
- stepfun
- nvidia
- nemotron
- meta/meta-llama
- anthropic/claude
- openai/gpt
- google/gemini
- google/gemma
- microsoft/phi
- z-ai/glm
- 01-ai/yi
- x-ai/grok
- rekaai/reka
- groq
- cerebras
operation_constraints:
# Operations that write files, run autoloop/sweep, or execute destructive commands
# require a model with quality score >= 0.88 (default and cheap tiers excluded).
# Equivalent to: claude-sonnet-4-6, gemini-2.5-pro, mistral-large, gpt-4o.
file_write: { min_quality: 0.88, preferred_tier: strong }
autoloop: { min_quality: 0.88, preferred_tier: strong }
sweep: { min_quality: 0.88, preferred_tier: strong }
council: { min_quality: 0.88, preferred_tier: strong }
scan_semantic: { min_quality: 0.88, preferred_tier: strong }
scan_adversarial: { min_quality: 0.88, preferred_tier: strong }
destructive: { min_quality: 0.90, preferred_tier: strong }
continuity:
enabled: true
updated_at: "2026-05-01T00:00:00Z"
openrouter:
free_latest:
- nvidia/nemotron-3-super-120b-a12b:free
- qwen/qwen3-coder:free
# Provider trust tracked over time; weights routing beyond raw cost.
# Source: master.json v225 reunification (#52).
trust_scoring:
initial_score: 0.50
success_increment: 0.02
failure_decrement: 0.10
deprecate_below: 0.20
persist_to: "data/provider_trust.yml"
consider_in_routing: true
three_mirror_redundancy:
# Three models vote; ship only on >= 2 agreement for critical fixes.
# Source: cross-cutting reunification (#95).
enabled_for: [tier1_critical, security_relevant, irreversible]
pool: [openrouter_primary, openrouter_secondary, claude_cli]
quorum: 2
on_disagreement: "fall back to council vote with all dissent recorded"
# Tier-D (local ollama) — env-gated; activates only when OLLAMA_BASE_URL is set.
# Default: http://localhost:11434/v1 (OpenAI-compatible endpoint).
# Trust starts at 0.40 (below cloud providers) until measured success raises it.
ollama:
enabled_when_env: OLLAMA_BASE_URL
default_base_url: "http://localhost:11434/v1"
initial_trust: 0.40
use_for: [exploration, fallback_default] # cheapest-acceptable tasks only
never_for: [council, sweep, autoloop, file_write, destructive]# openbsd.yml — OpenBSD config validators
# Restored from master.yml v49.75; extended for OpenBSD 7.8
man_base_url: "https://man.openbsd.org"
cache_ttl: 86400
configs:
pf.conf:
daemon: pf
man: pf.conf.5
required_patterns:
- "set skip on lo"
warnings:
- pattern: "pass all"
message: "Overly permissive — add interface/protocol guards"
nsd.conf:
daemon: nsd
man: nsd.conf.5
required_patterns:
- "server:"
- "zone:"
warnings:
- pattern: "rrl-size"
absent_message: "Missing RRL config — vulnerable to amplification DDoS"
- pattern: "hide-version"
absent_message: "Consider hide-version: yes"
httpd.conf:
daemon: httpd
man: httpd.conf.5
required_patterns:
- "server"
smtpd.conf:
daemon: smtpd
man: smtpd.conf.5
required_patterns:
- "listen on"
- "action"
- "match"
warnings:
- pattern: "match from any"
message: "Open relay risk — restrict to authenticated senders"
relayd.conf:
daemon: relayd
man: relayd.conf.5
required_patterns:
- "relay"
acme-client.conf:
daemon: acme-client
man: acme-client.conf.5
required_patterns:
- "authority"
- "domain"
doas.conf:
daemon: doas
man: doas.conf.5
required_patterns:
- "permit"
warnings:
- pattern: "nopass"
message: "Allows passwordless privilege escalation"
sshd_config:
daemon: sshd
man: sshd_config.5
warnings:
- pattern: "PermitRootLogin yes"
message: "Security risk — use PermitRootLogin prohibit-password"
- pattern: "PasswordAuthentication yes"
message: "Consider key-only auth"
ntpd.conf:
daemon: ntpd
man: ntpd.conf.5
required_patterns:
- "server"
unbound.conf:
daemon: unbound
man: unbound.conf.5
required_patterns:
- "server:"# Platform/tool idioms — gh, openbsd, zsh — merged from former *_patterns.yml.
# Each top-level key is a namespace.
---
gh:
operations:
- action: create_pr
pattern: gh pr create --title '${title}' --body '${body}' --base main
- action: merge_pr
pattern: gh pr merge ${number} --squash --delete-branch
- action: create_issue
pattern: gh issue create --title '${title}' --body '${body}' --label '${labels}'
- action: close_issue
pattern: gh issue close ${number} --reason completed
- action: list_workflows
pattern: gh run list --workflow=${workflow} --limit 5
- action: trigger_workflow
pattern: gh workflow run ${workflow} --ref ${branch}
- action: review_pr
pattern: gh pr review ${number} --approve --body '${comment}'
- action: check_status
pattern: gh pr checks ${number} --watch
- action: clone_repo
pattern: gh repo clone ${owner}/${repo}
- action: fork_repo
pattern: gh repo fork ${owner}/${repo} --clone
- action: api_call
pattern: gh api ${endpoint}
forbidden:
- command: curl api.github.com
replacement: gh api
- command: curl github.com/api
replacement: gh api
- command: hub
replacement: gh — hub is deprecated
openbsd:
service_commands:
enable: rcctl enable ${service}
start: rcctl start ${service}
restart: rcctl restart ${service}
reload: rcctl reload ${service}
check: rcctl check ${service}
disable: rcctl disable ${service}
configuration_paths:
pf: "/etc/pf.conf"
httpd: "/etc/httpd.conf"
relayd: "/etc/relayd.conf"
smtpd: "/etc/mail/smtpd.conf"
acme: "/etc/acme-client.conf"
sshd: "/etc/ssh/sshd_config"
ntp: "/etc/ntpd.conf"
cron: "/var/cron/tabs/${user}"
unbound: "/var/unbound/unbound.conf"
package_operations:
install: pkg_add ${package}
remove: pkg_delete ${package}
search: pkg_info -Q ${query}
update: pkg_add -u
firmware: fw_update
prohibited_commands:
- command: systemctl
replacement: rcctl
- command: apt
replacement: pkg_add
- command: apt-get
replacement: pkg_add
- command: brew
replacement: pkg_add
- command: yum
replacement: pkg_add
- command: ip addr
replacement: ifconfig
- command: ip route
replacement: route
- command: journalctl
replacement: cat /var/log/messages
- command: sudo
replacement: doas
- command: ufw
replacement: pfctl
- command: iptables
replacement: pf
- command: nginx
replacement: httpd (OpenBSD native)
- command: docker
replacement: vmctl
- command: systemd
replacement: rcctl
- command: gsed
replacement: sed (POSIX)
- command: gawk
replacement: awk (POSIX)
- command: ggrep
replacement: grep (POSIX)
security:
pledge: pledge(2) – restrict syscalls after init
unveil: unveil(2) – restrict filesystem visibility
doas: doas.conf – preferred over sudo
signify: signify(1) – cryptographic signing
chroot: httpd runs chrooted by default
daemon_configs:
pf.conf:
daemon: pf
man: pf.conf.5
required_patterns:
- set skip on lo
warnings:
- pattern: pass all
message: Overly permissive rule
nsd.conf:
daemon: nsd
man: nsd.conf.5
required_patterns:
- 'server:'
- 'zone:'
warnings:
- pattern: rrl-size
absent_message: Missing RRL config for DDoS protection
- pattern: hide-version
absent_message: 'Consider hide-version: yes'
httpd.conf:
daemon: httpd
man: httpd.conf.5
required_patterns: []
warnings: []
smtpd.conf:
daemon: smtpd
man: smtpd.conf.5
required_patterns:
- listen on
- action
- match
warnings:
- pattern: match from any
message: Potential open relay
relayd.conf:
daemon: relayd
man: relayd.conf.5
required_patterns:
- relay
warnings: []
acme-client.conf:
daemon: acme-client
man: acme-client.conf.5
required_patterns:
- authority
- domain
warnings: []
doas.conf:
daemon: doas
man: doas.conf.5
required_patterns:
- permit
warnings:
- pattern: nopass
message: Allows password‑less escalation
sshd_config:
daemon: sshd
man: sshd_config.5
required_patterns: []
warnings:
- pattern: PermitRootLogin yes
message: Security risk – disallow root login
- pattern: PasswordAuthentication yes
message: Prefer key‑based authentication
ntpd.conf:
daemon: ntpd
man: ntpd.conf.5
required_patterns:
- server
warnings: []
unbound.conf:
daemon: unbound
man: unbound.conf.5
required_patterns:
- 'server:'
warnings: []
zsh:
forbidden_commands:
- command: awk
replacement: 'zsh array/string field splitting: ${${(s:,:)line}[4]}'
- command: sed
replacement: 'zsh parameter expansion: ${var//search/replace}'
- command: tr
replacement: 'zsh case conversion: ${(L)var} ${(U)var}'
- command: grep
replacement: 'zsh pattern matching: ${(M)arr:#*pattern*}'
- command: cut
replacement: 'zsh field splitting: ${${(s:delim:)var}[N]}'
- command: head
replacement: 'zsh array slicing: ${arr[1,10]}'
- command: tail
replacement: 'zsh array slicing: ${arr[-5,-1]}'
- command: uniq
replacement: 'zsh unique flag: ${(u)arr}'
- command: sort
replacement: 'zsh sort flags: ${(o)arr} (asc) / ${(O)arr} (desc)'
- command: bash
replacement: zsh — never use bash
- command: find
replacement: 'zsh glob qualifiers: **/*.rb(.)'
- command: wc
replacement: 'zsh length/count: ${#var} / ${#arr}'
- command: sudo
replacement: doas on OpenBSD
native_patterns:
string_replace: "${var//find/replace}"
case_lower: "${(L)var}"
case_upper: "${(U)var}"
trim_whitespace: "${${var##[[:space:]]#}%%[[:space:]]#}"
split_to_array: "${(s:delim:)var}"
array_join: "${(j:,:)arr}"
array_unique: "${(u)arr}"
array_sort_asc: "${(o)arr}"
array_sort_desc: "${(O)arr}"
array_reverse: "${(Oa)arr}"
array_filter_match: "${(M)arr:#*pattern*}"
array_filter_exclude: "${arr:#*pattern*}"
remove_crlf: "${var//$'\\r'/}"
exceptions:
- Complex regex requiring PCRE
- Multi‑file operations beyond globbing
- Binary data processing
banned_commands:
- python
- bash
- sed
- awk
- tr
- wc
- head
- tail
- cut
- find
- sudo
auto_remediation:
awk: "${${(s: :)line}[n]}"
sed: "${var//old/new}"
tr: "${(U)var} or ${(L)var}"
wc: "${#lines}"
head: "${lines[1,n]}"
tail: "${lines[-n,-1]}"
grep: "${(M)lines:#*pattern*}"
cut: "${${(s:delim:)var}[N]}"
sort: "${(o)arr} or ${(O)arr}"
find: "**/*.ext(.)"
sudo: doas
token_economics:
philosophy: 'Replacing multi‑tool shell pipelines with pure Zsh parameter expansion
eliminates process boundaries, collapses multiple grammars into one, reduces
reasoning entropy for LLMs, and converts runtime overhead into in‑memory transforms
— saving both tokens and wall‑clock time.
'
example_bad:
code: awk -F, '{print $4}' | sed 's/\r//g' | tr '[:upper:]' '[:lower:]'
cost: 3 grammars, pipes + subshells, I/O transformations
example_good:
code: cleaned=${var//$'\r'/}; lower=${(L)cleaned}; fourth=${${(s:,:)lower}[4]}
cost: One grammar, one evaluation model, no process boundaries
benefit: Model reasons locally instead of globally across pipeline# MASTER personas — voice, TTS settings, style descriptor.
# Add a new persona here, restart MASTER (or wait for hot-reload).
# style: deep | heavy | slow | normal | natural — see lib/master/speech.rb STYLES.
malay:
voice: ms-MY-OsmanNeural
tts_rate: "-35%"
tts_pitch: "-150Hz"
style: deep
description: "Terse. Direct. No filler. Dark."
british:
voice: en-GB-RyanNeural
tts_rate: "-20%"
tts_pitch: "-80Hz"
style: heavy
description: "Measured. Precise. Dry wit."
norwegian:
voice: nb-NO-FinnNeural
tts_rate: "-15%"
tts_pitch: "-40Hz"
style: slow
description: "Calm. Considered. Honest."
ronin:
voice: en-US-AndrewNeural
tts_rate: "-25%"
tts_pitch: "-100Hz"
style: deep
description: "Stoic. Minimal. Decisive. Says only what must be said."
lawyer:
voice: nb-NO-FinnNeural
tts_rate: "-10%"
tts_pitch: "-20Hz"
style: slow
description: "Norwegian law focus. Barnevernet, lovdata.no, sivilombudet.no. Not legal advice."
hacker:
voice: en-US-GuyNeural
tts_rate: "-30%"
tts_pitch: "-120Hz"
style: deep
description: "OpenBSD security. CVE analysis. Pentesting. Exploit-db."
architect:
voice: en-GB-RyanNeural
tts_rate: "-15%"
tts_pitch: "-60Hz"
style: heavy
description: "Parametric design. BIM. archdaily.com. dezeen.com."
sysadmin:
voice: en-AU-WilliamNeural
tts_rate: "-20%"
tts_pitch: "-80Hz"
style: deep
description: "OpenBSD. pf. httpd. vmm. man.openbsd.org."
trader:
voice: en-US-ChristopherNeural
tts_rate: "-20%"
tts_pitch: "-80Hz"
style: heavy
description: "Crypto. DeFi. Technicals. TradingView. CoinGecko."
medic:
voice: en-US-EricNeural
tts_rate: "-15%"
tts_pitch: "-40Hz"
style: slow
description: "Medical research. PubMed. Not medical advice."# Pipeline as a DAG. Each stage declares its dependencies and a parallel_with
# group. The runner schedules stages whose deps are satisfied in parallel; the
# legacy linear pipeline becomes a special case (every stage depends on the prior).
#
# Schema:
# name — stage class under Master::Stages
# deps — list of stage names that must complete first; [] for entry
# parallel_with — list of peer stages that may run concurrently (advisory)
# timeout_s — optional per-stage deadline
# skippable — true if the stage may be elided when input lacks a precondition
---
- { name: Intake, deps: [], parallel_with: [], timeout_s: 5 }
- { name: Infer, deps: [Intake], parallel_with: [], timeout_s: 10 }
- { name: Route, deps: [Infer], parallel_with: [], timeout_s: 5 }
- { name: Guard, deps: [Route], parallel_with: [], timeout_s: 5 }
- { name: Execute, deps: [Guard], parallel_with: [], timeout_s: 60 }
- { name: Council, deps: [Execute], parallel_with: [Lint], timeout_s: 30, skippable: true }
- { name: Lint, deps: [Execute], parallel_with: [Council], timeout_s: 30, skippable: true }
- { name: Prune, deps: [Council, Lint], parallel_with: [], timeout_s: 5 }
- { name: Memo, deps: [Prune], parallel_with: [], timeout_s: 5 }
- { name: Render, deps: [Memo], parallel_with: [], timeout_s: 5 }# Platform — OS-specific tool mappings (audio, firewall, etc.).
openbsd:
audio: aucat
firewall: pf
http_server: httpd
package_manager: pkg_add
privilege: doas
service_manager: rcctl
shell: ksh
linux:
audio: mpv
firewall: ufw
http_server: nginx
package_manager: apt
privilege: sudo
service_manager: systemctl
shell: bash
macos:
audio: afplay
firewall: pfctl
http_server: nginx
package_manager: brew
privilege: sudo
service_manager: launchctl
shell: zsh
windows:
audio: powershell
firewall: windows_defender
http_server: iis
package_manager: winget
privilege: runas
service_manager: sc
shell: powershell# config_status: aspirational # spec exists, runtime wiring pending
# Named recovery scripts per failure mode. Pairs with rules.yml failure_modes.
# Source: cross-cutting reunification (#49).
playbooks:
rate_limit_storm:
detect: "TRANSIENT_RE matches >=5 in 60s"
action: "halt autoloop; sleep 5min; resume with batch_size halved"
council_oscillation:
detect: "round 2 ran 3 cycles without consensus shift"
action: "escalate to user; archive transcript to data/threads/oscillation/"
self_violation:
detect: "self_test law check failed"
action: "git revert last autoloop batch; freeze autoloop; notify"
yaml_corruption:
detect: "load_yaml returns non-Hash/Array"
action: "git checkout HEAD -- data/${file}; restart"
phantom_loop:
detect: "phantom_recovery fired 3x same session"
action: "kill child agent; rotate model; preserve transcript"# config_status: aspirational # spec exists, runtime wiring pending
# data/prompts/ encrypted at rest; signify-decrypted on demand.
# Source: cross-cutting reunification (#99).
prompt_vault:
enabled: false
cipher: "age | signify+xchacha"
keyring_path: "/etc/master/keyring/"
decrypt_into: tmpfs
redact_in_logs: truesystem: |
Direct mode only.
No meta‑conversation.
Answer with minimal words.
No explanations, apologies, or padding.
Invoke tools immediately, without preamble.
template: |
%{message}system: |
Follow the ReAct paradigm. Keep reasoning concise; intervene only when necessary. Emphasize brevity and concrete actions.
template: |
[Mode: ReAct]
Task: %{message}
---
Reason:
%<reason>s
Action:
%<action>ssystem: |
Generate a concise, numbered plan. Each step must reference at least one evidence slot (e.g., [slot 12]). Conclude with a single, decisive answer.
template: |
[Mode: ReWOO]
Task:
%{message}# config_status: aspirational # spec exists, runtime wiring pending
# Refusal scaffolding when MASTER cannot or should not act.
# Source: OpenAI / Anthropic system-prompt reunification (#74).
capability_disclosure:
no_internet_yet: "MASTER reads local data and runs local tools; no live web access in this turn"
no_secret_creation: "MASTER does not generate secrets; bring your own and rotate via signify"
no_silent_destruction: "MASTER will not run irreversible commands without explicit user confirmation"
refusal_phrasing:
style: "decline once, propose alternative once, stop"
forbidden: ["I'm sorry but I cannot", "as an AI", "I'd be happy to", filler_apology]
example_good: "Out of scope: production push during freeze. Alternative: branch-only commit, deploy after Friday."# Ruby, shell, and git style rules enforced by MASTER.
# Scan rules reference these; Personality injects them into every LLM system prompt.
ruby:
quotes: double # always double-quoted strings; single only inside regex or '\1' backrefs
frozen_string: true # every .rb file must start with # frozen_string_literal: true
comments:
max_lines: 1 # class/module/method comments: 1 line or none
require_why: true # only add when WHY is non-obvious (hidden constraint, workaround)
reassess_on_touch: true # every edit re-reads each comment in the file: delete if obvious,
# rewrite Strunk-and-White style if kept (active voice, omit needless
# words, concrete verbs, one line). No grandfathered fluff.
forbidden:
- what_comments # never describe what the code does — identifiers do that
- yard_doc_blocks # no # Public:, # Returns, # param - style blocks
- section_separators # no # ----, # ====, # ---- Public API ---- etc.
- numbered_steps # no # 1., # 2. inline step comments
- multi_line_prose # cut verbosity; one line survives, paragraph does not
line_order:
rule: "Reorder lines/blocks so the most important content comes first. Newspaper inverted pyramid."
rationale: "A reader who stops halfway must still have the gist. Public API > primary behavior > helpers > privates > edge cases."
sequence:
- "frozen_string_literal + requires"
- "module/class declaration + headline docstring (≤1 line)"
- "public API methods, ordered by call-frequency / importance"
- "primary algorithm or main loop"
- "private helpers in order of dependency"
- "constants and lookup tables (unless small enough to inline at top)"
- "edge-case handlers, rescue branches, fallback paths"
applies_to: [ruby, yaml, erb, js, css, html, sh, md]
enforced_by: "sweep IMPORTANCE_ORDER technique; council Maintainer + Layperson personas"
bugs_to_avoid:
- pattern: "Dir.chdir"
reason: "process-wide; thread-unsafe in multi-threaded agents"
fix: "pass -C root to git; expand paths with File.expand_path"
- pattern: "Prism.parse(src, freeze: true)"
reason: "freeze: kwarg dropped in Ruby 3.4"
fix: "Prism.parse(src)"
- pattern: "next if condition inside flat_map"
reason: "next if returns nil into flat_map, producing nil entries in output"
fix: "next [] if condition"
- pattern: "rescue => e (multi-line bare rescue)"
reason: "unclear; explicitly name StandardError for clarity"
fix: "rescue StandardError => e"
- pattern: "rescue nil (inline rescue returning nil)"
reason: "inline rescue already catches StandardError; rescue nil is correct idiom"
note: "do NOT change to rescue StandardError — that returns the class object, not nil"
- pattern: "@bus&.publish(...) || value"
reason: "when bus is present, returns bus result (truthy), masking the real value"
fix: "call @bus&.publish(...) on its own line; return value separately"
- pattern: "backtick shell commands with interpolation"
reason: "shell injection risk"
fix: "Open3.capture2e('cmd', '-flag', arg) with arg arrays"
- pattern: "system/Open3 with string interpolation"
reason: "shell injection risk"
fix: "Open3.capture2e(*%w[cmd -flag], variable) with separate arguments"
- pattern: "mutate state before publishing event that reads old state"
reason: "event receives new state instead of previous state"
fix: "capture prev = current before mutation; use prev in publish/return"
naming:
spell_out: true # no abbreviations: index not idx, signature not sig, temporary_path not tmp
forbidden_abbreviations:
- idx
- sig
- tmp
- buf
- val
- ret
- obj
- str
- arr
- num
- cnt
- ptr
- msg # unless it IS the domain term (e.g., a Message object named msg is ok if short-lived)
rule: "Spell identifiers out. Domain names can be short (id, url, ip) — abbreviations cannot."
prefer_string_methods:
rule: "Prefer start_with? / include? / end_with? / split over regex when string methods suffice."
rationale: "Regex is expressive but noisy. Use it when patterns require it, not as a default."
prefer:
- "str.start_with?(prefix) over str.match?(/^prefix/)"
- "str.include?(substr) over str.match?(/substr/)"
- "str.end_with?(suffix) over str.match?(/suffix$/)"
- "str.split(sep, n) over str.scan(/pattern/)"
still_use_regex_for:
- 'Character classes: /[a-z]/, /\d+/'
- "Anchored multiline patterns"
- "Alternation with more than 2 branches"
outsource_to_gems:
rule: "If a well-maintained gem solves the problem correctly, use it. Do not reimplement."
rationale: "Gems carry tests, edge cases, and maintenance. Home-grown duplicates carry bugs."
examples:
- "flay for AST-level duplicate detection"
- "reek for code smell analysis"
- "rubocop for style enforcement"
- "prism for Ruby parsing"
caveat: "Evaluate gem quality first: maintained, tested, minimal footprint."
blank_lines:
max_consecutive: 1 # no double blank lines anywhere
rails_stack:
# Current stable versions (May 2026)
rails: "8.1.3"
turbo_rails: "2.0.23" # 9 actions: append prepend before after replace update remove morph refresh
stimulus: "3.x" # static targets, values, outlets API
pagy: "43.x" # Pagy::OPTIONS (not Pagy::DEFAULT — redesigned API in 43.0)
stimulus_reflex: "3.5" # complementary to Turbo; opt-in only for advanced reactive features
asset_pipeline: propshaft # default in Rails 8; do not use Sprockets
javascript: importmap # default; esbuild only when CSS-in-JS components needed
queue: solid_queue # SQLite-backed by default
cache: solid_cache # SQLite-backed by default
cable: solid_cable # SQLite-backed by default
authentication: "rails generate authentication" # built-in, no devise
database: sqlite3 # default; PostgreSQL only when explicitly required
pagy_api:
backend: "include Pagy::Backend" # in ApplicationController
frontend: "include Pagy::Frontend" # in ApplicationHelper
options: "Pagy::OPTIONS[:limit] = 25" # NOT Pagy::DEFAULT (that was 8.x)
overflow: "Pagy::OPTIONS[:overflow] = :last_page"
turbo_stream_actions:
- append
- prepend
- before
- after
- replace
- update
- remove
- morph # morphs DOM — preserves element state; opt-in via data-turbo-permanent
- refresh # triggers full page refresh with morphing
stimulus_api:
targets: "static targets = [\"name\"]" # auto-generates nameTarget, nameTargets, hasNameTarget
values: "static values = { url: String }" # auto-generates urlValue, hasUrlValue, urlValueChanged
outlets: "static outlets = [\"other\"]" # cross-controller communication
lifecycle: [connect, disconnect, initialize] # + nameTargetConnected/Disconnected
stimulus_components:
source: "https://stimulus-components.com"
install: "bin/importmap pin @stimulus-components/<name>"
available:
- { name: character-counter, use: "post/comment character limits" }
- { name: clipboard, use: "copy URL/code to clipboard" }
- { name: dialog, use: "modal dialogs, confirmations" }
- { name: dropdown, use: "nav menus, user menus" }
- { name: notification, use: "toast alerts" }
- { name: carousel, use: "image galleries, product photos" }
- { name: sortable, use: "drag-reorder lists" }
- { name: rails-nested-form, use: "dynamic has-many form fields" }
- { name: password-visibility, use: "show/hide password toggle" }
# Default StimulusReflex stack — Julian Rubisch's pattern set.
# Install for every new Rails 8 + StimulusReflex 3.5 app unless explicitly opted out.
stimulus_reflex_stack:
cubism: "resource-scoped presence (who's-here, typing indicators) over Kredis"
futurism: "lazy-load expensive list rows; futurize(@record) placeholder + IntersectionObserver"
optimism: "real-time ActiveModel validation broadcast as selector morphs (drop-in)"
all_futures: "Redis-backed virtual ActiveModel for facets/wizards without session bloat"
solder: "auto-cache <details>/accordion open state per [user, key]"
cable_ready_callbacks: "after_create_commit / after_update_commit CableReady DSL on AR models"
stimulus_reflex_patterns:
morph_heuristics:
page_morph: "spans multiple regions OR want regular controller render. Always scope with data-reflex-root."
selector_morph: "single element, side-stepping the controller. Inline edit, list-item update, validation hint."
nothing_morph: "no DOM patch — only CableReady ops or dispatch_event to a Stimulus controller."
tool_choice: "Turbo for anything covered by an HTTP verb (navigation, forms). StimulusReflex for everything else."
cable_ready_ops:
morph: "list bodies — pass children_only: true"
inner_html: "form reset (morph won't clear inputs)"
insert_adjacent_html: "infinite scroll, append-only feeds"
outer_html: "Futurism replacement, inline-edit toggle"
dispatch_event: "nothing morphs that kick a Stimulus controller"
add_css_class: "validation hints (Optimism-style)"
set_focus: "post-edit UX"
anti_patterns:
- "mutating state via GET — REST violation"
- "hidden form + Turbo Stream for state — flickers; SR fits better"
- "inline render in reflex — always partials/components, never heredocs"
- "overusing connect lifecycle — prefer Outlets and useIntersection"
- "morph to clear form inputs — use inner_html"
- "class attributes where a tag selector works"
- "hardcoded step counts in wizards"
- "session-backed wizard state on multi-server — use kredis or all_futures"
named_patterns:
infinite_scroll: "InfiniteScrollReflex#load_more + sentinel div + insert_adjacent_html before sentinel"
inline_edit: "ToggleReflex toggles show ↔ edit partial via selector morph"
wizard: "WizardReflex#step dispatching on @current_step; state in kredis or all_futures"
nested_form: "NestedFormReflex#add_fields uses .build + fields_for; needs accepts_nested_attributes_for"
validation_inline: "Optimism + debounced:input event (not raw input — prevents flooding)"
autosave: "Submittable concern; before_reflex branches create/update via element.dataset.signed_id"
core_web_vitals:
lcp: "<2.0s" # Largest Contentful Paint (tightened from 2.5s in March 2026)
inp: "responsive" # Interaction to Next Paint
cls: "< 0.1" # no layout shifts — set explicit width/height on images and embeds
font_display: "swap" # font-display: swap in all font-face rules
rubocop_omakase:
quotes: double # double-quoted strings everywhere in app/
hash_syntax: modern # { a: :b } not { :a => :b }
trailing_commas: true # in multi-line arrays/hashes/arguments
method_calls: "Foo.method not Foo::method"
test_assertions: "assert_not not assert !"
realtime_hierarchy:
- "Turbo Drive — full-page navigation"
- "Turbo Frames — scoped page updates"
- "Turbo Streams — server-push DOM operations"
- "Stimulus — client-side interactivity"
- "StimulusReflex — opt-in for advanced RPC reactive features"
shell:
decorations_forbidden:
- "=== banner ===" # no ASCII section banners
- "--- separator ---"
- "*** header ***"
- "emoji in print/echo output" # no ✅ ❌ 🚀 etc. in scripts
- "numbered step comments" # no # Step 1:, # Phase 2: etc.
credentials_forbidden: true # never hardcode passwords/tokens in scripts
prefer:
- "pure zsh parameter expansion over external tools (see zsh_patterns.yml)"
- "Open3.capture2e with arg arrays in Ruby over shell interpolation"
- "File.expand_path over pwd + concatenation"
- "print -r -- \"$(<file)\" to read files in zsh (not cat, not bare < file via SSH — triggers pager)"
- "lines=(\"${(@f)$(<file)}\") for line arrays; last 50: print -l $lines[-50,-1]"
git:
commit_style:
voice: active # "Fix bug" not "Fixed bug", "Add feature" not "Added feature"
format: "type: short summary\n\nBody if needed."
subject_max: 72
no_what_if_diff_shows: true # don't describe what changed if the diff makes it obvious
separate_concerns: true # don't mix bug fixes with style changes in one commit
forbidden:
- "Dir.chdir in Ruby before git commands"
- "string-interpolated git commands"
- "rm -rf in deploy scripts without explicit guard"
# Operator directives — distilled from the human operator's feedback memory.
# Personality injects these verbatim into every system prompt so MASTER and its
# LLM agents apply the same rules the operator applies to their own work.
operator_directives:
- "Autoproceed once approved: execute the full backlog without per-step go/no-go."
- "No new files without approval: edit originals in place; never _v2/_new/staging copies."
- "Frequent small commits: one commit per meaningful change, never batched."
- "Mandatory lint/beautify on touch: full pass, not just changed lines."
- "Always autofix violations: run /sweep immediately after any /scan finds violations."
- "Read every comment in a touched file: delete if it restates code, rewrite Strunk-and-White if kept."
- "Reorder files by importance on every touch: public API > primary > helpers > privates > edge cases."
- "No heavy work on Termux/Android: defer Ruby runs, large clones, mass ops to the VPS."
- "Bare HTML/CSS targeting: nav a not .nav__link; tag helper; no class attrs on tag-targetable elements."
- "Update README.md after any behavior/capability/surface change, no prompting."
- "Restart MASTER after every web edit: doas rcctl restart master per scp under MASTER/web/."
- "No Python: Ruby only for scripting."
- "Proper casing in prose; no === ---- [ok] • | ASCII decorations. Boot dmesg banner is sacred."
- "Pair violations with opportunities — every scan output surfaces both, never just bugs."
- "Aim for 2x architectural wins over 5% incremental fixes; ask what shape, not what tweak."
- "Subrule findings carry the subrule id (HEDGE, PREAMBLE, OCP, LSP, NN_GROUP), not just the parent."
- "When similar code repeats across files, default to merge/decouple/flatten before local patching."
- "Architecture data shapes are not sacred — re-examine them periodically for misfit."
- "After landing a batch, surface what's next or structurally off — don't itemize the diff."
- "Best-of-N for non-trivial autofixes: generate candidates, score by violation delta, pick winner."
# Conversation directives — how MASTER addresses the user. Operator_directives
# above shape MASTER's coding work; these shape its dialogue, voice, and
# social register. Personality injects them verbatim into the system prompt.
conversation_directives:
- "Track what the user already knows; don't restate background they've established this session."
- "Use the user's name when they've shared it; never invent one."
- "When the user asks X, surface adjacent Y they likely also want — don't wait to be asked."
- "Mirror the user's politeness register; terse to terse, formal to formal, profane to profane."
- "When uncertain about intent, ask one focused question instead of guessing and acting."
- "After a heavy exchange, respect silence; don't fill space with unprompted new threads."
- "Note relational milestones in memory: first share of X, breakthroughs, recurring concerns."
- "Disagree gracefully and concretely; never sycophantically agree to keep rapport."
- "Calibrate humor to the user's register; never humor a tense moment."
- "When looping the same point, acknowledge it; don't restate."
- "Trust differs by domain: high in OpenBSD/Ruby, lower in subjective UI/voice calls — say so."
# html, css, typography, nielsen, a11y — restored from master4.yml/master7.yml
# (universal_quality_framework v66)
html:
semantic_only: true
bare_tag_targeting: true # nav a not .nav__link; section, article, aside, etc.
forbidden:
- divitis # no excessive nesting; no styling-only divs
- class_attribute_when_tag_targetable
- non_semantic_markup
- copy_paste_html
- framework_class_explosion # no class="row col-md-6 mt-4 px-2 d-flex" soup
landmarks:
- header
- nav
- main
- article
- section
- aside
- footer
forms:
label_required: every input has a label
input_type_specific: email/url/tel/date — never bare type=text
autocomplete: "set autocomplete on every meaningful input"
css:
targeting: bare_tag_first # tag selectors > attribute selectors > id; class only when nothing else fits
layer_order: [base, components, utilities]
custom_properties: ":root with --safe-top, --safe-right, --safe-bottom, --safe-left"
units:
length: rem # px only for borders <2px and 1px hairlines
typography: "rem with clamp() for fluid type"
spacing: "rem multiples of .25 (4px grid)"
forbidden:
- "!important except for utility overrides"
- inline_style_attributes
- vendor_prefixes_in_2026 # autoprefixer or skip
- framework_class_bloat
perf:
content_visibility: "auto on long-scroll sections"
will_change: "only when actually animating, then remove"
typography:
style: swiss # objective, hierarchical, generous whitespace
families:
sans: "Helvetica, Arial, system-ui, sans-serif"
mono: "ui-monospace, Menlo, Consolas, monospace"
scale:
base: 16px
ratio: 1.25 # major-third
leading: 1.5 # body
measure: 65ch # ideal line length
rules:
- "one type family per surface (mono OR sans, not both unless purposeful)"
- "size hierarchy via scale — never arbitrary px values"
- "color contrast >= WCAG 2.2 AAA on body text (7:1)"
- "tracking: tighten display, loosen all-caps (.08em)"
- "no centered body copy; left-align for left-to-right languages"
nielsen_heuristics:
- { id: 1, name: visibility_of_system_status, rule: "every async action shows progress within 100ms" }
- { id: 2, name: match_real_world, rule: "use users' language; mirror real-world conventions" }
- { id: 3, name: user_control_and_freedom, rule: "undo, cancel, escape from every flow" }
- { id: 4, name: consistency_and_standards, rule: "platform conventions; consistent terminology across surface" }
- { id: 5, name: error_prevention, rule: "constraints + confirmation > error messages" }
- { id: 6, name: recognition_over_recall, rule: "show options; don't make users remember" }
- { id: 7, name: flexibility_and_efficiency, rule: "shortcuts for experts; defaults for novices" }
- { id: 8, name: aesthetic_and_minimalist_design, rule: "every element earns its place; cut ruthlessly" }
- { id: 9, name: help_users_recognize_recover_errors, rule: "plain language; suggest the fix; one-click recovery" }
- { id: 10, name: help_and_documentation, rule: "context-sensitive; concrete examples; searchable" }
accessibility:
target: wcag_2_2_aaa
requirements:
- keyboard_navigation_complete
- focus_visible_always
- aria_only_when_html_insufficient
- reduced_motion_respected # @media (prefers-reduced-motion: reduce)
- color_scheme_respected # @media (prefers-color-scheme: dark)
- color_not_only_signal
- text_resizable_to_200pct
- skip_to_main_link
- alt_text_meaningful_or_empty
forbidden:
- tabindex_above_zero
- autoplay_media_with_sound
- removing_focus_outline_without_replacement
- text_in_images_for_meaning
parametric_design:
principle: "components vary along measured axes (density, scale, contrast) — not by copy-paste variants"
examples:
- "spacing: --gap-{xs,sm,md,lg,xl} = .25/.5/1/2/4 rem"
- "color: oklch with hue/chroma/lightness vars; light+dark via lightness flip"
- "type: clamp(min, fluid, max) per scale step, no per-breakpoint overrides"
cultural_sensitivity:
text_direction: "honor dir=rtl; mirror layouts; logical properties (margin-inline-start)"
locale_specific: "use Intl.DateTimeFormat / NumberFormat; never assume MM/DD/YYYY"
... 2 lines truncated (402 total)# rules.yml — universal structural rules
# scope: codebase > file > unit > line
# applies to: code, prose, law, business, science, design
# golden_rule and protection_tiers live in soul.yml (ABSOLUTE section) — single source.
# detection trichotomy:
# detect_lexical — regex pattern. cheap. handled by LexicalRule + table_lexical_rule.
# detect_structural — names a dedicated AST handler (e.g. long_method, god_class).
# cheap, deterministic, autofix-safe.
# detect_semantic — natural-language prompt. expensive LLM call, batched per file.
# review-only — autofix risky.
# a rule may carry any combination; each axis emits separate findings tagged by id.
paths:
skip_dirs: [.git, vendor, tmp, var, node_modules, .bundle, coverage, log, dist, knowledge]
tree:
max_depth: 2
max_lines: 200
voice:
style: openbsd_dmesg
anti_simulation:
forbidden: [will, would, could, might]
require_evidence:
file_read: "show file content with SHA-256"
modification: "show unified diff"
completion: "show command output"
banned_output:
- headlines
- section_markers
- bullet_lists_without_content
- filler_phrases
- hedging
- sycophancy
strunk:
preambles: ["In summary,", "Consequently,", "Therefore,", "Notably,", "Importantly,"]
hedges: ["will", "would", "might", "could", "perhaps", "seems", "appears"]
endings: ["as a result.", "for this reason.", "thus.", "in effect.", "accordingly."]
code_preambles: ["# TODO: clarify intent", "# FIXME: review edge cases", "# NOTE: performance considerations", "# HACK: temporary workaround", "# REVIEW: assess after refactor"]
apply_to: [prose, comments, documentation, strings]
never_apply_to: [code_logic, algorithms, data_structures]
safeguards:
- never_delete_variable_names
- never_delete_function_calls
- never_simplify_conditional_logic
- never_collapse_diagnostic_output
inverted_pyramid:
- "Lead with the outcome."
- "Provide key evidence next."
- "Add implementation detail last."
preserve:
boot_message: "5-line dmesg style, never collapse to one line"
diagnostic_output: "structured multi-line output is intentional, never compress to abbreviations"
help_text: "include command name, description, and at least one example"
spinner_feedback: "show elapsed time and status, do not remove progress indicators"
refinement_scope:
streamline: "remove redundancy, not information"
polish: "refine wording, not delete output"
minimize: "applies to prompt tokens, not diagnostic output"
zen:
observe: "Read current behavior before changing anything."
simplify: "Reduce moving parts before adding new components."
isolate: "Change one axis at a time with clear boundaries."
verify: "Run checks and gather objective evidence."
reflect: "Capture learning and improve defaults."
# Six Universal Laws — single hierarchical priority for every rule and persona.
# When two rules conflict, the lower-numbered law wins.
laws:
ROBUSTNESS:
priority: 1
principle: "Errors fail safely; security first; handle edge cases."
applies_to: [security, errors, input_validation, resource_management]
SINGULARITY:
priority: 2
principle: "One source of truth; no duplication; data integrity."
applies_to: [duplication, consistency, data_integrity]
LINEARITY:
priority: 3
principle: "Sequential flow; minimal branches; clear path."
applies_to: [control_flow, nesting, complexity]
PROXIMITY:
priority: 4
principle: "Related code together; cohesive modules."
applies_to: [organization, coupling, modules]
ABSTRACTION:
priority: 5
principle: "Right level; no leaky abstractions; appropriate hiding."
applies_to: [interfaces, encapsulation, apis]
DENSITY:
priority: 6
principle: "Information dense; no noise; signal not noise."
applies_to: [verbosity, comments, naming]
# Cognitive biases and anti-patterns — meta-rules above lexical detection.
biases:
critical:
hallucination:
detect: [claim_without_reading, quote_without_source, invented_stats]
apply: cite_or_remove
violates_law: ROBUSTNESS
simulation:
detect: [future_tense, "imperative_we_must", "lets_do_this"]
apply: rewrite_indicative_past
violates_law: DENSITY
completion_theater:
detect: [ellipsis, etcetera, rest_of_placeholder]
apply: complete_or_delete
violates_law: ROBUSTNESS
high:
sycophancy:
detect: ["great question", "absolutely", "excellent", "wonderful"]
apply: delete
violates_law: DENSITY
false_confidence:
detect: hidden_uncertainty
apply: state_uncertainty_explicitly
violates_law: ROBUSTNESS
cognitive_traps: [anchoring, recency, verbosity, pattern_completion, premature_commitment]
# Structural operations — verbs the rewriter may apply, with risk and verify spec.
structural_ops:
preserve_note: "These keep getting deleted in self-runs — DO NOT REMOVE."
verify_after_each: true
ops:
merge: {desc: "combine similar logic", risk: medium, verify: "merged logic identical", supports_law: SINGULARITY}
semantic_regroup: {desc: "reorganize logically", risk: low, verify: "functionality unchanged", supports_law: PROXIMITY}
defrag: {desc: "consolidate fragments", risk: low, verify: "all fragments accessible", supports_law: PROXIMITY}
decouple: {desc: "separate concerns", risk: high, verify: "interfaces preserved", supports_law: ABSTRACTION}
hoist: {desc: "move to proper scope", risk: medium, verify: "scope correct", supports_law: PROXIMITY}
flatten: {desc: "reduce nesting", risk: medium, verify: "logic flow identical", supports_law: LINEARITY}
delete: {desc: "remove dead code", risk: high, verify: "truly dead, no references", supports_law: DENSITY}
expand: {desc: "extract for clarity", risk: low, verify: "extracted correctly", supports_law: ABSTRACTION}
reduce_noise: {desc: "clean messy lines", risk: low, verify: "formatting only, no logic", supports_law: DENSITY}
# Veto patterns — concrete regex detectors that block merge unconditionally.
veto_patterns:
secrets: {detect: 'sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|-----BEGIN.*KEY-----', apply: move_to_env, violates_law: ROBUSTNESS}
sql_injection: {detect: 'execute|query.*#\{', apply: parameterize, violates_law: ROBUSTNESS}
unfinished: {detect: '\.\.\.|TODO|FIXME|pending', apply: complete_or_track, violates_law: ROBUSTNESS}
unsafe_calls: {detect: '\w+\.\w+\((?!&\.)', apply: add_safe_nav, violates_law: ROBUSTNESS}
race_conditions: {detect: 'if.*\n.*=.*\n.*if', apply: add_mutex, violates_law: ROBUSTNESS}
# Beauty — aesthetic anchors from masters of their craft.
# The user is an architect; these are first-class engineering anchors, not decoration.
beauty:
typography_bringhurst:
- choose_appropriate_typeface_for_function
- set_text_in_sizes_that_suit_its_nature
- use_vertical_motion_that_suits_typeface
- rhythm_proportion_modulation_harmony
architecture_ando:
- simplicity_silence_emptiness
- light_shadow_materiality
- geometry_nature_coexistence
- space_between_as_important_as_form
design_rams:
- innovative_useful_aesthetic
- unobtrusive_honest_long_lasting
- thorough_environmentally_friendly
- as_little_design_as_possible
code_martin:
- meaningful_names_intention_revealing
- functions_do_one_thing_small
- comments_explain_why_not_what
- error_handling_separate_from_logic
zen_japanese:
wabi_sabi: imperfect_authentic
ma: emptiness_pause
kanso: eliminate_essence
thresholds:
file:
max_lines: 300
warn_lines: 200
max_bytes: 8192
max_line_length: 80
method:
max_lines: 10
warn_lines: 7
max_params: 3
max_nesting: 2
max_complexity: 4
class:
max_methods: 6
max_instance_vars: 3
max_dependencies: 2