openbsd/
openbsd/files/
openbsd/files/rc.d/
rails/
rails/__shared/
rails/__shared/layouts/
rails/amber/
rails/amber/app/
rails/amber/app/app/
rails/amber/app/app/assets/
rails/amber/app/app/assets/builds/
rails/amber/app/app/assets/images/
rails/amber/app/app/assets/stylesheets/
rails/amber/app/app/channels/
rails/amber/app/app/channels/application_cable/
rails/amber/app/app/controllers/
rails/amber/app/app/controllers/concerns/
rails/amber/app/app/helpers/
rails/amber/app/app/javascript/
rails/amber/app/app/javascript/controllers/
rails/amber/app/app/jobs/
rails/amber/app/app/mailers/
rails/amber/app/app/models/
rails/amber/app/app/models/concerns/
rails/amber/app/app/services/
rails/amber/app/app/views/
rails/amber/app/app/views/ai/
rails/amber/app/app/views/home/
rails/amber/app/app/views/items/
rails/amber/app/app/views/layouts/
rails/amber/app/app/views/outfits/
rails/amber/app/app/views/passwords/
rails/amber/app/app/views/passwords_mailer/
rails/amber/app/app/views/planned_outfits/
rails/amber/app/app/views/posts/
rails/amber/app/app/views/pwa/
rails/amber/app/app/views/registrations/
rails/amber/app/app/views/sessions/
rails/amber/app/app/views/shared/
rails/amber/app/app/views/users/
rails/amber/app/bin/
rails/amber/app/config/
rails/amber/app/config/environments/
rails/amber/app/config/initializers/
rails/amber/app/config/locales/
rails/amber/app/db/
rails/amber/app/db/migrate/
rails/amber/app/lib/
rails/amber/app/lib/tasks/
rails/amber/app/public/
rails/amber/app/script/
rails/amber/app/storage/
rails/baibl/
rails/baibl/app/
rails/baibl/app/app/
rails/baibl/app/app/assets/
rails/baibl/app/app/assets/images/
rails/baibl/app/app/assets/stylesheets/
rails/baibl/app/app/controllers/
rails/baibl/app/app/controllers/concerns/
rails/baibl/app/app/helpers/
rails/baibl/app/app/javascript/
rails/baibl/app/app/javascript/controllers/
rails/baibl/app/app/jobs/
rails/baibl/app/app/mailers/
rails/baibl/app/app/models/
rails/baibl/app/app/models/concerns/
rails/baibl/app/app/views/
rails/baibl/app/app/views/bookmarks/
rails/baibl/app/app/views/highlights/
rails/baibl/app/app/views/layouts/
rails/baibl/app/app/views/pwa/
rails/baibl/app/app/views/scriptures/
rails/baibl/app/bin/
rails/baibl/app/config/
rails/baibl/app/config/environments/
rails/baibl/app/config/initializers/
rails/baibl/app/config/locales/
rails/baibl/app/db/
rails/baibl/app/db/migrate/
rails/baibl/app/lib/
rails/baibl/app/lib/tasks/
rails/baibl/app/public/
rails/baibl/app/script/
rails/baibl/app/storage/
rails/blognet/
rails/blognet/app/
rails/blognet/app/app/
rails/blognet/app/app/assets/
rails/blognet/app/app/assets/images/
rails/blognet/app/app/assets/stylesheets/
rails/blognet/app/app/channels/
rails/blognet/app/app/channels/application_cable/
rails/blognet/app/app/controllers/
rails/blognet/app/app/controllers/concerns/
rails/blognet/app/app/helpers/
rails/blognet/app/app/javascript/
rails/blognet/app/app/javascript/controllers/
rails/blognet/app/app/jobs/
rails/blognet/app/app/mailers/
rails/blognet/app/app/models/
rails/blognet/app/app/models/concerns/
rails/blognet/app/app/views/
rails/blognet/app/app/views/active_storage/
rails/blognet/app/app/views/active_storage/blobs/
rails/blognet/app/app/views/blogs/
rails/blognet/app/app/views/comments/
rails/blognet/app/app/views/layouts/
rails/blognet/app/app/views/layouts/action_text/
rails/blognet/app/app/views/layouts/action_text/contents/
rails/blognet/app/app/views/passwords/
rails/blognet/app/app/views/passwords_mailer/
rails/blognet/app/app/views/posts/
rails/blognet/app/app/views/pwa/
rails/blognet/app/app/views/sessions/
rails/blognet/app/bin/
rails/blognet/app/config/
rails/blognet/app/config/environments/
rails/blognet/app/config/initializers/
rails/blognet/app/config/locales/
rails/blognet/app/db/
rails/blognet/app/db/migrate/
rails/blognet/app/lib/
rails/blognet/app/lib/tasks/
rails/blognet/app/public/
rails/blognet/app/script/
rails/blognet/app/storage/
rails/brgen/
rails/brgen/app/
rails/brgen/app/app/
rails/brgen/app/app/assets/
rails/brgen/app/app/assets/images/
rails/brgen/app/app/assets/stylesheets/
rails/brgen/app/app/channels/
rails/brgen/app/app/channels/application_cable/
rails/brgen/app/app/controllers/
rails/brgen/app/app/controllers/concerns/
rails/brgen/app/app/controllers/dating/
rails/brgen/app/app/controllers/marketplace/
rails/brgen/app/app/controllers/playlist/
rails/brgen/app/app/controllers/takeaway/
rails/brgen/app/app/controllers/tv/
rails/brgen/app/app/helpers/
rails/brgen/app/app/javascript/
rails/brgen/app/app/javascript/controllers/
rails/brgen/app/app/jobs/
rails/brgen/app/app/mailers/
rails/brgen/app/app/models/
rails/brgen/app/app/models/concerns/
rails/brgen/app/app/models/dating/
rails/brgen/app/app/models/marketplace/
rails/brgen/app/app/models/playlist/
rails/brgen/app/app/models/takeaway/
rails/brgen/app/app/models/tv/
rails/brgen/app/app/services/
rails/brgen/app/app/views/
rails/brgen/app/app/views/comments/
rails/brgen/app/app/views/communities/
rails/brgen/app/app/views/conversations/
rails/brgen/app/app/views/dating/
rails/brgen/app/app/views/dating/home/
rails/brgen/app/app/views/dating/matches/
rails/brgen/app/app/views/dating/profiles/
rails/brgen/app/app/views/home/
rails/brgen/app/app/views/layouts/
rails/brgen/app/app/views/marketplace/
rails/brgen/app/app/views/marketplace/categories/
rails/brgen/app/app/views/marketplace/listings/
rails/brgen/app/app/views/messages/
rails/brgen/app/app/views/passwords/
rails/brgen/app/app/views/passwords_mailer/
rails/brgen/app/app/views/playlist/
rails/brgen/app/app/views/playlist/playlists/
rails/brgen/app/app/views/playlist/tracks/
rails/brgen/app/app/views/posts/
rails/brgen/app/app/views/pwa/
rails/brgen/app/app/views/sessions/
rails/brgen/app/app/views/shared/
rails/brgen/app/app/views/takeaway/
rails/brgen/app/app/views/takeaway/menu_items/
rails/brgen/app/app/views/takeaway/orders/
rails/brgen/app/app/views/takeaway/restaurants/
rails/brgen/app/app/views/tv/
rails/brgen/app/app/views/tv/channels/
rails/brgen/app/app/views/tv/home/
rails/brgen/app/app/views/tv/videos/
rails/brgen/app/app/views/typing_indicators/
rails/brgen/app/app/views/votes/
rails/brgen/app/bin/
rails/brgen/app/config/
rails/brgen/app/config/environments/
rails/brgen/app/config/initializers/
rails/brgen/app/config/locales/
rails/brgen/app/db/
rails/brgen/app/db/migrate/
rails/brgen/app/lib/
rails/brgen/app/lib/tasks/
rails/brgen/app/public/
rails/brgen/app/script/
rails/brgen/app/storage/
rails/brgen/app/test/
rails/brgen/app/test/controllers/
rails/brgen/app/test/fixtures/
rails/brgen/app/test/fixtures/files/
rails/brgen/app/test/helpers/
rails/brgen/app/test/integration/
rails/brgen/app/test/models/
rails/brgen/subapps/
rails/brgen/subapps/dating/
rails/brgen/subapps/marketplace/
rails/brgen/subapps/playlist/
rails/brgen/subapps/takeaway/
rails/brgen/subapps/tv/
rails/bsdports/
rails/bsdports/app/
rails/bsdports/app/app/
rails/bsdports/app/app/assets/
rails/bsdports/app/app/assets/images/
rails/bsdports/app/app/assets/stylesheets/
rails/bsdports/app/app/controllers/
rails/bsdports/app/app/controllers/concerns/
rails/bsdports/app/app/helpers/
rails/bsdports/app/app/javascript/
rails/bsdports/app/app/javascript/controllers/
rails/bsdports/app/app/jobs/
rails/bsdports/app/app/mailers/
rails/bsdports/app/app/models/
rails/bsdports/app/app/models/concerns/
rails/bsdports/app/app/views/
rails/bsdports/app/app/views/categories/
rails/bsdports/app/app/views/comments/
rails/bsdports/app/app/views/layouts/
rails/bsdports/app/app/views/ports/
rails/bsdports/app/app/views/pwa/
rails/bsdports/app/bin/
rails/bsdports/app/config/
rails/bsdports/app/config/environments/
rails/bsdports/app/config/initializers/
rails/bsdports/app/config/locales/
rails/bsdports/app/db/
rails/bsdports/app/db/migrate/
rails/bsdports/app/lib/
rails/bsdports/app/lib/tasks/
rails/bsdports/app/public/
rails/bsdports/app/script/
rails/bsdports/app/storage/
rails/hjerterom/
rails/hjerterom/app/
rails/hjerterom/app/app/
rails/hjerterom/app/app/assets/
rails/hjerterom/app/app/assets/images/
rails/hjerterom/app/app/assets/stylesheets/
rails/hjerterom/app/app/controllers/
rails/hjerterom/app/app/controllers/concerns/
rails/hjerterom/app/app/helpers/
rails/hjerterom/app/app/javascript/
rails/hjerterom/app/app/javascript/controllers/
rails/hjerterom/app/app/jobs/
rails/hjerterom/app/app/mailers/
rails/hjerterom/app/app/models/
rails/hjerterom/app/app/models/concerns/
rails/hjerterom/app/app/views/
rails/hjerterom/app/app/views/community/
rails/hjerterom/app/app/views/food_listings/
rails/hjerterom/app/app/views/home/
rails/hjerterom/app/app/views/layouts/
rails/hjerterom/app/app/views/pwa/
rails/hjerterom/app/app/views/resources/
rails/hjerterom/app/bin/
rails/hjerterom/app/config/
rails/hjerterom/app/config/environments/
rails/hjerterom/app/config/initializers/
rails/hjerterom/app/config/locales/
rails/hjerterom/app/db/
rails/hjerterom/app/db/migrate/
rails/hjerterom/app/lib/
rails/hjerterom/app/lib/tasks/
rails/hjerterom/app/public/
rails/hjerterom/app/script/
rails/hjerterom/app/storage/
README.md
openbsd/README.md
openbsd/files/httpd.conf
openbsd/files/pf.stage1.conf
openbsd/files/pf.stage2.conf
openbsd/files/renew-certs.sh
openbsd/files/smtpd.conf
openbsd/openbsd.sh
postpro.rb
rails/@shared_functions.sh
rails/README.md
rails/__shared/@active_storage_and_imageprocessing.sh
rails/__shared/@ai.sh
rails/__shared/@airbnb_features.sh
rails/__shared/@common.sh
rails/__shared/@devise.sh
rails/__shared/@features_base.sh
rails/__shared/@instant_messaging.sh
rails/__shared/@live_cam_streaming.sh
rails/__shared/@live_streaming.sh
rails/__shared/@messenger_features.sh
rails/__shared/@postgresql.sh
rails/__shared/@posts.sh
rails/__shared/@pwa.sh
rails/__shared/@rails_new.sh
rails/__shared/@reddit_features.sh
rails/__shared/@redis.sh
rails/__shared/@twitter_features.sh
rails/__shared/@yarn.sh
rails/__shared/layouts/_flash.html.erb
rails/__shared/layouts/_footer.html.erb
rails/__shared/layouts/_meta.html.erb
rails/__shared/layouts/_nav.html.erb
rails/__shared/layouts/application.html.erb
rails/__shared/layouts/visualizer.js
rails/amber/@shared_functions.sh
rails/amber/README.md
rails/amber/amber.sh
rails/amber/app/Dockerfile
rails/amber/app/Gemfile
rails/amber/app/README.md
rails/amber/app/Rakefile
rails/amber/app/app/channels/application_cable/connection.rb
rails/amber/app/app/controllers/ai_controller.rb
rails/amber/app/app/controllers/application_controller.rb
rails/amber/app/app/controllers/concerns/authentication.rb
rails/amber/app/app/controllers/follows_controller.rb
rails/amber/app/app/controllers/home_controller.rb
rails/amber/app/app/controllers/items_controller.rb
rails/amber/app/app/controllers/outfits_controller.rb
rails/amber/app/app/controllers/passwords_controller.rb
rails/amber/app/app/controllers/planned_outfits_controller.rb
rails/amber/app/app/controllers/posts_controller.rb
rails/amber/app/app/controllers/registrations_controller.rb
rails/amber/app/app/controllers/sessions_controller.rb
rails/amber/app/app/controllers/users_controller.rb
rails/amber/app/app/helpers/application_helper.rb
rails/amber/app/app/javascript/application.js
rails/amber/app/app/javascript/controllers/animated_number_controller.js
rails/amber/app/app/javascript/controllers/application.js
rails/amber/app/app/javascript/controllers/auto_submit_controller.js
rails/amber/app/app/javascript/controllers/character_counter_controller.js
rails/amber/app/app/javascript/controllers/clipboard_controller.js
rails/amber/app/app/javascript/controllers/dialog_controller.js
rails/amber/app/app/javascript/controllers/dropdown_controller.js
rails/amber/app/app/javascript/controllers/filter_controller.js
rails/amber/app/app/javascript/controllers/hello_controller.js
rails/amber/app/app/javascript/controllers/index.js
rails/amber/app/app/javascript/controllers/notification_controller.js
rails/amber/app/app/javascript/controllers/sortable_controller.js
rails/amber/app/app/javascript/controllers/textarea_autogrow_controller.js
rails/amber/app/app/javascript/controllers/timeago_controller.js
rails/amber/app/app/jobs/application_job.rb
rails/amber/app/app/mailers/application_mailer.rb
rails/amber/app/app/mailers/passwords_mailer.rb
rails/amber/app/app/models/application_record.rb
rails/amber/app/app/models/current.rb
rails/amber/app/app/models/follow.rb
rails/amber/app/app/models/item.rb
rails/amber/app/app/models/outfit.rb
rails/amber/app/app/models/outfit_item.rb
rails/amber/app/app/models/planned_outfit.rb
rails/amber/app/app/models/post.rb
rails/amber/app/app/models/session.rb
rails/amber/app/app/models/user.rb
rails/amber/app/app/services/wardrobe_ai_service.rb
rails/amber/app/app/services/weather_service.rb
rails/amber/app/app/views/ai/_analysis.html.erb
rails/amber/app/app/views/ai/_item_tags.html.erb
rails/amber/app/app/views/ai/capsule.html.erb
rails/amber/app/app/views/ai/color_palette.html.erb
rails/amber/app/app/views/ai/declutter_guide.html.erb
rails/amber/app/app/views/ai/mood_board.html.erb
rails/amber/app/app/views/ai/occasion_map.html.erb
rails/amber/app/app/views/ai/search.html.erb
rails/amber/app/app/views/ai/suggest_outfits.html.erb
rails/amber/app/app/views/home/index.html.erb
rails/amber/app/app/views/items/_form.html.erb
rails/amber/app/app/views/items/_item.html.erb
rails/amber/app/app/views/items/edit.html.erb
rails/amber/app/app/views/items/index.html.erb
rails/amber/app/app/views/items/new.html.erb
rails/amber/app/app/views/items/show.html.erb
rails/amber/app/app/views/layouts/application.html.erb
rails/amber/app/app/views/layouts/mailer.html.erb
rails/amber/app/app/views/layouts/mailer.text.erb
rails/amber/app/app/views/outfits/_form.html.erb
rails/amber/app/app/views/outfits/_outfit.html.erb
rails/amber/app/app/views/outfits/edit.html.erb
rails/amber/app/app/views/outfits/index.html.erb
rails/amber/app/app/views/outfits/new.html.erb
rails/amber/app/app/views/outfits/show.html.erb
rails/amber/app/app/views/passwords/edit.html.erb
rails/amber/app/app/views/passwords/new.html.erb
rails/amber/app/app/views/passwords_mailer/reset.html.erb
rails/amber/app/app/views/passwords_mailer/reset.text.erb
rails/amber/app/app/views/planned_outfits/index.html.erb
rails/amber/app/app/views/posts/_post.html.erb
rails/amber/app/app/views/posts/feed.html.erb
rails/amber/app/app/views/posts/index.html.erb
rails/amber/app/app/views/posts/new.html.erb
rails/amber/app/app/views/posts/show.html.erb
rails/amber/app/app/views/pwa/manifest.json.erb
rails/amber/app/app/views/pwa/service-worker.js
rails/amber/app/app/views/registrations/new.html.erb
rails/amber/app/app/views/sessions/new.html.erb
rails/amber/app/app/views/shared/_errors.html.erb
rails/amber/app/app/views/shared/_flash.html.erb
rails/amber/app/app/views/shared/_pagination.html.erb
rails/amber/app/app/views/users/show.html.erb
rails/amber/app/config/application.rb
rails/amber/app/config/boot.rb
rails/amber/app/config/bundler-audit.yml
rails/amber/app/config/cable.yml
rails/amber/app/config/cache.yml
rails/amber/app/config/ci.rb
rails/amber/app/config/database.yml
rails/amber/app/config/deploy.yml
rails/amber/app/config/environment.rb
rails/amber/app/config/environments/development.rb
rails/amber/app/config/environments/production.rb
rails/amber/app/config/environments/test.rb
rails/amber/app/config/falcon.rb
rails/amber/app/config/importmap.rb
rails/amber/app/config/initializers/assets.rb
rails/amber/app/config/initializers/content_security_policy.rb
rails/amber/app/config/initializers/filter_parameter_logging.rb
rails/amber/app/config/initializers/inflections.rb
rails/amber/app/config/initializers/pagy.rb
rails/amber/app/config/initializers/requires.rb
rails/amber/app/config/locales/en.yml
rails/amber/app/config/puma.rb
rails/amber/app/config/queue.yml
rails/amber/app/config/recurring.yml
rails/amber/app/config/routes.rb
rails/amber/app/config/storage.yml
rails/amber/app/db/cable_schema.rb
rails/amber/app/db/cache_schema.rb
rails/amber/app/db/migrate/20260504180350_create_users.rb
rails/amber/app/db/migrate/20260504180352_create_sessions.rb
rails/amber/app/db/migrate/20260504180357_create_active_storage_tables.active_storage.rb
rails/amber/app/db/migrate/20260504180401_create_items.rb
rails/amber/app/db/migrate/20260504180405_create_outfit_items.rb
rails/amber/app/db/migrate/20260504180406_create_planned_outfits.rb
rails/amber/app/db/migrate/20260504180410_add_extended_fields_to_items.rb
rails/amber/app/db/migrate/20260504205505_create_outfits.rb
rails/amber/app/db/migrate/20260504211952_create_follows.rb
rails/amber/app/db/migrate/20260504212306_create_posts.rb
rails/amber/app/db/queue_schema.rb
rails/amber/app/db/schema.rb
rails/amber/app/db/seeds.rb
rails/amber/app/public/robots.txt
rails/baibl/README.md
rails/baibl/app/Dockerfile
rails/baibl/app/Gemfile
rails/baibl/app/README.md
rails/baibl/app/Rakefile
rails/baibl/app/app/controllers/application_controller.rb
rails/baibl/app/app/controllers/bookmarks_controller.rb
rails/baibl/app/app/controllers/concerns/authentication.rb
rails/baibl/app/app/controllers/highlights_controller.rb
rails/baibl/app/app/controllers/passwords_controller.rb
rails/baibl/app/app/controllers/scriptures_controller.rb
rails/baibl/app/app/controllers/sessions_controller.rb
rails/baibl/app/app/helpers/application_helper.rb
rails/baibl/app/app/javascript/application.js
rails/baibl/app/app/javascript/controllers/animated_number_controller.js
rails/baibl/app/app/javascript/controllers/application.js
rails/baibl/app/app/javascript/controllers/auto_submit_controller.js
rails/baibl/app/app/javascript/controllers/character_counter_controller.js
rails/baibl/app/app/javascript/controllers/clipboard_controller.js
rails/baibl/app/app/javascript/controllers/dialog_controller.js
rails/baibl/app/app/javascript/controllers/dropdown_controller.js
rails/baibl/app/app/javascript/controllers/hello_controller.js
rails/baibl/app/app/javascript/controllers/index.js
rails/baibl/app/app/javascript/controllers/notification_controller.js
rails/baibl/app/app/javascript/controllers/sortable_controller.js
rails/baibl/app/app/javascript/controllers/textarea_autogrow_controller.js
rails/baibl/app/app/javascript/controllers/timeago_controller.js
rails/baibl/app/app/jobs/application_job.rb
rails/baibl/app/app/mailers/application_mailer.rb
rails/baibl/app/app/models/application_record.rb
rails/baibl/app/app/models/book.rb
rails/baibl/app/app/models/bookmark.rb
rails/baibl/app/app/models/chapter.rb
rails/baibl/app/app/models/current.rb
rails/baibl/app/app/models/highlight.rb
rails/baibl/app/app/models/reading_plan.rb
rails/baibl/app/app/models/reading_plan_day.rb
rails/baibl/app/app/models/session.rb
rails/baibl/app/app/models/user.rb
rails/baibl/app/app/models/verse.rb
rails/baibl/app/app/views/bookmarks/index.html.erb
rails/baibl/app/app/views/highlights/create.turbo_stream.erb
rails/baibl/app/app/views/highlights/destroy.turbo_stream.erb
rails/baibl/app/app/views/layouts/application.html.erb
rails/baibl/app/app/views/layouts/mailer.html.erb
rails/baibl/app/app/views/layouts/mailer.text.erb
rails/baibl/app/app/views/pwa/manifest.json.erb
rails/baibl/app/app/views/pwa/service-worker.js
rails/baibl/app/app/views/scriptures/book.html.erb
rails/baibl/app/app/views/scriptures/chapter.html.erb
rails/baibl/app/app/views/scriptures/index.html.erb
rails/baibl/app/app/views/scriptures/search.html.erb
rails/baibl/app/config/application.rb
rails/baibl/app/config/boot.rb
rails/baibl/app/config/bundler-audit.yml
rails/baibl/app/config/cable.yml
rails/baibl/app/config/ci.rb
rails/baibl/app/config/database.yml
rails/baibl/app/config/deploy.yml
rails/baibl/app/config/environment.rb
rails/baibl/app/config/environments/development.rb
rails/baibl/app/config/environments/production.rb
rails/baibl/app/config/environments/test.rb
rails/baibl/app/config/importmap.rb
rails/baibl/app/config/initializers/assets.rb
rails/baibl/app/config/initializers/content_security_policy.rb
rails/baibl/app/config/initializers/filter_parameter_logging.rb
rails/baibl/app/config/initializers/inflections.rb
rails/baibl/app/config/locales/en.yml
rails/baibl/app/config/puma.rb
rails/baibl/app/config/routes.rb
rails/baibl/app/config/storage.yml
rails/baibl/app/db/migrate/20260501020807_create_users.rb
rails/baibl/app/db/migrate/20260501020818_create_sessions.rb
rails/baibl/app/db/migrate/20260507120001_create_books.rb
rails/baibl/app/db/migrate/20260507120002_create_chapters.rb
rails/baibl/app/db/migrate/20260507120003_create_verses.rb
rails/baibl/app/db/migrate/20260507120004_create_highlights.rb
rails/baibl/app/db/migrate/20260507120005_create_bookmarks.rb
rails/baibl/app/db/migrate/20260507120006_create_reading_plans.rb
rails/baibl/app/db/migrate/20260507120007_create_reading_plan_days.rb
rails/baibl/app/db/seeds.rb
rails/baibl/app/public/robots.txt
rails/baibl/baibl.sh
rails/blognet/README.md
rails/blognet/app/Dockerfile
rails/blognet/app/Gemfile
rails/blognet/app/README.md
rails/blognet/app/Rakefile
rails/blognet/app/app/channels/application_cable/connection.rb
rails/blognet/app/app/controllers/application_controller.rb
rails/blognet/app/app/controllers/blogs_controller.rb
rails/blognet/app/app/controllers/comments_controller.rb
rails/blognet/app/app/controllers/concerns/authentication.rb
rails/blognet/app/app/controllers/passwords_controller.rb
rails/blognet/app/app/controllers/posts_controller.rb
rails/blognet/app/app/controllers/sessions_controller.rb
rails/blognet/app/app/helpers/application_helper.rb
rails/blognet/app/app/javascript/application.js
rails/blognet/app/app/javascript/controllers/animated_number_controller.js
rails/blognet/app/app/javascript/controllers/application.js
rails/blognet/app/app/javascript/controllers/auto_submit_controller.js
rails/blognet/app/app/javascript/controllers/character_counter_controller.js
rails/blognet/app/app/javascript/controllers/clipboard_controller.js
rails/blognet/app/app/javascript/controllers/dialog_controller.js
rails/blognet/app/app/javascript/controllers/dropdown_controller.js
rails/blognet/app/app/javascript/controllers/hello_controller.js
rails/blognet/app/app/javascript/controllers/index.js
rails/blognet/app/app/javascript/controllers/notification_controller.js
rails/blognet/app/app/javascript/controllers/sortable_controller.js
rails/blognet/app/app/javascript/controllers/textarea_autogrow_controller.js
rails/blognet/app/app/javascript/controllers/timeago_controller.js
rails/blognet/app/app/jobs/application_job.rb
rails/blognet/app/app/mailers/application_mailer.rb
rails/blognet/app/app/mailers/passwords_mailer.rb
rails/blognet/app/app/models/application_record.rb
rails/blognet/app/app/models/blog.rb
rails/blognet/app/app/models/categorization.rb
rails/blognet/app/app/models/category.rb
rails/blognet/app/app/models/comment.rb
rails/blognet/app/app/models/current.rb
rails/blognet/app/app/models/post.rb
rails/blognet/app/app/models/session.rb
rails/blognet/app/app/models/tag.rb
rails/blognet/app/app/models/tagging.rb
rails/blognet/app/app/models/user.rb
rails/blognet/app/app/views/active_storage/blobs/_blob.html.erb
rails/blognet/app/app/views/blogs/_form.html.erb
rails/blognet/app/app/views/blogs/edit.html.erb
rails/blognet/app/app/views/blogs/index.html.erb
rails/blognet/app/app/views/blogs/new.html.erb
rails/blognet/app/app/views/blogs/show.html.erb
rails/blognet/app/app/views/comments/_comment.html.erb
rails/blognet/app/app/views/layouts/action_text/contents/_content.html.erb
rails/blognet/app/app/views/layouts/application.html.erb
rails/blognet/app/app/views/layouts/mailer.html.erb
rails/blognet/app/app/views/layouts/mailer.text.erb
rails/blognet/app/app/views/passwords/edit.html.erb
rails/blognet/app/app/views/passwords/new.html.erb
rails/blognet/app/app/views/passwords_mailer/reset.html.erb
rails/blognet/app/app/views/passwords_mailer/reset.text.erb
rails/blognet/app/app/views/posts/_form.html.erb
rails/blognet/app/app/views/posts/edit.html.erb
rails/blognet/app/app/views/posts/new.html.erb
rails/blognet/app/app/views/posts/show.html.erb
rails/blognet/app/app/views/pwa/manifest.json.erb
rails/blognet/app/app/views/pwa/service-worker.js
rails/blognet/app/app/views/sessions/new.html.erb
rails/blognet/app/config/application.rb
rails/blognet/app/config/boot.rb
rails/blognet/app/config/bundler-audit.yml
rails/blognet/app/config/cable.yml
rails/blognet/app/config/cache.yml
rails/blognet/app/config/ci.rb
rails/blognet/app/config/database.yml
rails/blognet/app/config/deploy.yml
rails/blognet/app/config/environment.rb
rails/blognet/app/config/environments/development.rb
rails/blognet/app/config/environments/production.rb
rails/blognet/app/config/environments/test.rb
rails/blognet/app/config/importmap.rb
rails/blognet/app/config/initializers/assets.rb
rails/blognet/app/config/initializers/content_security_policy.rb
rails/blognet/app/config/initializers/filter_parameter_logging.rb
rails/blognet/app/config/initializers/inflections.rb
rails/blognet/app/config/locales/en.yml
rails/blognet/app/config/puma.rb
rails/blognet/app/config/queue.yml
rails/blognet/app/config/recurring.yml
rails/blognet/app/config/routes.rb
rails/blognet/app/config/storage.yml
rails/blognet/app/db/cable_schema.rb
rails/blognet/app/db/cache_schema.rb
rails/blognet/app/db/migrate/20260501020807_create_users.rb
rails/blognet/app/db/migrate/20260501020818_create_sessions.rb
rails/blognet/app/db/migrate/20260501020848_create_active_storage_tables.active_storage.rb
rails/blognet/app/db/migrate/20260501020920_create_action_text_tables.action_text.rb
rails/blognet/app/db/migrate/20260507120001_create_blogs.rb
rails/blognet/app/db/migrate/20260507120002_create_posts.rb
rails/blognet/app/db/migrate/20260507120003_create_categories.rb
rails/blognet/app/db/migrate/20260507120004_create_categorizations.rb
rails/blognet/app/db/migrate/20260507120005_create_comments.rb
rails/blognet/app/db/migrate/20260507120006_create_tags.rb
rails/blognet/app/db/migrate/20260507120007_create_taggings.rb
rails/blognet/app/db/queue_schema.rb
rails/blognet/app/db/schema.rb
rails/blognet/app/db/seeds.rb
rails/blognet/app/public/robots.txt
rails/blognet/blognet.sh
rails/blognet/blognet_test.sh
rails/brgen/README.md
rails/brgen/README_takeaway.md
rails/brgen/README_tv.md
rails/brgen/app/Dockerfile
rails/brgen/app/Gemfile
rails/brgen/app/README.md
rails/brgen/app/Rakefile
rails/brgen/app/app/channels/application_cable/channel.rb
rails/brgen/app/app/channels/application_cable/connection.rb
rails/brgen/app/app/controllers/application_controller.rb
rails/brgen/app/app/controllers/comments_controller.rb
rails/brgen/app/app/controllers/communities_controller.rb
rails/brgen/app/app/controllers/concerns/authentication.rb
rails/brgen/app/app/controllers/conversations_controller.rb
rails/brgen/app/app/controllers/dating/base_controller.rb
rails/brgen/app/app/controllers/dating/dislikes_controller.rb
rails/brgen/app/app/controllers/dating/home_controller.rb
rails/brgen/app/app/controllers/dating/likes_controller.rb
rails/brgen/app/app/controllers/dating/matches_controller.rb
rails/brgen/app/app/controllers/dating/profiles_controller.rb
rails/brgen/app/app/controllers/follows_controller.rb
rails/brgen/app/app/controllers/home_controller.rb
rails/brgen/app/app/controllers/marketplace/base_controller.rb
rails/brgen/app/app/controllers/marketplace/categories_controller.rb
rails/brgen/app/app/controllers/marketplace/listings_controller.rb
rails/brgen/app/app/controllers/marketplace/orders_controller.rb
rails/brgen/app/app/controllers/messages_controller.rb
rails/brgen/app/app/controllers/passwords_controller.rb
rails/brgen/app/app/controllers/playlist/base_controller.rb
rails/brgen/app/app/controllers/playlist/listens_controller.rb
rails/brgen/app/app/controllers/playlist/playlists_controller.rb
rails/brgen/app/app/controllers/playlist/tracks_controller.rb
rails/brgen/app/app/controllers/playlist_controller.rb
rails/brgen/app/app/controllers/posts_controller.rb
rails/brgen/app/app/controllers/sessions_controller.rb
rails/brgen/app/app/controllers/takeaway/base_controller.rb
rails/brgen/app/app/controllers/takeaway/menu_items_controller.rb
rails/brgen/app/app/controllers/takeaway/orders_controller.rb
rails/brgen/app/app/controllers/takeaway/restaurants_controller.rb
rails/brgen/app/app/controllers/tv/base_controller.rb
rails/brgen/app/app/controllers/tv/channels_controller.rb
rails/brgen/app/app/controllers/tv/home_controller.rb
rails/brgen/app/app/controllers/tv/videos_controller.rb
rails/brgen/app/app/controllers/typing_indicators_controller.rb
rails/brgen/app/app/controllers/votes_controller.rb
rails/brgen/app/app/helpers/application_helper.rb
rails/brgen/app/app/javascript/application.js
rails/brgen/app/app/javascript/controllers/animated_number_controller.js
rails/brgen/app/app/javascript/controllers/application.js
rails/brgen/app/app/javascript/controllers/auto_submit_controller.js
rails/brgen/app/app/javascript/controllers/character_counter_controller.js
rails/brgen/app/app/javascript/controllers/clipboard_controller.js
rails/brgen/app/app/javascript/controllers/dialog_controller.js
rails/brgen/app/app/javascript/controllers/dropdown_controller.js
rails/brgen/app/app/javascript/controllers/hello_controller.js
rails/brgen/app/app/javascript/controllers/index.js
rails/brgen/app/app/javascript/controllers/notification_controller.js
rails/brgen/app/app/javascript/controllers/sortable_controller.js
rails/brgen/app/app/javascript/controllers/textarea_autogrow_controller.js
rails/brgen/app/app/javascript/controllers/timeago_controller.js
rails/brgen/app/app/javascript/controllers/typing_controller.js
rails/brgen/app/app/javascript/controllers/typing_input_controller.js
rails/brgen/app/app/jobs/application_job.rb
rails/brgen/app/app/mailers/application_mailer.rb
rails/brgen/app/app/mailers/passwords_mailer.rb
rails/brgen/app/app/models/application_record.rb
rails/brgen/app/app/models/comment.rb
rails/brgen/app/app/models/community.rb
rails/brgen/app/app/models/concerns/commentable.rb
rails/brgen/app/app/models/concerns/mentionable.rb
rails/brgen/app/app/models/concerns/taggable.rb
rails/brgen/app/app/models/concerns/votable.rb
rails/brgen/app/app/models/conversation.rb
rails/brgen/app/app/models/conversation_participant.rb
rails/brgen/app/app/models/current.rb
rails/brgen/app/app/models/dating.rb
rails/brgen/app/app/models/dating/dislike.rb
rails/brgen/app/app/models/dating/like.rb
rails/brgen/app/app/models/dating/match.rb
rails/brgen/app/app/models/dating/profile.rb
rails/brgen/app/app/models/follow.rb
rails/brgen/app/app/models/hashtag.rb
rails/brgen/app/app/models/marketplace.rb
rails/brgen/app/app/models/marketplace/category.rb
rails/brgen/app/app/models/marketplace/listing.rb
rails/brgen/app/app/models/marketplace/order.rb
rails/brgen/app/app/models/mention.rb
rails/brgen/app/app/models/message.rb
rails/brgen/app/app/models/message_receipt.rb
rails/brgen/app/app/models/playlist.rb
rails/brgen/app/app/models/playlist/listen.rb
rails/brgen/app/app/models/playlist/playlist.rb
rails/brgen/app/app/models/playlist/playlist_track.rb
rails/brgen/app/app/models/playlist/track.rb
rails/brgen/app/app/models/post.rb
rails/brgen/app/app/models/reaction.rb
rails/brgen/app/app/models/session.rb
rails/brgen/app/app/models/stream.rb
rails/brgen/app/app/models/tagging.rb
rails/brgen/app/app/models/takeaway.rb
rails/brgen/app/app/models/takeaway/menu_item.rb
rails/brgen/app/app/models/takeaway/order.rb
rails/brgen/app/app/models/takeaway/order_item.rb
rails/brgen/app/app/models/takeaway/restaurant.rb
rails/brgen/app/app/models/tv.rb
rails/brgen/app/app/models/tv/broadcast.rb
rails/brgen/app/app/models/tv/channel.rb
rails/brgen/app/app/models/tv/subscription.rb
rails/brgen/app/app/models/tv/video.rb
rails/brgen/app/app/models/tv/view_event.rb
rails/brgen/app/app/models/typing_indicator.rb
rails/brgen/app/app/models/user.rb
rails/brgen/app/app/models/vote.rb
rails/brgen/app/app/services/scrape.rb
rails/brgen/app/app/views/comments/_comment.html.erb
rails/brgen/app/app/views/communities/index.html.erb
rails/brgen/app/app/views/communities/new.html.erb
rails/brgen/app/app/views/communities/show.html.erb
rails/brgen/app/app/views/conversations/index.html.erb
rails/brgen/app/app/views/conversations/show.html.erb
rails/brgen/app/app/views/dating/home/index.html.erb
rails/brgen/app/app/views/dating/matches/index.html.erb
rails/brgen/app/app/views/dating/profiles/edit.html.erb
rails/brgen/app/app/views/dating/profiles/new.html.erb
rails/brgen/app/app/views/dating/profiles/show.html.erb
rails/brgen/app/app/views/home/index.html.erb
rails/brgen/app/app/views/layouts/application.html.erb
rails/brgen/app/app/views/layouts/mailer.html.erb
rails/brgen/app/app/views/layouts/mailer.text.erb
rails/brgen/app/app/views/marketplace/categories/show.html.erb
rails/brgen/app/app/views/marketplace/listings/edit.html.erb
rails/brgen/app/app/views/marketplace/listings/index.html.erb
rails/brgen/app/app/views/marketplace/listings/new.html.erb
rails/brgen/app/app/views/marketplace/listings/show.html.erb
rails/brgen/app/app/views/messages/_message.html.erb
rails/brgen/app/app/views/messages/create.turbo_stream.erb
rails/brgen/app/app/views/messages/new.html.erb
rails/brgen/app/app/views/passwords/edit.html.erb
rails/brgen/app/app/views/passwords/new.html.erb
rails/brgen/app/app/views/passwords_mailer/reset.html.erb
rails/brgen/app/app/views/passwords_mailer/reset.text.erb
rails/brgen/app/app/views/playlist/index.html.erb
rails/brgen/app/app/views/playlist/playlists/edit.html.erb
rails/brgen/app/app/views/playlist/playlists/index.html.erb
rails/brgen/app/app/views/playlist/playlists/new.html.erb
rails/brgen/app/app/views/playlist/playlists/show.html.erb
rails/brgen/app/app/views/posts/_post.html.erb
rails/brgen/app/app/views/posts/index.html.erb
rails/brgen/app/app/views/posts/new.html.erb
rails/brgen/app/app/views/posts/show.html.erb
rails/brgen/app/app/views/pwa/manifest.json.erb
rails/brgen/app/app/views/pwa/service-worker.js
rails/brgen/app/app/views/sessions/new.html.erb
rails/brgen/app/app/views/shared/_vote.html.erb
rails/brgen/app/app/views/takeaway/orders/index.html.erb
rails/brgen/app/app/views/takeaway/orders/show.html.erb
rails/brgen/app/app/views/takeaway/restaurants/edit.html.erb
rails/brgen/app/app/views/takeaway/restaurants/index.html.erb
rails/brgen/app/app/views/takeaway/restaurants/new.html.erb
rails/brgen/app/app/views/takeaway/restaurants/show.html.erb
rails/brgen/app/app/views/tv/channels/edit.html.erb
rails/brgen/app/app/views/tv/channels/index.html.erb
rails/brgen/app/app/views/tv/channels/new.html.erb
rails/brgen/app/app/views/tv/channels/show.html.erb
rails/brgen/app/app/views/tv/home/index.html.erb
rails/brgen/app/app/views/tv/videos/_tv_video.html.erb
rails/brgen/app/app/views/tv/videos/new.html.erb
rails/brgen/app/app/views/tv/videos/show.html.erb
rails/brgen/app/app/views/typing_indicators/_indicator.html.erb
rails/brgen/app/app/views/votes/create.turbo_stream.erb
rails/brgen/app/config/application.rb
rails/brgen/app/config/boot.rb
rails/brgen/app/config/bundler-audit.yml
rails/brgen/app/config/cable.yml
rails/brgen/app/config/cache.yml
rails/brgen/app/config/ci.rb
rails/brgen/app/config/database.yml
rails/brgen/app/config/deploy.yml
rails/brgen/app/config/environment.rb
rails/brgen/app/config/environments/development.rb
rails/brgen/app/config/environments/production.rb
rails/brgen/app/config/environments/test.rb
rails/brgen/app/config/falcon.rb
rails/brgen/app/config/importmap.rb
rails/brgen/app/config/initializers/assets.rb
rails/brgen/app/config/initializers/content_security_policy.rb
rails/brgen/app/config/initializers/filter_parameter_logging.rb
rails/brgen/app/config/initializers/inflections.rb
rails/brgen/app/config/locales/en.yml
rails/brgen/app/config/puma.rb
rails/brgen/app/config/queue.yml
rails/brgen/app/config/recurring.yml
rails/brgen/app/config/routes.rb
rails/brgen/app/config/storage.yml
rails/brgen/app/db/cable_schema.rb
rails/brgen/app/db/cache_schema.rb
rails/brgen/app/db/migrate/20260311162114_create_users.rb
rails/brgen/app/db/migrate/20260311162121_create_sessions.rb
rails/brgen/app/db/migrate/20260311162206_create_communities.rb
rails/brgen/app/db/migrate/20260311162227_create_reactions.rb
rails/brgen/app/db/migrate/20260311162235_create_streams.rb
rails/brgen/app/db/migrate/20260311162345_create_posts.rb
rails/brgen/app/db/migrate/20260311162350_create_comments.rb
rails/brgen/app/db/migrate/20260311162355_add_fields_to_users.rb
rails/brgen/app/db/migrate/20260311163039_create_votes.rb
rails/brgen/app/db/migrate/20260311163634_create_follows.rb
rails/brgen/app/db/migrate/20260311163641_create_hashtags.rb
rails/brgen/app/db/migrate/20260311163648_create_taggings.rb
rails/brgen/app/db/migrate/20260311163655_create_mentions.rb
rails/brgen/app/db/migrate/20260311164112_create_conversations.rb
rails/brgen/app/db/migrate/20260311164119_create_conversation_participants.rb
rails/brgen/app/db/migrate/20260311164127_create_messages.rb
rails/brgen/app/db/migrate/20260311164134_create_message_receipts.rb
rails/brgen/app/db/migrate/20260311164141_create_typing_indicators.rb
rails/brgen/app/db/migrate/20260311165000_add_guest_to_users.rb
rails/brgen/app/db/migrate/20260311221744_add_user_description_to_communities.rb
rails/brgen/app/db/migrate/20260505002649_create_tv_channels.rb
rails/brgen/app/db/migrate/20260505002659_create_tv_videos.rb
rails/brgen/app/db/migrate/20260505002711_create_tv_broadcasts.rb
rails/brgen/app/db/migrate/20260505002719_create_tv_subscriptions.rb
rails/brgen/app/db/migrate/20260505002729_create_tv_view_events.rb
rails/brgen/app/db/migrate/20260505014447_create_dating_profiles.rb
rails/brgen/app/db/migrate/20260505014452_create_dating_likes.rb
rails/brgen/app/db/migrate/20260505014457_create_dating_dislikes.rb
rails/brgen/app/db/migrate/20260505014503_create_dating_matches.rb
rails/brgen/app/db/migrate/20260505015400_create_playlist_playlists.rb
rails/brgen/app/db/migrate/20260505015406_create_playlist_tracks.rb
rails/brgen/app/db/migrate/20260505015411_create_playlist_playlist_tracks.rb
rails/brgen/app/db/migrate/20260505015416_create_playlist_listens.rb
rails/brgen/app/db/migrate/20260505015440_create_takeaway_restaurants.rb
rails/brgen/app/db/migrate/20260505015446_create_takeaway_menu_items.rb
rails/brgen/app/db/migrate/20260505015451_create_takeaway_orders.rb
rails/brgen/app/db/migrate/20260505015456_create_takeaway_order_items.rb
rails/brgen/app/db/migrate/20260505015518_create_marketplace_categories.rb
rails/brgen/app/db/migrate/20260505015523_create_marketplace_listings.rb
rails/brgen/app/db/migrate/20260505015530_create_marketplace_orders.rb
rails/brgen/app/db/queue_schema.rb
rails/brgen/app/db/schema.rb
rails/brgen/app/db/seeds.rb
rails/brgen/app/public/robots.txt
rails/brgen/app/test/test_helper.rb
rails/brgen/brgen.sh
rails/brgen/subapps/dating/README.md
rails/brgen/subapps/marketplace/README.md
rails/brgen/subapps/playlist/README.md
rails/brgen/subapps/takeaway/README.md
rails/brgen/subapps/tv/README.md
rails/bsdports/README.md
rails/bsdports/app/Dockerfile
rails/bsdports/app/Gemfile
rails/bsdports/app/README.md
rails/bsdports/app/Rakefile
rails/bsdports/app/app/controllers/application_controller.rb
rails/bsdports/app/app/controllers/categories_controller.rb
rails/bsdports/app/app/controllers/comments_controller.rb
rails/bsdports/app/app/controllers/concerns/authentication.rb
rails/bsdports/app/app/controllers/passwords_controller.rb
rails/bsdports/app/app/controllers/ports_controller.rb
rails/bsdports/app/app/controllers/sessions_controller.rb
rails/bsdports/app/app/helpers/application_helper.rb
rails/bsdports/app/app/javascript/application.js
rails/bsdports/app/app/javascript/controllers/animated_number_controller.js
rails/bsdports/app/app/javascript/controllers/application.js
rails/bsdports/app/app/javascript/controllers/auto_submit_controller.js
rails/bsdports/app/app/javascript/controllers/character_counter_controller.js
rails/bsdports/app/app/javascript/controllers/clipboard_controller.js
rails/bsdports/app/app/javascript/controllers/dialog_controller.js
rails/bsdports/app/app/javascript/controllers/dropdown_controller.js
rails/bsdports/app/app/javascript/controllers/hello_controller.js
rails/bsdports/app/app/javascript/controllers/index.js
rails/bsdports/app/app/javascript/controllers/notification_controller.js
rails/bsdports/app/app/javascript/controllers/sortable_controller.js
rails/bsdports/app/app/javascript/controllers/textarea_autogrow_controller.js
rails/bsdports/app/app/javascript/controllers/timeago_controller.js
rails/bsdports/app/app/jobs/application_job.rb
rails/bsdports/app/app/mailers/application_mailer.rb
rails/bsdports/app/app/models/application_record.rb
rails/bsdports/app/app/models/category.rb
rails/bsdports/app/app/models/comment.rb
rails/bsdports/app/app/models/current.rb
rails/bsdports/app/app/models/dependency.rb
rails/bsdports/app/app/models/port.rb
rails/bsdports/app/app/models/port_update.rb
rails/bsdports/app/app/models/session.rb
rails/bsdports/app/app/models/user.rb
rails/bsdports/app/app/models/watch.rb
rails/bsdports/app/app/views/categories/index.html.erb
rails/bsdports/app/app/views/categories/show.html.erb
rails/bsdports/app/app/views/comments/_comment.html.erb
rails/bsdports/app/app/views/layouts/application.html.erb
rails/bsdports/app/app/views/layouts/mailer.html.erb
rails/bsdports/app/app/views/layouts/mailer.text.erb
rails/bsdports/app/app/views/ports/index.html.erb
rails/bsdports/app/app/views/ports/show.html.erb
rails/bsdports/app/app/views/pwa/manifest.json.erb
rails/bsdports/app/app/views/pwa/service-worker.js
rails/bsdports/app/config/application.rb
rails/bsdports/app/config/boot.rb
rails/bsdports/app/config/bundler-audit.yml
rails/bsdports/app/config/cable.yml
rails/bsdports/app/config/ci.rb
rails/bsdports/app/config/database.yml
rails/bsdports/app/config/deploy.yml
rails/bsdports/app/config/environment.rb
rails/bsdports/app/config/environments/development.rb
rails/bsdports/app/config/environments/production.rb
rails/bsdports/app/config/environments/test.rb
rails/bsdports/app/config/importmap.rb
rails/bsdports/app/config/initializers/assets.rb
rails/bsdports/app/config/initializers/content_security_policy.rb
rails/bsdports/app/config/initializers/filter_parameter_logging.rb
rails/bsdports/app/config/initializers/inflections.rb
rails/bsdports/app/config/locales/en.yml
rails/bsdports/app/config/puma.rb
rails/bsdports/app/config/routes.rb
rails/bsdports/app/config/storage.yml
rails/bsdports/app/db/migrate/20260501020807_create_users.rb
rails/bsdports/app/db/migrate/20260501020818_create_sessions.rb
rails/bsdports/app/db/migrate/20260507120001_create_categories.rb
rails/bsdports/app/db/migrate/20260507120002_create_ports.rb
rails/bsdports/app/db/migrate/20260507120003_create_dependencies.rb
rails/bsdports/app/db/migrate/20260507120004_create_port_updates.rb
rails/bsdports/app/db/migrate/20260507120005_create_watches.rb
rails/bsdports/app/db/migrate/20260507120006_create_comments.rb
rails/bsdports/app/db/seeds.rb
rails/bsdports/app/public/robots.txt
rails/bsdports/bsdports.sh
rails/bsdports/bsdports_test.sh
rails/check_ports.sh
rails/demo.sh
rails/hjerterom/README.md
rails/hjerterom/app/Dockerfile
rails/hjerterom/app/Gemfile
rails/hjerterom/app/README.md
rails/hjerterom/app/Rakefile
rails/hjerterom/app/app/controllers/application_controller.rb
rails/hjerterom/app/app/controllers/community_controller.rb
rails/hjerterom/app/app/controllers/concerns/authentication.rb
rails/hjerterom/app/app/controllers/food_listings_controller.rb
rails/hjerterom/app/app/controllers/food_requests_controller.rb
rails/hjerterom/app/app/controllers/home_controller.rb
rails/hjerterom/app/app/controllers/passwords_controller.rb
rails/hjerterom/app/app/controllers/resources_controller.rb
rails/hjerterom/app/app/controllers/sessions_controller.rb
rails/hjerterom/app/app/helpers/application_helper.rb
rails/hjerterom/app/app/javascript/application.js
rails/hjerterom/app/app/javascript/controllers/animated_number_controller.js
rails/hjerterom/app/app/javascript/controllers/application.js
rails/hjerterom/app/app/javascript/controllers/auto_submit_controller.js
rails/hjerterom/app/app/javascript/controllers/character_counter_controller.js
rails/hjerterom/app/app/javascript/controllers/clipboard_controller.js
rails/hjerterom/app/app/javascript/controllers/dialog_controller.js
rails/hjerterom/app/app/javascript/controllers/dropdown_controller.js
rails/hjerterom/app/app/javascript/controllers/hello_controller.js
rails/hjerterom/app/app/javascript/controllers/index.js
rails/hjerterom/app/app/javascript/controllers/notification_controller.js
rails/hjerterom/app/app/javascript/controllers/sortable_controller.js
rails/hjerterom/app/app/javascript/controllers/textarea_autogrow_controller.js
rails/hjerterom/app/app/javascript/controllers/timeago_controller.js
rails/hjerterom/app/app/jobs/application_job.rb
rails/hjerterom/app/app/mailers/application_mailer.rb
rails/hjerterom/app/app/models/application_record.rb
rails/hjerterom/app/app/models/category.rb
rails/hjerterom/app/app/models/comment.rb
rails/hjerterom/app/app/models/crisis.rb
rails/hjerterom/app/app/models/current.rb
rails/hjerterom/app/app/models/food_listing.rb
rails/hjerterom/app/app/models/food_request.rb
rails/hjerterom/app/app/models/post.rb
rails/hjerterom/app/app/models/resource.rb
rails/hjerterom/app/app/models/session.rb
rails/hjerterom/app/app/models/support_request.rb
rails/hjerterom/app/app/models/user.rb
rails/hjerterom/app/app/views/community/index.html.erb
rails/hjerterom/app/app/views/community/new.html.erb
rails/hjerterom/app/app/views/community/show.html.erb
rails/hjerterom/app/app/views/food_listings/_form.html.erb
rails/hjerterom/app/app/views/food_listings/edit.html.erb
rails/hjerterom/app/app/views/food_listings/index.html.erb
rails/hjerterom/app/app/views/food_listings/new.html.erb
rails/hjerterom/app/app/views/food_listings/show.html.erb
rails/hjerterom/app/app/views/home/index.html.erb
rails/hjerterom/app/app/views/layouts/application.html.erb
rails/hjerterom/app/app/views/layouts/mailer.html.erb
rails/hjerterom/app/app/views/layouts/mailer.text.erb
rails/hjerterom/app/app/views/pwa/manifest.json.erb
rails/hjerterom/app/app/views/pwa/service-worker.js
rails/hjerterom/app/app/views/resources/_form.html.erb
rails/hjerterom/app/app/views/resources/edit.html.erb
rails/hjerterom/app/app/views/resources/index.html.erb
rails/hjerterom/app/app/views/resources/new.html.erb
rails/hjerterom/app/app/views/resources/show.html.erb
rails/hjerterom/app/config/application.rb
rails/hjerterom/app/config/boot.rb
rails/hjerterom/app/config/bundler-audit.yml
rails/hjerterom/app/config/cable.yml
rails/hjerterom/app/config/ci.rb
rails/hjerterom/app/config/database.yml
rails/hjerterom/app/config/deploy.yml
rails/hjerterom/app/config/environment.rb
rails/hjerterom/app/config/environments/development.rb
rails/hjerterom/app/config/environments/production.rb
rails/hjerterom/app/config/environments/test.rb
rails/hjerterom/app/config/importmap.rb
rails/hjerterom/app/config/initializers/assets.rb
rails/hjerterom/app/config/initializers/content_security_policy.rb
rails/hjerterom/app/config/initializers/filter_parameter_logging.rb
rails/hjerterom/app/config/initializers/inflections.rb
rails/hjerterom/app/config/locales/en.yml
rails/hjerterom/app/config/puma.rb
rails/hjerterom/app/config/routes.rb
rails/hjerterom/app/config/storage.yml
rails/hjerterom/app/db/migrate/20260501020807_create_users.rb
rails/hjerterom/app/db/migrate/20260501020818_create_sessions.rb
rails/hjerterom/app/db/migrate/20260507120001_create_categories.rb
rails/hjerterom/app/db/migrate/20260507120002_create_resources.rb
rails/hjerterom/app/db/migrate/20260507120003_create_crises.rb
rails/hjerterom/app/db/migrate/20260507120004_create_food_listings.rb
rails/hjerterom/app/db/migrate/20260507120005_create_food_requests.rb
rails/hjerterom/app/db/migrate/20260507120006_create_posts.rb
rails/hjerterom/app/db/migrate/20260507120007_create_comments.rb
rails/hjerterom/app/db/migrate/20260507120008_create_support_requests.rb
rails/hjerterom/app/db/seeds.rb
rails/hjerterom/app/public/robots.txt
rails/hjerterom/hjerterom.sh
rails/modernize_zsh.sh
rails/rich_editor_system.sh
repligen.rb
# 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
## `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
## `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
## `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
## `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
## `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.
## `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
## `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
## `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
## `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
## `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
endfiles: 790 / lines: 21095 / truncated: 5