diff --git a/.gitignore b/.gitignore index 74231ed..6701a45 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,9 @@ yarn-debug.log* OAUTH_TESTING.md config/credentials/development.yml.enc config/credentials/test.yml.enc + +/config/credentials/production.key +config/credentials/production.yml.enc +.kamal/secrets +.gitignore +KAMAL.md diff --git a/Dockerfile b/Dockerfile index 1375687..1719b5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,9 +28,11 @@ ENV RAILS_ENV="production" \ # Throw-away build stage to reduce size of final image FROM base AS build -# Install packages needed to build gems +# Install packages needed to build gems and Node.js for TailwindCSS RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \ + apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config curl && \ + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install --no-install-recommends -y nodejs && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems @@ -39,9 +41,16 @@ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile +# Install JavaScript dependencies +COPY package.json package-lock.json ./ +RUN npm ci --include=dev + # Copy application code COPY . . +# Build TailwindCSS +RUN npm run build:css + # Precompile bootsnap code for faster boot times RUN bundle exec bootsnap precompile app/ lib/ diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c81..4a7ea0c 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,4 @@ class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" + default from: ENV.fetch("MAILER_FROM_EMAIL", "noreply@stackoverflow-clone.com") layout "mailer" end diff --git a/app/models/answer.rb b/app/models/answer.rb index 2fc8879..33c9bc4 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -2,7 +2,6 @@ class Answer < ApplicationRecord include Votable include Elasticsearch::Model - include Elasticsearch::Model::Callbacks settings index: { number_of_shards: 1, number_of_replicas: 0 } do mappings dynamic: false do diff --git a/app/models/comment.rb b/app/models/comment.rb index 1f0a9e3..6086ee8 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -5,7 +5,6 @@ class Comment < ApplicationRecord validates :body, presence: true, length: { maximum: 2000 } include Elasticsearch::Model - include Elasticsearch::Model::Callbacks settings index: { number_of_shards: 1, number_of_replicas: 0 } do mappings dynamic: false do diff --git a/app/models/question.rb b/app/models/question.rb index e0b5e10..9feb2ba 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -2,7 +2,6 @@ class Question < ApplicationRecord include Votable include Elasticsearch::Model - include Elasticsearch::Model::Callbacks settings index: { number_of_shards: 1, number_of_replicas: 0 } do mappings dynamic: false do diff --git a/app/models/user.rb b/app/models/user.rb index 4d869b6..9982534 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,7 +4,6 @@ class User < ApplicationRecord :confirmable, :omniauthable, omniauth_providers: [ :google_oauth2 ] include Elasticsearch::Model - include Elasticsearch::Model::Callbacks settings index: { number_of_shards: 1, number_of_replicas: 0 } do mappings dynamic: false do diff --git a/config/deploy.yml b/config/deploy.yml index 5e3d901..1bab561 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -1,116 +1,101 @@ -# Name of your application. Used to uniquely configure containers. service: stackoverflow_clone -# Name of the container image. -image: your-user/stackoverflow_clone +image: amsak701/stclone -# Deploy to these servers. servers: web: - - 192.168.0.1 - # job: - # hosts: - # - 192.168.0.1 - # cmd: bin/jobs + - 90.156.228.95 -# 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 - -# Credentials for your image host. registry: - # Specify the registry server, if you're not using Docker Hub - # server: registry.digitalocean.com / ghcr.io / ... - username: your-user - - # Always use an access token rather than real password when possible. + username: + - KAMAL_REGISTRY_USERNAME password: - KAMAL_REGISTRY_PASSWORD -# Inject ENV variables into containers (secrets come from .kamal/secrets). env: secret: - - RAILS_MASTER_KEY + - SECRET_KEY_BASE + - GOOGLE_CLIENT_ID + - GOOGLE_CLIENT_SECRET + - SIDEKIQ_USERNAME + - SIDEKIQ_PASSWORD + - SMTP_ADDRESS + - SMTP_USERNAME + - SMTP_PASSWORD + 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 stackoverflow_clone-db for a db accessory server on same machine via local kamal docker network. - # DB_HOST: 192.168.0.2 + RAILS_ENV: production + RAILS_LOG_TO_STDOUT: "1" + RAILS_SERVE_STATIC_FILES: "true" + WEB_CONCURRENCY: "2" + RAILS_MAX_THREADS: "5" + REDIS_URL: redis://stackoverflow_clone-redis:6379/0 + ELASTICSEARCH_URL: http://stackoverflow_clone-elasticsearch:9200 + APP_HOST: "90.156.228.95" + MAILER_FROM_EMAIL: "noreply@stackoverflow-clone.com" + SMTP_PORT: "587" + SMTP_DOMAIN: "90.156.228.95" - # Log everything from Rails - # RAILS_LOG_LEVEL: debug - -# Aliases are triggered with "bin/kamal ". 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" - -# 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: - "stackoverflow_clone_storage:/rails/storage" + - "stackoverflow_clone_log:/rails/log" + - "stackoverflow_clone_tmp:/rails/tmp" - -# 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: + remote: 90.156.228.95 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: 3.2.6 - # 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: redis:7.0 -# host: 192.168.0.2 -# port: 6379 -# directories: -# - data:/data +proxy: + host: 90.156.228.95 + + +accessories: + redis: + image: redis:7-alpine + host: 90.156.228.95 + port: "6379" + directories: + - redis-data:/data + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4 + host: 90.156.228.95 + port: "9200" + env: + clear: + discovery.type: single-node + xpack.security.enabled: "false" + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + directories: + - es-data:/usr/share/elasticsearch/data + + sidekiq: + image: amsak701/stclone + host: 90.156.228.95 + cmd: bundle exec sidekiq -C config/sidekiq.yml + volumes: + - "stackoverflow_clone_storage:/rails/storage" + - "stackoverflow_clone_log:/rails/log" + - "stackoverflow_clone_tmp:/rails/tmp" + env: + secret: + - SECRET_KEY_BASE + - SMTP_ADDRESS + - SMTP_USERNAME + - SMTP_PASSWORD + clear: + RAILS_ENV: production + REDIS_URL: redis://stackoverflow_clone-redis:6379/0 + ELASTICSEARCH_URL: http://stackoverflow_clone-elasticsearch:9200 + APP_HOST: "90.156.228.95" + MAILER_FROM_EMAIL: "noreply@stackoverflow-clone.com" + SMTP_PORT: "587" + SMTP_DOMAIN: "90.156.228.95" diff --git a/config/environments/production.rb b/config/environments/production.rb index bdcd01d..eda4157 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -25,13 +25,15 @@ config.active_storage.service = :local # Assume all access to the app is happening through a SSL-terminating reverse proxy. - config.assume_ssl = true + # Disabled for IP-based deployment without SSL certificate + config.assume_ssl = false # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true + # Note: Disabled for IP-based deployment. Enable when using a domain with proper SSL certificate. + config.force_ssl = false # Skip http-to-https redirect for the default health check endpoint. - # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + 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 ] @@ -49,25 +51,63 @@ # 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 } } + # Use Sidekiq for background jobs + config.active_job.queue_adapter = :sidekiq - # 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 + # Mailer configuration + config.action_mailer.raise_delivery_errors = true + config.action_mailer.perform_deliveries = true + config.action_mailer.deliver_later_queue_name = :default # Set host to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "example.com" } - - # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. - # config.action_mailer.smtp_settings = { - # user_name: Rails.application.credentials.dig(:smtp, :user_name), - # password: Rails.application.credentials.dig(:smtp, :password), - # address: "smtp.example.com", - # port: 587, - # authentication: :plain - # } + config.action_mailer.default_url_options = { + host: ENV.fetch("APP_HOST", "90.156.228.95"), + protocol: "http" # Use http for IP-based deployment without SSL + } + + smtp_credentials = if ENV["RAILS_MASTER_KEY"].present? && ENV["RAILS_MASTER_KEY"].length == 32 + begin + { + address: Rails.application.credentials.dig(:smtp, :address), + port: Rails.application.credentials.dig(:smtp, :port), + domain: Rails.application.credentials.dig(:smtp, :domain), + user_name: Rails.application.credentials.dig(:smtp, :user_name), + password: Rails.application.credentials.dig(:smtp, :password) + } + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError => e + puts "⚠ Could not decrypt credentials: #{e.class}" + {} + rescue => e + puts "⚠ Credentials error: #{e.message}" + {} + end + else + puts "⚠ RAILS_MASTER_KEY not configured, using ENV variables only" + {} + end + + smtp_address = ENV["SMTP_ADDRESS"] || smtp_credentials[:address] + + if smtp_address.present? + config.action_mailer.perform_deliveries = true + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: smtp_address, + port: (ENV["SMTP_PORT"] || smtp_credentials[:port] || 587).to_i, + domain: ENV["SMTP_DOMAIN"] || smtp_credentials[:domain], + user_name: ENV["SMTP_USERNAME"] || smtp_credentials[:user_name], + password: ENV["SMTP_PASSWORD"] || smtp_credentials[:password], + authentication: :plain, + enable_starttls_auto: true, + open_timeout: 10, + read_timeout: 10 + } + else + # SMTP not configured - disable email delivery (dev mode) + config.action_mailer.perform_deliveries = false + config.action_mailer.delivery_method = :test + # Logger not available during initialization, warning will be logged at runtime if needed + end # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation cannot be found). @@ -80,11 +120,13 @@ 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` - # ] - # + config.hosts = [ + "90.156.228.95" # Allow requests from server IP + # Add your domain here when you have one: + # "yourdomain.com", + # /.*\.yourdomain\.com/ + ] + # Skip DNS rebinding protection for the default health check endpoint. - # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + config.host_authorization = { exclude: ->(request) { request.path == "/up" } } end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 27992c4..47ba52c 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -9,12 +9,26 @@ # Use this hook to configure devise mailer, warden hooks and so forth. # Many of these configuration options can be set straight in your model. Devise.setup do |config| - # The secret key used by Devise. Devise uses this key to generate - # random tokens. Changing this key will render invalid all existing - # confirmation, reset password and unlock tokens in the database. - # Devise will use the `secret_key_base` as its `secret_key` - # by default. You can change it below and use your own secret key. - # config.secret_key = '050b1fdbc8ecf2fc3aef40193adecc2a5b68f5dbf50f27d802aefe8d88ae83598e8bbebac835ecb9fba4b11faa6e7ad271e9c2ca3a373cdcd234fd837f50bbe5' + # Use Rails secret_key_base for Devise (safe and secure by default) + # Only use DEVISE_SECRET_KEY if it's explicitly set and has proper length (>= 64 bytes) + devise_key = ENV["DEVISE_SECRET_KEY"] + if devise_key.present? && devise_key.length >= 64 + config.secret_key = devise_key + else + # Fallback to Rails secret_key_base (recommended for production) + # Safely access secret_key_base which may try to decrypt credentials + begin + config.secret_key = Rails.application.secret_key_base + rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError => e + # Credentials decryption failed - generate a fallback secret + # This will be regenerated on each boot, so sessions will be invalidated + puts "⚠ WARNING: Using temporary secret key. Sessions will not persist across restarts." + config.secret_key = ENV.fetch("SECRET_KEY_BASE") do + require "securerandom" + SecureRandom.hex(64) + end + end + end # ==> Controller configuration # Configure the parent class to the devise controllers. @@ -24,7 +38,7 @@ # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class # with default "from" parameter. - config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" + config.mailer_sender = ENV.fetch("MAILER_FROM_EMAIL", "noreply@stackoverflow-clone.com") # Configure the class responsible to send e-mails. # config.mailer = 'Devise::Mailer' @@ -273,12 +287,18 @@ # up on your models and hooks. # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' - config.omniauth :google_oauth2, Rails.application.credentials.dig(:google, :client_id), Rails.application.credentials.dig(:google, :client_secret), { - scope: "email,profile", - prompt: "select_account", - image_aspect_ratio: "square", - image_size: 50 - } + # Google OAuth2 configuration - using environment variables for production + google_client_id = ENV["GOOGLE_CLIENT_ID"] + google_client_secret = ENV["GOOGLE_CLIENT_SECRET"] + + if google_client_id.present? && google_client_secret.present? + config.omniauth :google_oauth2, google_client_id, google_client_secret, { + scope: "email,profile", + prompt: "select_account", + image_aspect_ratio: "square", + image_size: 50 + } + end # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or diff --git a/config/initializers/devise_async.rb b/config/initializers/devise_async.rb new file mode 100644 index 0000000..90cec2e --- /dev/null +++ b/config/initializers/devise_async.rb @@ -0,0 +1,9 @@ +if Rails.env.production? + Devise::Models::Confirmable.class_eval do + protected + + def send_devise_notification(notification, *args) + devise_mailer.send(notification, self, *args).deliver_later + end + end +end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 8e02906..f4305e9 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -6,7 +6,11 @@ end admin_authenticator do - current_user || redirect_to(new_user_session_url) + if current_user&.admin? + current_user + else + redirect_to(root_url, alert: "Access denied. Admin privileges required.") + end end default_scopes :public diff --git a/config/initializers/elasticsearch.rb b/config/initializers/elasticsearch.rb index 35a5c5b..16d709e 100644 --- a/config/initializers/elasticsearch.rb +++ b/config/initializers/elasticsearch.rb @@ -4,17 +4,32 @@ # export ELASTICSEARCH_URL="https://elastic:password@localhost:9200" # If you don't have a local CA certificate handy, you can disable SSL verification in development only. -require "elasticsearch/model" +# Skip Elasticsearch initialization during asset precompilation +unless ENV["SECRET_KEY_BASE_DUMMY"] == "1" || defined?(::Rails::Console) + require "elasticsearch/model" -Elasticsearch::Model.client = Elasticsearch::Client.new( - url: ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200"), - transport_options: { - request: { timeout: 5, open_timeout: 2 } - }, - ssl: Rails.env.development? ? { verify: false } : {} -) + Elasticsearch::Model.client = Elasticsearch::Client.new( + url: ENV.fetch("ELASTICSEARCH_URL", "http://127.0.0.1:9200"), + transport_options: { + request: { timeout: 5, open_timeout: 2 } + }, + ssl: Rails.env.development? ? { verify: false } : {} + ) -# Optional: log to Rails logger in development -if Rails.env.development? - Elasticsearch::Model.client.transport.logger = Logger.new(STDOUT) + # Optional: log to Rails logger in development + if Rails.env.development? + Elasticsearch::Model.client.transport.logger = Logger.new(STDOUT) + end + + # Check Elasticsearch connection and log status + begin + if Elasticsearch::Model.client.ping + Rails.logger.info "✓ Elasticsearch connected successfully at #{ENV.fetch('ELASTICSEARCH_URL', 'http://127.0.0.1:9200')}" + end + rescue Faraday::ConnectionFailed, Elastic::Transport::Transport::Error => e + Rails.logger.warn "⚠ Elasticsearch connection failed: #{e.message}" + Rails.logger.warn "Search functionality will be limited. Please ensure Elasticsearch is running." + rescue => e + Rails.logger.error "✗ Elasticsearch error: #{e.message}" + end end diff --git a/config/routes.rb b/config/routes.rb index 587bf94..aba1e61 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,17 @@ Rails.application.routes.draw do require "sidekiq/web" + + # Protect Sidekiq Web UI with basic authentication + Sidekiq::Web.use Rack::Auth::Basic do |username, password| + ActiveSupport::SecurityUtils.secure_compare( + ::Digest::SHA256.hexdigest(username), + ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_USERNAME", "admin")) + ) & ActiveSupport::SecurityUtils.secure_compare( + ::Digest::SHA256.hexdigest(password), + ::Digest::SHA256.hexdigest(ENV.fetch("SIDEKIQ_PASSWORD", "changeme")) + ) + end + use_doorkeeper devise_for :users, controllers: { omniauth_callbacks: "users/omniauth_callbacks" diff --git a/config/sidekiq.yml b/config/sidekiq.yml index b757ac2..f5721a9 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -1,9 +1,10 @@ :concurrency: 5 :queues: - default + - mailers :schedule: daily_digest: - cron: "0 8 * * *" # каждый день в 8 часов по времени сервера + cron: "0 8 * * *" class: "DailyDigestJob" queue: default diff --git a/db/migrate/20251013000050_add_admin_to_users.rb b/db/migrate/20251013000050_add_admin_to_users.rb new file mode 100644 index 0000000..655d227 --- /dev/null +++ b/db/migrate/20251013000050_add_admin_to_users.rb @@ -0,0 +1,6 @@ +class AddAdminToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :admin, :boolean, default: false, null: false + add_index :users, :admin + end +end diff --git a/db/schema.rb b/db/schema.rb index b26f4e0..8a15748 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_08_175000) do +ActiveRecord::Schema[8.0].define(version: 2025_10_13_000050) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -160,6 +160,8 @@ t.string "confirmation_token" t.datetime "confirmation_sent_at" t.string "unconfirmed_email" + t.boolean "admin", default: false, null: false + t.index ["admin"], name: "index_users_on_admin" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["email"], name: "index_users_on_email", unique: true t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true diff --git a/db/seeds.rb b/db/seeds.rb index 0a510a5..9613adb 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -7,51 +7,125 @@ Question.destroy_all if defined?(Question) User.destroy_all -puts "Creating test users..." -test_user = User.create!( - email: "test@example.com", - password: "password123", - password_confirmation: "password123", - confirmed_at: Time.current -) -puts "Created test user: #{test_user.email} with password: password123" +puts "Creating users..." -second_user = User.create!( - email: "user2@example.com", - password: "password123", - password_confirmation: "password123", - confirmed_at: Time.current -) -puts "Created second user: #{second_user.email} with password: password123" +users_data = [ + { email: "john.doe@example.com", name: "John Doe", password: "john2024secure" }, + { email: "alice.smith@example.com", name: "Alice Smith", password: "alice_dev123" }, + { email: "bob.johnson@example.com", name: "Bob Johnson", password: "bob!secure99" }, + { email: "emma.wilson@example.com", name: "Emma Wilson", password: "emma@rails2024" }, + { email: "mike.brown@example.com", name: "Mike Brown", password: "mikebrown_pass" } +] + +users = [] +puts "\n" + "="*60 +puts "Creating users with credentials:" +puts "="*60 + +users_data.each do |user_data| + user = User.create!( + email: user_data[:email], + password: user_data[:password], + password_confirmation: user_data[:password], + confirmed_at: Time.current + ) + users << user + puts "✓ Email: #{user.email.ljust(30)} | Password: #{user_data[:password]}" +end + +puts "="*60 puts "Creating questions..." questions = [ { - title: "How to use Active Record in Rails?", - body: "I'm new to Rails and would like to know how to properly use Active Record for database operations. What are the best practices?" + title: "How to deploy Rails application with Kamal?", + body: "I want to deploy my Rails 8 application using Kamal. What are the basic steps? I have a VPS with Ubuntu. Need help with configuration and setup." }, { - title: "Differences between has_many and has_many :through", - body: "What's the difference between has_many and has_many :through relationships in Rails? When should I use each of them?" + title: "Sidekiq not processing background jobs", + body: "My Sidekiq workers are running but jobs stay in the queue and never get processed. Redis connection is fine. What could be wrong?" }, { - title: "How to set up RSpec in a Rails project?", - body: "I want to add tests to my Rails project. How do I properly set up RSpec and what gems should I add?" + title: "Best way to send emails in Rails production?", + body: "I'm getting SMTP timeout errors when sending emails. My hosting provider blocks port 587. What are alternative solutions for sending transactional emails?" }, { - title: "Problem with validation in Rails", - body: "When saving my model, I'm getting a validation error, but I can't figure out what's wrong. What's the best way to debug validations in Rails?" + title: "How to use Active Record associations?", + body: "I'm learning Rails and confused about has_many, belongs_to and has_many :through. Can someone explain with simple examples when to use each?" }, { - title: "How to implement authentication in Rails?", - body: "What are the different ways to implement user authentication in Rails? Should I use Devise or build my own solution?" + title: "Docker volume permissions issue", + body: "Getting 'permission denied' when trying to access SQLite database from Docker container. How do I fix volume permissions?" + }, + { + title: "TailwindCSS not loading styles in production", + body: "My TailwindCSS styles work fine in development but don't load in production after deployment. Asset pipeline is configured. What am I missing?" + }, + { + title: "How to implement real-time features with ActionCable?", + body: "I want to add real-time notifications to my Rails app. Should I use ActionCable or something else? Looking for a simple tutorial." + }, + { + title: "RSpec vs Minitest - which is better?", + body: "Starting a new Rails project. Should I use RSpec or stick with Minitest? What are the pros and cons of each testing framework?" + }, + { + title: "Optimizing database queries in Rails", + body: "My application is slow with N+1 queries. I've heard about includes, joins, and eager loading. What's the difference and when should I use each?" + }, + { + title: "Authentication with Devise: setup guide", + body: "New to Devise gem. How do I set it up properly? Need basic authentication with email confirmation and password reset functionality." } ] # Create questions and answers created_questions = [] -questions.each do |question_data| - random_user = [ test_user, second_user ].sample +answers_data = { + 0 => [ # Kamal deployment + "First, install Kamal: `gem install kamal`. Then run `kamal init` in your project. Configure deploy.yml with your server details and run `kamal setup` followed by `kamal deploy`.", + "Check out the official Kamal documentation. You'll need Docker on your VPS. Basic steps: 1) Setup SSH keys 2) Configure deploy.yml 3) Run kamal setup 4) Deploy with kamal deploy. Make sure ports 80/443 are open." + ], + 1 => [ # Sidekiq not processing + "Check if Sidekiq is listening to the correct queue. Your jobs might be in 'mailers' queue but Sidekiq only processes 'default'. Add the queue to config/sidekiq.yml under :queues section.", + "Also verify that Sidekiq container has access to the same Redis instance as your app. Use `Sidekiq::Queue.new('default').size` to check queue size." + ], + 2 => [ # Email sending + "Use SendGrid or Mailgun API instead of SMTP. They provide HTTP APIs that bypass port restrictions. SendGrid free tier gives 100 emails/day. Just add their gem and configure with API key.", + "Another option is Amazon SES. It's cheap and reliable. You can also try port 2525 which some providers don't block." + ], + 3 => [ # Active Record associations + "belongs_to: child model has foreign key. has_many: parent can have multiple children. Example: User has_many :posts, Post belongs_to :user. Use has_many :through for many-to-many like User has_many :groups, through: :memberships.", + "Think of it like parent-child. belongs_to = this record belongs to another. has_many = this record owns many others. Through adds a join table in between for complex relationships." + ], + 4 => [ # Docker permissions + "Add volumes with proper permissions in your docker-compose or Kamal config. Make sure the app user inside container matches the volume owner. You can also run `chown -R rails:rails /rails/storage` in your Dockerfile.", + "Check your Dockerfile USER directive. SQLite needs write access to the directory. Mount volumes with correct user:group mapping." + ], + 5 => [ # TailwindCSS production + "Run `rails assets:precompile` before deployment. Make sure your build:css npm script runs during Docker build. Check if RAILS_SERVE_STATIC_FILES=true is set in production.", + "Verify that application.css imports your tailwind css file. Also check that NODE_ENV=production during build so Tailwind purges unused styles correctly." + ], + 6 => [ # ActionCable real-time + "ActionCable is built into Rails! Create a channel: `rails g channel Notifications`. Subscribe in JavaScript with `consumer.subscriptions.create`. Broadcast from server with `ActionCable.server.broadcast`.", + "For simple notifications, ActionCable is perfect. For complex real-time features, consider AnyCable for better performance. Check Rails guides for complete ActionCable tutorial." + ], + 7 => [ # RSpec vs Minitest + "RSpec has more readable syntax and powerful matchers. Minitest is simpler and included with Rails. For beginners, Minitest is easier. For large teams, RSpec's expressiveness helps. Both are excellent choices.", + "I prefer RSpec for its describe/context/it syntax. It makes tests read like documentation. But Minitest is faster and has less magic. Choose based on team preference." + ], + 8 => [ # Query optimization + "Use `includes` for eager loading to prevent N+1: `User.includes(:posts)`. Use `joins` for filtering: `User.joins(:posts).where(posts: {status: 'published'})`. Install bullet gem to detect N+1 queries in development.", + "The key difference: includes loads associated records (2 queries), joins just filters (1 query but doesn't load). Use select to limit columns: `User.select(:id, :email)` for better performance." + ], + 9 => [ # Devise setup + "Add gem 'devise' to Gemfile, run bundle install, then `rails generate devise:install`. Follow the instructions, then `rails generate devise User`. Run migrations. Configure mailer settings in config/environments.", + "Devise is great for quick auth setup. After generation, customize views with `rails generate devise:views`. Enable confirmable module in your User model for email confirmation." + ] +} + +questions.each_with_index do |question_data, index| + random_user = users.sample question = Question.create!( title: question_data[:title], @@ -60,20 +134,35 @@ created_at: rand(1..30).days.ago ) created_questions << question - puts "Created question: #{question.title} by #{random_user.email}" - - # Create 1-3 answers for each question - rand(1..3).times do |i| - answer_user = [ test_user, second_user ].sample + puts "Created question: #{question.title}" + # Create answers for this question + question_answers = answers_data[index] || [] + question_answers.each_with_index do |answer_body, answer_index| + answer_user = users.sample + answer = question.answers.create!( - body: "Answer #{i+1} to the question about #{question.title.downcase}. This contains a detailed explanation with code examples and recommendations.", + body: answer_body, user: answer_user, - created_at: rand(1..question.created_at.to_i).seconds.ago + created_at: question.created_at + rand(1..48).hours, + best: answer_index == 0 && rand < 0.5 # 50% chance first answer is marked as best ) - puts " - Created answer #{i+1} by #{answer_user.email} for question: #{question.title}" + puts " ✓ Created answer by #{answer_user.email}" end end -puts "Seed data created successfully!" -puts "Created #{Question.count} questions and #{Answer.count} answers." +puts "\n" + "="*70 +puts "🎉 Seed data created successfully!" +puts "="*70 +puts "📊 Statistics:" +puts " Users: #{User.count}" +puts " Questions: #{Question.count}" +puts " Answers: #{Answer.count}" +puts "\n🔐 Login Credentials:" +puts "-"*70 + +users_data.each do |user_data| + puts " Email: #{user_data[:email].ljust(35)} | Password: #{user_data[:password]}" +end + +puts "="*70