From 144047d0e19a2c091f18247880f441875740c9db Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:10:23 +0100 Subject: [PATCH 01/28] Fix .ruby-version to match Gemfile ruby requirement (3.4.9) The Gemfile specifies ruby "3.4.9" but .ruby-version was still 3.4.8, causing CI to install the wrong Ruby version and fail with "Your Ruby version is 3.4.8, but your Gemfile specified 3.4.9". Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 From 1092b923c05bba40902464ed2a6ae1ec4fbed2db Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:07:36 +0100 Subject: [PATCH 02/28] Enable Content-Security-Policy headers in report-only mode The CSP initializer was entirely commented out, leaving the application with no Content-Security-Policy headers. This is a security risk as it allows unrestricted loading of scripts, styles, and other resources. Configure CSP with sensible defaults for this app's needs: - default_src/script_src/connect_src: self + https - img_src/font_src: self + https + data URIs - object_src: none (blocks Flash/Java embeds) - style_src includes unsafe_inline for Tailwind compatibility - Nonce-based script protection via importmap Starts in report-only mode so violations are logged without breaking the site. Once verified in production, report_only can be removed. Fixes the medium-severity CSP bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- .../initializers/content_security_policy.rb | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 53538c1..322c5f4 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,27 +1,25 @@ # frozen_string_literal: true -# 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 and inline scripts -# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } -# config.content_security_policy_nonce_directives = %w(script-src) -# -# # Report violations without enforcing the policy. -# # config.content_security_policy_report_only = true -# end +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, :unsafe_inline + policy.connect_src :self, :https + end + + # Generate session nonces for permitted importmap and inline scripts + config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } + config.content_security_policy_nonce_directives = %w[script-src] + + # Report violations without enforcing the policy initially. + # Remove this line once the policy has been verified in production. + config.content_security_policy_report_only = true +end From 36c75f0e994f7f46645e5f4de654e204f2f8f377 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:07:46 +0100 Subject: [PATCH 03/28] Add nil guard to Tool#filename to prevent NoMethodError Tool#filename calls url.split("/") without checking if url is nil. When a Firestore document lacks a fileURL field, url is nil and calling .split on it raises NoMethodError, crashing the tools index page. Add a nil guard that returns nil early when url is absent, and use URI parsing for consistency with how Mod#filename handles URLs. Fixes the medium-severity Tool#filename crash reported in #76. Co-Authored-By: Claude Opus 4.6 --- app/models/tool.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/tool.rb b/app/models/tool.rb index 1db43fb..732493b 100644 --- a/app/models/tool.rb +++ b/app/models/tool.rb @@ -43,7 +43,9 @@ def self.expire_cache end def filename - url.split("/").last + return nil unless url + + URI(url).path.split("/").last end def name_slug From a04a12ac23cc1a218402d4514b2e275c1b1afd3e Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:07:55 +0100 Subject: [PATCH 04/28] Add nil guard to author_slug to prevent NoMethodError on index page The author_slug method in the Displayable concern calls author.parameterize without checking if author is nil. If a single Firestore document lacks an author field, it raises NoMethodError and can crash the entire mods or tools index page since author_slug is called during rendering of every record. Return "unknown" as a safe fallback slug when author is nil, keeping the page rendering and producing a valid URL segment. Fixes the medium-severity author_slug crash reported in #76. Co-Authored-By: Claude Opus 4.6 --- app/models/concerns/displayable.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/concerns/displayable.rb b/app/models/concerns/displayable.rb index 14dc06e..5abff8b 100644 --- a/app/models/concerns/displayable.rb +++ b/app/models/concerns/displayable.rb @@ -19,6 +19,8 @@ def details end def author_slug + return "unknown" unless author + author.parameterize end From 2df3a31851f4dfa31ddbe745f671653c81855158 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:53:39 +0100 Subject: [PATCH 05/28] Fix Tool#filename to match Mod#filename safe approach Use string splitting instead of URI() to avoid URI::InvalidURIError on malformed Firestore URLs, matching the safer approach already used in Mod#filename (commit 2b66be7). Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- app/models/tool.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/app/models/tool.rb b/app/models/tool.rb index 732493b..06c3344 100644 --- a/app/models/tool.rb +++ b/app/models/tool.rb @@ -43,9 +43,9 @@ def self.expire_cache end def filename - return nil unless url + return if url.blank? - URI(url).path.split("/").last + url.split("?").first.split("/").last end def name_slug From e79cca66bf7f5b4211cb5ad21567c54af0264c51 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 10:56:54 +0100 Subject: [PATCH 06/28] Add dark/light theme toggle to header nav --- app/views/layouts/_header.html.erb | 5 +++- app/views/layouts/application.html.erb | 36 ++++++++++++++++++++++++++ config/tailwind.config.js | 1 + 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 369e3e1..2073801 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -3,7 +3,7 @@ <%= image_tag "daedalus-logo.png", width: "64", alt: "Daedalus Logo" %> <%= link_to "Project Daedalus", home_path, class: "drop-shadow-md text-2xl font-bold leading-7 no-underline text-icarus-500 sm:truncate sm:text-4xl sm:py-1 sm:tracking-tight" %> -
+
@@ -13,5 +13,8 @@ +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 022e8e6..97ba177 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -15,6 +15,18 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + @@ -27,5 +39,29 @@ <%= yield %> + + diff --git a/config/tailwind.config.js b/config/tailwind.config.js index 913cb8b..2beaa15 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -1,6 +1,7 @@ const defaultTheme = require("tailwindcss/defaultTheme") module.exports = { + darkMode: "class", content: [ "./public/*.html", "./app/helpers/**/*.rb", From 70bcd09787bb9c4d0bba4995e4d0173197efcc42 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:55:36 +0100 Subject: [PATCH 07/28] Respect OS dark mode preference when no saved theme exists When switching Tailwind from darkMode: "media" to "class", the OS preference is no longer automatically respected. This adds a matchMedia check as fallback when no localStorage preference is saved, preserving the original behavior for first-time visitors. Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- app/views/layouts/application.html.erb | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 97ba177..4604ac4 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -16,15 +16,18 @@ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> From bb586a8be1476d9be29e383f37380ea96f3ed64b Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 03:00:08 +0100 Subject: [PATCH 08/28] Add pagination to mods listing Implements array-based pagination for the mods index when displaying all mods. Search results and author-filtered views show all matches without pagination, as requested in issue #55. - Add PaginationHelper with page windowing and ellipsis support - Add _pagination.html.erb partial with Prev/Next and page numbers - Paginate at 20 mods per page (configurable via DEFAULT_PER_PAGE) - Show "page X of Y" counter when paginated - Styled with existing Tailwind classes for light/dark mode --- app/controllers/mods_controller.rb | 20 ++++++++- app/helpers/pagination_helper.rb | 68 +++++++++++++++++++++++++++++ app/views/mods/_mods.html.erb | 8 +++- app/views/mods/_pagination.html.erb | 49 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 app/helpers/pagination_helper.rb create mode 100644 app/views/mods/_pagination.html.erb diff --git a/app/controllers/mods_controller.rb b/app/controllers/mods_controller.rb index 41858dd..b30663d 100644 --- a/app/controllers/mods_controller.rb +++ b/app/controllers/mods_controller.rb @@ -1,12 +1,19 @@ # frozen_string_literal: true class ModsController < ApplicationController + include PaginationHelper + before_action :authors, only: %i[index show] before_action :mods, only: %i[index show] before_action :set_session, only: %i[index] def index - @mods = find_mods_by_author(sanitize(params[:author])) if params[:author].present? + @filtered = false + + if params[:author].present? + @mods = find_mods_by_author(sanitize(params[:author])) + @filtered = true + end # If we're given a mod ID, try to find it and redirect to the mod's page if @mods.empty? && params[:author].present? @@ -15,7 +22,10 @@ def index end # Perform a search if we have a query - @mods = find_mods(sanitize(params[:query])) if params[:query].present? + if params[:query].present? + @mods = find_mods(sanitize(params[:query])) + @filtered = true + end # Sort the mods if we have a sort key # Disabled for now, as it's redundant with the search @@ -23,6 +33,12 @@ def index @total_mods = @mods.size + # Paginate only when listing all mods (no search query or author filter) + unless @filtered + @pagination = paginate_array(@mods, page: params[:page]) + @mods = @pagination.items + end + if turbo_frame_request? render partial: "mods", locals: { mods: @mods } else diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb new file mode 100644 index 0000000..6cfc6e8 --- /dev/null +++ b/app/helpers/pagination_helper.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module PaginationHelper + DEFAULT_PER_PAGE = 20 + + # Paginates an array and returns the current page slice + def paginate_array(collection, page:, per_page: DEFAULT_PER_PAGE) + page = [page.to_i, 1].max + total = collection.size + total_pages = (total / per_page.to_f).ceil + page = [page, total_pages].min if total_pages > 0 + + offset = (page - 1) * per_page + items = collection[offset, per_page] || [] + + PaginationResult.new(items: items, current_page: page, total_pages: total_pages, total_count: total, per_page: per_page) + end + + class PaginationResult + attr_reader :items, :current_page, :total_pages, :total_count, :per_page + + def initialize(items:, current_page:, total_pages:, total_count:, per_page:) + @items = items + @current_page = current_page + @total_pages = total_pages + @total_count = total_count + @per_page = per_page + end + + def first_page? + current_page <= 1 + end + + def last_page? + current_page >= total_pages + end + + def paginated? + total_pages > 1 + end + + def previous_page + current_page - 1 unless first_page? + end + + def next_page + current_page + 1 unless last_page? + end + + # Returns an array of page numbers with ellipsis markers (nil) for gaps + def page_range(window: 2) + return (1..total_pages).to_a if total_pages <= (window * 2) + 5 + + pages = [] + pages << 1 + + left = [current_page - window, 2].max + right = [current_page + window, total_pages - 1].min + + pages << nil if left > 2 + (left..right).each { |p| pages << p } + pages << nil if right < total_pages - 1 + + pages << total_pages + pages + end + end +end diff --git a/app/views/mods/_mods.html.erb b/app/views/mods/_mods.html.erb index 40b7b2d..08e87b6 100644 --- a/app/views/mods/_mods.html.erb +++ b/app/views/mods/_mods.html.erb @@ -1,6 +1,9 @@ <%= turbo_frame_tag "mods" do %>
-
+
+
<%= @total_mods %> mods<%= " (page #{@pagination.current_page} of #{@pagination.total_pages})" if @pagination&.paginated? %>
+
+
@@ -23,5 +26,6 @@
+ <%= render "pagination" %>
-<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/mods/_pagination.html.erb b/app/views/mods/_pagination.html.erb new file mode 100644 index 0000000..4c493f0 --- /dev/null +++ b/app/views/mods/_pagination.html.erb @@ -0,0 +1,49 @@ +<% if @pagination&.paginated? %> + +<% end %> From 32451bdd67e401a55c200d0560ec1d43f9d6d62f Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:54:58 +0100 Subject: [PATCH 09/28] Add test coverage for PaginationHelper and PaginationResult Covers paginate_array edge cases (empty collection, page clamping, custom per_page) and PaginationResult methods (first/last page, navigation, page_range with ellipsis). Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- spec/helpers/pagination_helper_spec.rb | 123 +++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 spec/helpers/pagination_helper_spec.rb diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/spec/helpers/pagination_helper_spec.rb b/spec/helpers/pagination_helper_spec.rb new file mode 100644 index 0000000..62f5a6b --- /dev/null +++ b/spec/helpers/pagination_helper_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaginationHelper do + include PaginationHelper + + let(:items) { (1..50).to_a } + + describe "#paginate_array" do + it "returns the first page by default" do + result = paginate_array(items, page: 1) + expect(result.items).to eq((1..20).to_a) + expect(result.current_page).to eq(1) + expect(result.total_pages).to eq(3) + expect(result.total_count).to eq(50) + end + + it "returns the correct page slice" do + result = paginate_array(items, page: 2) + expect(result.items).to eq((21..40).to_a) + expect(result.current_page).to eq(2) + end + + it "returns the last page with remaining items" do + result = paginate_array(items, page: 3) + expect(result.items).to eq((41..50).to_a) + end + + it "clamps page to 1 when given 0 or negative" do + result = paginate_array(items, page: 0) + expect(result.current_page).to eq(1) + + result = paginate_array(items, page: -5) + expect(result.current_page).to eq(1) + end + + it "clamps page to last page when exceeding total" do + result = paginate_array(items, page: 999) + expect(result.current_page).to eq(3) + expect(result.items).to eq((41..50).to_a) + end + + it "respects custom per_page" do + result = paginate_array(items, page: 1, per_page: 10) + expect(result.items).to eq((1..10).to_a) + expect(result.total_pages).to eq(5) + end + + it "handles empty collection" do + result = paginate_array([], page: 1) + expect(result.items).to eq([]) + expect(result.total_pages).to eq(0) + expect(result.total_count).to eq(0) + end + end + + describe PaginationHelper::PaginationResult do + subject(:result) do + PaginationHelper::PaginationResult.new( + items: [], current_page: current_page, total_pages: 5, total_count: 100, per_page: 20 + ) + end + + context "on the first page" do + let(:current_page) { 1 } + + it { is_expected.to be_first_page } + it { is_expected.not_to be_last_page } + it { is_expected.to be_paginated } + it { expect(result.previous_page).to be_nil } + it { expect(result.next_page).to eq(2) } + end + + context "on a middle page" do + let(:current_page) { 3 } + + it { is_expected.not_to be_first_page } + it { is_expected.not_to be_last_page } + it { expect(result.previous_page).to eq(2) } + it { expect(result.next_page).to eq(4) } + end + + context "on the last page" do + let(:current_page) { 5 } + + it { is_expected.not_to be_first_page } + it { is_expected.to be_last_page } + it { expect(result.previous_page).to eq(4) } + it { expect(result.next_page).to be_nil } + end + + context "with a single page" do + subject(:result) do + PaginationHelper::PaginationResult.new( + items: [], current_page: 1, total_pages: 1, total_count: 5, per_page: 20 + ) + end + + it { is_expected.not_to be_paginated } + end + + describe "#page_range" do + it "returns all pages when total is small" do + result = PaginationHelper::PaginationResult.new( + items: [], current_page: 1, total_pages: 5, total_count: 100, per_page: 20 + ) + expect(result.page_range).to eq([1, 2, 3, 4, 5]) + end + + it "includes ellipsis for large page counts" do + result = PaginationHelper::PaginationResult.new( + items: [], current_page: 10, total_pages: 20, total_count: 400, per_page: 20 + ) + range = result.page_range + expect(range.first).to eq(1) + expect(range.last).to eq(20) + expect(range).to include(nil) # ellipsis markers + expect(range).to include(10) # current page + end + end + end +end From d4d482f358a9dc2c46843d49e6ffb0af65677bdb Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:08:19 +0100 Subject: [PATCH 10/28] Remove .env from version control and add .env.example template The .gitignore only had .env.* (matching .env.local, .env.production, etc.) but not .env itself, so the base .env file was committed and tracked. While it currently contains non-secret config (project IDs, bucket names), tracking .env files is a security risk as developers may add secrets to it later. Changes: - Add .env to .gitignore so it is no longer tracked - Remove .env from git index (file stays on disk for existing devs) - Add .env.example template so new developers know which vars to set - Whitelist .env.example in .gitignore so the template is tracked Fixes the low-severity .env tracking bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- .env | 8 -------- .env.example | 6 ++++++ .gitignore | 2 ++ 3 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 .env create mode 100644 .env.example diff --git a/.env b/.env deleted file mode 100644 index b8a9baa..0000000 --- a/.env +++ /dev/null @@ -1,8 +0,0 @@ -# PRODUCTION_DB_NAME=project_daedalus_production # Legacy Cloud SQL/Cloud Run — no longer used -# PRODUCTION_DB_USER=daedalus # Legacy Cloud SQL/Cloud Run — no longer used -# CLOUD_SQL_CONNECTION_NAME=projectdaedalus-fb09f:us-central1:project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used -GOOGLE_PROJECT_ID=projectdaedalus-fb09f -STORAGE_BUCKET_NAME=project-daedalus-public -GOOGLE_REGION=us-central1 -# GOOGLE_SERVICE_NAME=project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used -# GOOGLE_INSTANCE_NAME=project-daedalus # Legacy Cloud SQL/Cloud Run — no longer used diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7a853a0 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Copy this file to .env and fill in your values +# cp .env.example .env + +GOOGLE_PROJECT_ID=your-firebase-project-id +STORAGE_BUCKET_NAME=your-storage-bucket-name +GOOGLE_REGION=us-central1 diff --git a/.gitignore b/.gitignore index d047613..e48669d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,9 @@ /app/assets/builds/* !/app/assets/builds/.keep +.env .env.* +!.env.example # Claude Code local settings .claude/ From c96f8f07426b5078d94207c27b6260ed8b7de930 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:08:30 +0100 Subject: [PATCH 11/28] Use author_slug instead of raw author name in mod partial URLs The _mod.html.erb partial passes mod.author (which contains spaces and mixed case) to mod_detail_path, producing ugly URLs with encoded spaces like /mods/Donovan%20Young/some-mod. Change to mod.author_slug which produces clean, parameterized URLs like /mods/donovan-young/some-mod, consistent with how the show action and author filtering already work. Fixes the low-severity ugly URL bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- app/views/mods/_mod.html.erb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/mods/_mod.html.erb b/app/views/mods/_mod.html.erb index e9482c0..194e193 100644 --- a/app/views/mods/_mod.html.erb +++ b/app/views/mods/_mod.html.erb @@ -1,6 +1,6 @@ + data-mods-path-param="<%= mod_detail_path(author: mod.author_slug, slug: mod.slug) %>"> <%= mod.name %> <% if type = mod.preferred_type %> @@ -25,5 +25,6 @@ <% end %> +<<<<<<< HEAD <%= truncate(mod.description.to_s, length: 120) %> - \ No newline at end of file + From 4439ce2b44f2e81d1d9ec1ea25bfc5ebc14ab220 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:08:38 +0100 Subject: [PATCH 12/28] Increase search debounce from 200ms to 400ms to reduce requests The search input debounce was set to 200ms, firing a Turbo Frame request on nearly every keystroke. Increase to 400ms which still feels responsive but significantly reduces unnecessary requests and Firestore reads during active typing. Fixes the low-severity search debounce bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- app/javascript/controllers/mods_controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/controllers/mods_controller.js b/app/javascript/controllers/mods_controller.js index a9f54cc..63ba14c 100644 --- a/app/javascript/controllers/mods_controller.js +++ b/app/javascript/controllers/mods_controller.js @@ -37,7 +37,7 @@ export default class extends Controller { clearTimeout(this.timeout) this.timeout = setTimeout(() => { event.target.form.requestSubmit(); - }, 200) + }, 400) } submit(event) { From f1413b8333262f2048b0902778324d4d26e56d7e Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:08:50 +0100 Subject: [PATCH 13/28] Memoize markdown renderer to avoid recreating objects per call The markdown helper created new CodeRayify and Redcarpet::Markdown instances on every call. On the mods index this means allocating these objects once per mod with a README. Extract into a memoized private method so objects are created once per request and reused for all subsequent renders. Fixes the low-severity markdown renderer performance bug in #76. Co-Authored-By: Claude Opus 4.6 --- app/helpers/application_helper.rb | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c4956b4..0d38801 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -14,15 +14,23 @@ def block_code(code, language) def markdown(text) return if text.blank? - coderayified = CodeRayify.new(filter_html: true, hard_wrap: true) - - options = { - fenced_code_blocks: true, - no_intra_emphasis: true, - autolink: true, - lax_html_blocks: true - } - markdown_to_html = Redcarpet::Markdown.new(coderayified, options) - sanitize(markdown_to_html.render(text)) + sanitize(markdown_renderer.render(text)) + end + + private + + def markdown_renderer + @markdown_renderer ||= begin + coderayified = CodeRayify.new(filter_html: true, hard_wrap: true) + + options = { + fenced_code_blocks: true, + no_intra_emphasis: true, + autolink: true, + lax_html_blocks: true + } + + Redcarpet::Markdown.new(coderayified, options) + end end end From 811834634e9d5145137cc43c3b07eb8271099be4 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:09:00 +0100 Subject: [PATCH 14/28] Enable require_master_key in production environment The require_master_key setting was commented out, meaning the app can boot in production without a master key. Without it, Rails credentials (including Firebase keyfile) can't be decrypted, causing confusing Firestore errors instead of a clear startup failure. Uncomment so production deploys fail fast if RAILS_MASTER_KEY is missing. The Kamal deploy config already provides this as a secret. Fixes the low-severity require_master_key bug reported in #76. Co-Authored-By: Claude Opus 4.6 --- config/environments/production.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/environments/production.rb b/config/environments/production.rb index 391e08d..d790609 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -20,7 +20,7 @@ # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). - # config.require_master_key = true + config.require_master_key = true # Disable serving static files from the `/public` folder by default since # Apache or NGINX already handles this. From f306d35ab1764c5a827ae1e2e62dee8d225044fd Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 02:09:12 +0100 Subject: [PATCH 15/28] Memoize Firestore client to avoid redundant connections The firestore class method creates a new Google::Cloud::Firestore client on every call. Memoize with @firestore ||= so the client is created once per class and reused. The Google Cloud Firestore client is designed to be long-lived and thread-safe. Fixes the low-severity Firestore client performance bug in #76. Co-Authored-By: Claude Opus 4.6 --- app/models/concerns/firestorable.rb | 30 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/app/models/concerns/firestorable.rb b/app/models/concerns/firestorable.rb index 92ebfb1..e1494b5 100644 --- a/app/models/concerns/firestorable.rb +++ b/app/models/concerns/firestorable.rb @@ -7,24 +7,26 @@ module Firestorable included do def self.firestore - credentials = Rails.application.credentials.firebase_keyfile + @firestore ||= begin + credentials = Rails.application.credentials.firebase_keyfile - if credentials.nil? - raise <<~ERROR - Firebase credentials not configured. Please add firebase_keyfile to your Rails credentials. + if credentials.nil? + raise <<~ERROR + Firebase credentials not configured. Please add firebase_keyfile to your Rails credentials. - To configure credentials, run: - EDITOR=nano rails credentials:edit + To configure credentials, run: + EDITOR=nano rails credentials:edit - Then add: - firebase_keyfile: - type: service_account - project_id: your-project-id - # ... other Firebase credentials - ERROR - end + Then add: + firebase_keyfile: + type: service_account + project_id: your-project-id + # ... other Firebase credentials + ERROR + end - Google::Cloud::Firestore.new(credentials: credentials.to_h) + Google::Cloud::Firestore.new(credentials: credentials.to_h) + end end end end From 30ae0e3be720eb316bda26de5f5c907ac8732a4e Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:54:31 +0100 Subject: [PATCH 16/28] Fix Firestore memoization leaking test doubles across examples Reset class-level @firestore instance variable after each test to prevent RSpec doubles from persisting via Firestorable's @firestore ||= memoization pattern. This fixes 8 test failures where leaked doubles caused errors in subsequent examples. Co-Authored-By: Claude Opus 4.6 --- .ruby-version | 2 +- spec/support/firestore.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 7921bd0..7bcbb38 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.8 +3.4.9 diff --git a/spec/support/firestore.rb b/spec/support/firestore.rb index f1859cc..869e40d 100644 --- a/spec/support/firestore.rb +++ b/spec/support/firestore.rb @@ -15,4 +15,16 @@ ) end end + + # Reset memoized Firestore clients after each test to prevent + # RSpec test doubles from leaking across examples via the + # class-level @firestore ||= memoization in Firestorable. + config.after do + [Mod, Tool].each do |klass| + klass.instance_variable_set(:@firestore, nil) if klass.instance_variable_defined?(:@firestore) + end + if defined?(SiteContent) && SiteContent.respond_to?(:instance_variable_set) + SiteContent.instance_variable_set(:@firestore, nil) if SiteContent.instance_variable_defined?(:@firestore) + end + end end From 65778416bc2bca8e60dce0522795c2a024d77e56 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:33:57 +0100 Subject: [PATCH 17/28] Fix RuboCop offenses in pagination helper and specs - Use .positive? instead of > 0 - Break long line in PaginationResult.new - Use described_class instead of explicit class names in specs - Fix context descriptions to start with when/with/without Co-Authored-By: Claude Opus 4.6 --- app/helpers/pagination_helper.rb | 7 +++++-- spec/helpers/pagination_helper_spec.rb | 18 +++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb index 6cfc6e8..cc6988a 100644 --- a/app/helpers/pagination_helper.rb +++ b/app/helpers/pagination_helper.rb @@ -8,12 +8,15 @@ def paginate_array(collection, page:, per_page: DEFAULT_PER_PAGE) page = [page.to_i, 1].max total = collection.size total_pages = (total / per_page.to_f).ceil - page = [page, total_pages].min if total_pages > 0 + page = [page, total_pages].min if total_pages.positive? offset = (page - 1) * per_page items = collection[offset, per_page] || [] - PaginationResult.new(items: items, current_page: page, total_pages: total_pages, total_count: total, per_page: per_page) + PaginationResult.new( + items: items, current_page: page, total_pages: total_pages, + total_count: total, per_page: per_page + ) end class PaginationResult diff --git a/spec/helpers/pagination_helper_spec.rb b/spec/helpers/pagination_helper_spec.rb index 62f5a6b..5308667 100644 --- a/spec/helpers/pagination_helper_spec.rb +++ b/spec/helpers/pagination_helper_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe PaginationHelper do - include PaginationHelper + include described_class let(:items) { (1..50).to_a } @@ -55,14 +55,14 @@ end end - describe PaginationHelper::PaginationResult do + describe described_class::PaginationResult do subject(:result) do - PaginationHelper::PaginationResult.new( + described_class.new( items: [], current_page: current_page, total_pages: 5, total_count: 100, per_page: 20 ) end - context "on the first page" do + context "when on the first page" do let(:current_page) { 1 } it { is_expected.to be_first_page } @@ -72,7 +72,7 @@ it { expect(result.next_page).to eq(2) } end - context "on a middle page" do + context "when on a middle page" do let(:current_page) { 3 } it { is_expected.not_to be_first_page } @@ -81,7 +81,7 @@ it { expect(result.next_page).to eq(4) } end - context "on the last page" do + context "when on the last page" do let(:current_page) { 5 } it { is_expected.not_to be_first_page } @@ -92,7 +92,7 @@ context "with a single page" do subject(:result) do - PaginationHelper::PaginationResult.new( + described_class.new( items: [], current_page: 1, total_pages: 1, total_count: 5, per_page: 20 ) end @@ -102,14 +102,14 @@ describe "#page_range" do it "returns all pages when total is small" do - result = PaginationHelper::PaginationResult.new( + result = described_class.new( items: [], current_page: 1, total_pages: 5, total_count: 100, per_page: 20 ) expect(result.page_range).to eq([1, 2, 3, 4, 5]) end it "includes ellipsis for large page counts" do - result = PaginationHelper::PaginationResult.new( + result = described_class.new( items: [], current_page: 10, total_pages: 20, total_count: 400, per_page: 20 ) range = result.page_range From b6614e10f4f7f86c0c5ff1fde073b61d88f5c791 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:34:28 +0100 Subject: [PATCH 18/28] Update _mod view spec to expect author_slug in path param The template now uses mod.author_slug instead of mod.author for cleaner URLs, so the test expectation needs to match. Co-Authored-By: Claude Opus 4.6 --- spec/views/mods/_mod.html.erb_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/views/mods/_mod.html.erb_spec.rb b/spec/views/mods/_mod.html.erb_spec.rb index 5e5a94b..2afc838 100644 --- a/spec/views/mods/_mod.html.erb_spec.rb +++ b/spec/views/mods/_mod.html.erb_spec.rb @@ -51,8 +51,8 @@ it "includes correct mod_detail_path in data attribute" do render partial: "mods/mod", locals: { mod: mod } - # Path uses mod.author (which gets URL encoded), not author_slug - expect(rendered).to include('data-mods-path-param="/mods/Test%20Author/test-mod"') + # Path uses mod.author_slug (parameterized) for cleaner URLs + expect(rendered).to include('data-mods-path-param="/mods/test-author/test-mod"') end it "triggers download Stimulus action on button click" do From b047264677f20d24510252c08aa6c3ccaeaeac6d Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Sat, 4 Apr 2026 13:37:23 -0400 Subject: [PATCH 19/28] Add .serena/ and .playwright-mcp/ to .gitignore These are local MCP plugin directories that shouldn't be tracked. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 ++++ .serena/.gitignore | 1 - .serena/project.yml | 87 --------------------------------------------- 3 files changed, 6 insertions(+), 88 deletions(-) delete mode 100644 .serena/.gitignore delete mode 100644 .serena/project.yml diff --git a/.gitignore b/.gitignore index d047613..30a89bc 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,10 @@ # Claude Code local settings .claude/ + +# Serena MCP plugin +.serena/ + +# Playwright MCP plugin +.playwright-mcp/ firebase-debug.log diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 14d86ad..0000000 --- a/.serena/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/cache diff --git a/.serena/project.yml b/.serena/project.yml deleted file mode 100644 index 8206bd1..0000000 --- a/.serena/project.yml +++ /dev/null @@ -1,87 +0,0 @@ -# list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp csharp_omnisharp -# dart elixir elm erlang fortran fsharp -# go groovy haskell java julia kotlin -# lua markdown nix pascal perl php -# powershell python python_jedi r rego ruby -# ruby_solargraph rust scala swift terraform toml -# typescript typescript_vts yaml zig -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Free Pascal / Lazarus, use pascal -# Special requirements: -# - csharp: Requires the presence of a .sln file in the project folder. -# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- ruby - -# the encoding used by text files in the project -# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings -encoding: "utf-8" - -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 -ignore_all_files_in_gitignore: true - -# list of additional paths to ignore -# same syntax as gitignore, so you can use * and ** -# Was previously called `ignored_dirs`, please update your config if you are using that. -# Added (renamed) on 2025-04-07 -ignored_paths: [] - -# whether the project is in read-only mode -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. -excluded_tools: [] - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: "" - -project_name: "project_daedalus" -included_optional_tools: [] From 4c163a51916794182530a3ffbe60460ad43bd552 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:41:56 +0100 Subject: [PATCH 20/28] Move theme toggle to Stimulus controller for scoped JS Replaces inline toggleTheme() and DOMContentLoaded listener with a Stimulus theme_controller.js. Uses data-action and data-target attributes instead of global functions and getElementById, preventing potential conflicts with other scripts. The early-load FOUC prevention script in is kept since Stimulus connects after DOMContentLoaded. Co-Authored-By: Claude Opus 4.6 --- .../controllers/theme_controller.js | 29 +++++++++++++++++++ app/views/layouts/_header.html.erb | 8 +++-- app/views/layouts/application.html.erb | 23 --------------- 3 files changed, 34 insertions(+), 26 deletions(-) create mode 100644 app/javascript/controllers/theme_controller.js diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js new file mode 100644 index 0000000..174fb30 --- /dev/null +++ b/app/javascript/controllers/theme_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +// Manages dark/light theme toggle with localStorage persistence. +// Falls back to OS preference when no saved preference exists. +export default class extends Controller { + static targets = ["icon"] + + connect() { + this.updateIcon() + } + + toggle() { + const html = document.documentElement + if (html.classList.contains("dark")) { + html.classList.remove("dark") + localStorage.setItem("theme", "light") + } else { + html.classList.add("dark") + localStorage.setItem("theme", "dark") + } + this.updateIcon() + } + + updateIcon() { + if (!this.hasIconTarget) return + const isDark = document.documentElement.classList.contains("dark") + this.iconTarget.innerHTML = isDark ? "\uD83C\uDF19" : "\u2600\uFE0F" + } +} diff --git a/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 2073801..3ae9325 100644 --- a/app/views/layouts/_header.html.erb +++ b/app/views/layouts/_header.html.erb @@ -13,8 +13,10 @@ - +
+ +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4604ac4..4898c3b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -43,28 +43,5 @@ - From e025efd5cc647f9327e647643089edc342af4610 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:46:53 +0100 Subject: [PATCH 21/28] Fix RuboCop SoleNestedConditional in firestore support Merge nested conditional into outer if to satisfy RuboCop Style/SoleNestedConditional rule. Co-Authored-By: Claude Opus 4.6 --- spec/support/firestore.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/support/firestore.rb b/spec/support/firestore.rb index 869e40d..1f7d8a9 100644 --- a/spec/support/firestore.rb +++ b/spec/support/firestore.rb @@ -23,8 +23,9 @@ [Mod, Tool].each do |klass| klass.instance_variable_set(:@firestore, nil) if klass.instance_variable_defined?(:@firestore) end - if defined?(SiteContent) && SiteContent.respond_to?(:instance_variable_set) - SiteContent.instance_variable_set(:@firestore, nil) if SiteContent.instance_variable_defined?(:@firestore) + if defined?(SiteContent) && SiteContent.respond_to?(:instance_variable_set) && + SiteContent.instance_variable_defined?(:@firestore) + SiteContent.instance_variable_set(:@firestore, nil) end end end From fb7f18e1ccd83c7f254f6746db4961465ee02ac5 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:47:24 +0100 Subject: [PATCH 22/28] Refactor index action to reduce complexity Extract filter_by_author, filter_by_query, paginate_mods, and render_index private methods to bring ABC size and method length below RuboCop thresholds. Co-Authored-By: Claude Opus 4.6 --- app/controllers/mods_controller.rb | 70 ++++++++++++++++-------------- 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/app/controllers/mods_controller.rb b/app/controllers/mods_controller.rb index b30663d..025499d 100644 --- a/app/controllers/mods_controller.rb +++ b/app/controllers/mods_controller.rb @@ -10,40 +10,13 @@ class ModsController < ApplicationController def index @filtered = false - if params[:author].present? - @mods = find_mods_by_author(sanitize(params[:author])) - @filtered = true - end - - # If we're given a mod ID, try to find it and redirect to the mod's page - if @mods.empty? && params[:author].present? - @mod = mods.find { |mod| mod.id == params[:author] } - return redirect_to mod_detail_path(author: @mod.author_slug, slug: @mod.slug) if @mod.present? - end - - # Perform a search if we have a query - if params[:query].present? - @mods = find_mods(sanitize(params[:query])) - @filtered = true - end - - # Sort the mods if we have a sort key - # Disabled for now, as it's redundant with the search - # params[:sort].present? && Mod::SORTKEYS.include?(params[:sort]) && @mods.sort_by! { |mod| [mod.send(sanitize(params[:sort])), mod.name] } + filter_by_author + return if performed? + filter_by_query @total_mods = @mods.size - - # Paginate only when listing all mods (no search query or author filter) - unless @filtered - @pagination = paginate_array(@mods, page: params[:page]) - @mods = @pagination.items - end - - if turbo_frame_request? - render partial: "mods", locals: { mods: @mods } - else - render :index - end + paginate_mods unless @filtered + render_index end def show @@ -62,6 +35,39 @@ def show private + def filter_by_author + return unless params[:author].present? + + @mods = find_mods_by_author(sanitize(params[:author])) + @filtered = true + + # If no mods found by author slug, try to find by mod ID and redirect + return unless @mods.empty? + + @mod = mods.find { |mod| mod.id == params[:author] } + redirect_to mod_detail_path(author: @mod.author_slug, slug: @mod.slug) if @mod.present? + end + + def filter_by_query + return unless params[:query].present? + + @mods = find_mods(sanitize(params[:query])) + @filtered = true + end + + def paginate_mods + @pagination = paginate_array(@mods, page: params[:page]) + @mods = @pagination.items + end + + def render_index + if turbo_frame_request? + render partial: "mods", locals: { mods: @mods } + else + render :index + end + end + def find_mods(query) # Escape regex special characters to prevent injection escaped_query = Regexp.escape(query) From 4591a386a2194963d9e21bb7434deba578e5b68e Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:53:58 +0100 Subject: [PATCH 23/28] Use Rails/Blank guard clauses in filter methods Replace `return unless params[:x].present?` with `return if params[:x].blank?` per Rails/Blank cop preference. Co-Authored-By: Claude Opus 4.6 --- app/controllers/mods_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/mods_controller.rb b/app/controllers/mods_controller.rb index 025499d..e0a2bbc 100644 --- a/app/controllers/mods_controller.rb +++ b/app/controllers/mods_controller.rb @@ -36,7 +36,7 @@ def show private def filter_by_author - return unless params[:author].present? + return if params[:author].blank? @mods = find_mods_by_author(sanitize(params[:author])) @filtered = true @@ -49,7 +49,7 @@ def filter_by_author end def filter_by_query - return unless params[:query].present? + return if params[:query].blank? @mods = find_mods(sanitize(params[:query])) @filtered = true From c50a4ea732bd08487ef0a8cea4faab13e51109bc Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Sat, 4 Apr 2026 17:43:26 -0400 Subject: [PATCH 24/28] Fix stray merge conflict marker in _mod partial Co-Authored-By: Claude Opus 4.6 (1M context) --- app/views/mods/_mod.html.erb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/mods/_mod.html.erb b/app/views/mods/_mod.html.erb index 194e193..c82b217 100644 --- a/app/views/mods/_mod.html.erb +++ b/app/views/mods/_mod.html.erb @@ -25,6 +25,5 @@ <% end %> -<<<<<<< HEAD <%= truncate(mod.description.to_s, length: 120) %> From b339baa479bb12ef9790539a5d2851890aefeaf3 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 14:58:01 +0100 Subject: [PATCH 25/28] Add dynamic info page editing via Firestore and admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses issue #25 — the info page content is now stored in Firestore and editable through an admin interface at /admin/info, protected by HTTP Basic Auth (ADMIN_PASSWORD env var). No user login system needed. - SiteContent model reads/writes page sections to Firestore - Admin::BaseController provides HTTP Basic Auth for admin routes - Admin::InfoController allows adding, editing, and removing sections - Info view renders dynamic content with markdown support - Falls back to default content if no Firestore document exists - Includes model and request specs Co-Authored-By: Claude Opus 4.6 --- app/controllers/admin/base_controller.rb | 24 ++++++++ app/controllers/admin/info_controller.rb | 51 ++++++++++++++++ app/controllers/info_controller.rb | 6 +- app/models/site_content.rb | 73 ++++++++++++++++++++++ app/views/admin/info/edit.html.erb | 71 +++++++++++++++++++++ app/views/info/index.html.erb | 46 ++++++-------- config/routes.rb | 8 +++ spec/models/site_content_spec.rb | 78 ++++++++++++++++++++++++ spec/requests/admin/info_spec.rb | 53 ++++++++++++++++ 9 files changed, 383 insertions(+), 27 deletions(-) create mode 100644 app/controllers/admin/base_controller.rb create mode 100644 app/controllers/admin/info_controller.rb create mode 100644 app/models/site_content.rb create mode 100644 app/views/admin/info/edit.html.erb create mode 100644 spec/models/site_content_spec.rb create mode 100644 spec/requests/admin/info_spec.rb diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 0000000..f2302ad --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Admin + # Base controller for admin routes. + # Uses HTTP Basic Auth with a password set via ADMIN_PASSWORD env var. + class BaseController < ApplicationController + before_action :authenticate_admin! + + private + + def authenticate_admin! + admin_password = ENV.fetch("ADMIN_PASSWORD", nil) + + if admin_password.blank? + render plain: "Admin access is not configured. Set the ADMIN_PASSWORD environment variable.", status: :service_unavailable + return + end + + authenticate_or_request_with_http_basic("Project Daedalus Admin") do |_username, password| + ActiveSupport::SecurityUtils.secure_compare(password, admin_password) + end + end + end +end diff --git a/app/controllers/admin/info_controller.rb b/app/controllers/admin/info_controller.rb new file mode 100644 index 0000000..54b5958 --- /dev/null +++ b/app/controllers/admin/info_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Admin + # Allows admins to edit the info page content stored in Firestore. + class InfoController < Admin::BaseController + def edit + content = SiteContent.find("info_page") + @sections = content&.sections || SiteContent.default_info_sections + end + + def update + sections = build_sections_from_params + SiteContent.save!("info_page", sections) + redirect_to admin_info_edit_path, notice: "Info page updated successfully." + end + + # Add a blank section to the form + def add_section + content = SiteContent.find("info_page") + sections = content&.sections || SiteContent.default_info_sections + sections << SiteContent::Section.new(title: "", description: "", link_text: "", link_url: "") + SiteContent.save!("info_page", sections) + redirect_to admin_info_edit_path, notice: "New section added." + end + + # Remove a section by index + def remove_section + content = SiteContent.find("info_page") + sections = content&.sections || SiteContent.default_info_sections + index = params[:index].to_i + sections.delete_at(index) if index >= 0 && index < sections.size + SiteContent.save!("info_page", sections) + redirect_to admin_info_edit_path, notice: "Section removed." + end + + private + + def build_sections_from_params + return [] unless params[:sections].is_a?(ActionController::Parameters) + + params[:sections].values.map do |section_params| + SiteContent::Section.new( + title: section_params[:title].to_s.strip, + description: section_params[:description].to_s.strip, + link_text: section_params[:link_text].to_s.strip, + link_url: section_params[:link_url].to_s.strip + ) + end.reject { |s| s.title.blank? && s.description.blank? } + end + end +end diff --git a/app/controllers/info_controller.rb b/app/controllers/info_controller.rb index 5c08938..d657241 100644 --- a/app/controllers/info_controller.rb +++ b/app/controllers/info_controller.rb @@ -2,5 +2,9 @@ # InfoController class InfoController < ApplicationController - def index; end + def index + content = SiteContent.find("info_page") + @sections = content&.sections || SiteContent.default_info_sections + @last_updated = content&.updated_at + end end diff --git a/app/models/site_content.rb b/app/models/site_content.rb new file mode 100644 index 0000000..b2ff127 --- /dev/null +++ b/app/models/site_content.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# SiteContent stores editable page content in Firestore. +# Each page is a document in the "site_content" collection. +# +# Example Firestore document for the info page: +# site_content/info_page => { sections: [...], updated_at: ... } +class SiteContent + include Firestorable + + COLLECTION = "site_content" + CACHE_TTL = 2.minutes + + Section = Struct.new(:title, :description, :link_text, :link_url, keyword_init: true) + + attr_reader :page_id, :sections, :updated_at + + def initialize(page_id:, sections: [], updated_at: nil) + @page_id = page_id + @sections = sections.map { |s| s.is_a?(Section) ? s : Section.new(**s.symbolize_keys) } + @updated_at = updated_at + end + + # Fetch content for a page, with caching + def self.find(page_id) + Rails.cache.fetch("site_content/#{page_id}", expires_in: CACHE_TTL) do + fetch_from_firestore(page_id) + end + end + + # Fetch directly from Firestore (bypasses cache) + def self.fetch_from_firestore(page_id) + doc = firestore.doc("#{COLLECTION}/#{page_id}").get + return nil unless doc.exists? + + new( + page_id: page_id, + sections: (doc[:sections] || []).map { |s| s.transform_keys(&:to_sym) }, + updated_at: doc[:updated_at] + ) + end + + # Save content to Firestore and bust cache + def self.save!(page_id, sections) + data = { + sections: sections.map(&:to_h), + updated_at: Time.current.utc + } + + firestore.doc("#{COLLECTION}/#{page_id}").set(data) + Rails.cache.delete("site_content/#{page_id}") + + new(page_id: page_id, sections: sections, updated_at: data[:updated_at]) + end + + # Default info page content (used as fallback and for seeding) + def self.default_info_sections + [ + Section.new( + title: "The Icarus Modding Discord Server", + description: "This is the unofficial modding Discord server for Icarus. It's a great place to get help with modding, ask questions, and meet other modders.", + link_text: "Join our Discord", + link_url: "https://discord.gg/linkarus-icarus-modding-936621749733302292" + ), + Section.new( + title: "The Icarus Modding Upvote Page", + description: "This is a page where you can upvote or add new mods you'd like to see for Icarus. It's a great way to let us know what you want to see in the future.", + link_text: "Modding Upvote Page", + link_url: "https://feedback.projectdaedalus.app/" + ) + ] + end +end diff --git a/app/views/admin/info/edit.html.erb b/app/views/admin/info/edit.html.erb new file mode 100644 index 0000000..325148f --- /dev/null +++ b/app/views/admin/info/edit.html.erb @@ -0,0 +1,71 @@ +<% content_for(:title) { "Admin — Edit Info Page" } %> + +
+
+

Edit Info Page

+ <%= link_to "View Info Page", info_path, class: "text-sm text-blue-500 hover:text-blue-400", target: "_blank" %> +
+ + <% if flash[:notice] %> +
+ <%= flash[:notice] %> +
+ <% end %> + + <%= form_with url: admin_info_update_path, method: :patch, local: true, class: "space-y-6" do |f| %> + <% @sections.each_with_index do |section, index| %> +
+
+ Section <%= index + 1 %> + <%= button_to "Remove", + admin_info_remove_section_path(index: index), + method: :delete, + class: "text-xs text-red-500 hover:text-red-400", + data: { turbo_confirm: "Remove this section?" } %> +
+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+ <% end %> + +
+ <%= button_to "Add Section", admin_info_add_section_path, method: :post, class: "px-4 py-2 text-sm font-medium text-icarus-500 border border-icarus-500 rounded hover:bg-icarus-500/10 transition-colors" %> + + +
+ <% end %> +
diff --git a/app/views/info/index.html.erb b/app/views/info/index.html.erb index 7f434f8..3e77548 100644 --- a/app/views/info/index.html.erb +++ b/app/views/info/index.html.erb @@ -21,34 +21,28 @@ <%# Section Title %>

Icarus Modding Links

- <%# Link Cards %> + <%# Dynamic Link Cards %>
- - <%# Discord Card %> - <%= link_to "https://discord.gg/linkarus-icarus-modding-936621749733302292", target: "_blank", class: "info-card block rounded-xl p-6 text-center border-2 border-icarus-500 bg-slate-100 dark:bg-gradient-to-b dark:from-slate-800 dark:to-slate-900 no-underline" do %> -
💬
-

The Icarus Modding Discord Server

-

- This is the unofficial modding Discord server for Icarus. - It's a great place to get help with modding, ask questions, and meet other modders. -

- - Join our Discord - + <% @sections.each do |section| %> + <%= link_to section.link_url.presence || "#", target: "_blank", class: "info-card block rounded-xl p-6 text-center border-2 border-icarus-500 bg-slate-100 dark:bg-gradient-to-b dark:from-slate-800 dark:to-slate-900 no-underline" do %> +

<%= section.title %>

+ <% if section.description.present? %> +
<%= markdown(section.description) %>
+ <% end %> + <% if section.link_text.present? %> + + <%= section.link_text %> + + <% end %> + <% end %> <% end %> +
- <%# Upvote Card %> - <%= link_to "https://feedback.projectdaedalus.app/", target: "_blank", class: "info-card block rounded-xl p-6 text-center border-2 border-icarus-500 bg-slate-100 dark:bg-gradient-to-b dark:from-slate-800 dark:to-slate-900 no-underline" do %> -
⬆️
-

The Icarus Modding Upvote Page

-

- Upvote or add new mods you'd like to see for Icarus. - It's a great way to let us know what you want to see in the future. -

- - Modding Upvote Page - - <% end %> + <% if @sections.empty? %> +

No information available at this time.

+ <% end %> - + <% if @last_updated %> +

Last updated: <%= @last_updated.strftime("%B %d, %Y at %H:%M UTC") %>

+ <% end %> diff --git a/config/routes.rb b/config/routes.rb index 0630084..3acabcc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,14 @@ get "home", to: "home#index" get "info", to: "info#index" + # Admin routes (HTTP Basic Auth protected) + namespace :admin do + get "info", to: "info#edit", as: "info_edit" + patch "info", to: "info#update", as: "info_update" + post "info/sections", to: "info#add_section", as: "info_add_section" + delete "info/sections/:index", to: "info#remove_section", as: "info_remove_section" + end + scope :mods do get "/:author/:slug", to: "mods#show", as: "mod_detail" get "/:author", to: "mods#index", as: "mods_author" diff --git a/spec/models/site_content_spec.rb b/spec/models/site_content_spec.rb new file mode 100644 index 0000000..ca78892 --- /dev/null +++ b/spec/models/site_content_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SiteContent do + let(:firestore_client) { instance_double(Google::Cloud::Firestore::Client) } + let(:doc_ref) { instance_double(Google::Cloud::Firestore::DocumentReference) } + + before do + allow(Google::Cloud::Firestore).to receive(:new).and_return(firestore_client) + allow(firestore_client).to receive(:doc).and_return(doc_ref) + Rails.cache.clear + end + + describe ".find" do + context "when Firestore document exists" do + let(:doc_snapshot) do + instance_double(Google::Cloud::Firestore::DocumentSnapshot, + exists?: true, + :[] => nil) + end + + before do + allow(doc_ref).to receive(:get).and_return(doc_snapshot) + allow(doc_snapshot).to receive(:[]).with(:sections).and_return([ + { title: "Test Section", description: "A description", link_text: "Click", link_url: "https://example.com" } + ]) + allow(doc_snapshot).to receive(:[]).with(:updated_at).and_return(Time.utc(2026, 1, 1)) + end + + it "returns a SiteContent instance with sections" do + content = described_class.find("info_page") + expect(content).to be_a(SiteContent) + expect(content.sections.size).to eq(1) + expect(content.sections.first.title).to eq("Test Section") + end + end + + context "when Firestore document does not exist" do + let(:doc_snapshot) { instance_double(Google::Cloud::Firestore::DocumentSnapshot, exists?: false) } + + before do + allow(doc_ref).to receive(:get).and_return(doc_snapshot) + end + + it "returns nil" do + content = described_class.find("info_page") + expect(content).to be_nil + end + end + end + + describe ".save!" do + let(:sections) do + [SiteContent::Section.new(title: "New", description: "Desc", link_text: "Go", link_url: "https://example.com")] + end + + before do + allow(doc_ref).to receive(:set) + end + + it "writes to Firestore and returns a SiteContent instance" do + result = described_class.save!("info_page", sections) + expect(result).to be_a(SiteContent) + expect(result.sections.size).to eq(1) + expect(doc_ref).to have_received(:set).once + end + end + + describe ".default_info_sections" do + it "returns default sections with Discord and Upvote entries" do + defaults = described_class.default_info_sections + expect(defaults.size).to eq(2) + expect(defaults.first.title).to include("Discord") + expect(defaults.last.title).to include("Upvote") + end + end +end diff --git a/spec/requests/admin/info_spec.rb b/spec/requests/admin/info_spec.rb new file mode 100644 index 0000000..2a3526e --- /dev/null +++ b/spec/requests/admin/info_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Admin::Info", type: :request do + let(:firestore_client) { instance_double(Google::Cloud::Firestore::Client) } + let(:doc_ref) { instance_double(Google::Cloud::Firestore::DocumentReference) } + let(:admin_password) { "test-admin-password" } + let(:auth_headers) do + { "HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic.encode_credentials("admin", admin_password) } + end + + before do + allow(Google::Cloud::Firestore).to receive(:new).and_return(firestore_client) + allow(firestore_client).to receive(:doc).and_return(doc_ref) + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with("ADMIN_PASSWORD", nil).and_return(admin_password) + Rails.cache.clear + end + + describe "GET /admin/info" do + context "without authentication" do + it "returns 401" do + allow(doc_ref).to receive(:get).and_return( + instance_double(Google::Cloud::Firestore::DocumentSnapshot, exists?: false) + ) + get admin_info_edit_path + expect(response).to have_http_status(:unauthorized) + end + end + + context "with valid authentication" do + it "returns 200 and renders edit form" do + allow(doc_ref).to receive(:get).and_return( + instance_double(Google::Cloud::Firestore::DocumentSnapshot, exists?: false) + ) + get admin_info_edit_path, headers: auth_headers + expect(response).to have_http_status(:ok) + end + end + + context "when ADMIN_PASSWORD is not set" do + before do + allow(ENV).to receive(:fetch).with("ADMIN_PASSWORD", nil).and_return(nil) + end + + it "returns 503" do + get admin_info_edit_path, headers: auth_headers + expect(response).to have_http_status(:service_unavailable) + end + end + end +end From e6bcd895ff36f7f93d97df0a223340592e19c165 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 17:57:41 +0100 Subject: [PATCH 26/28] Replace HTTP Basic Auth with session-based admin authentication - Add login form at /admin/login with gold theme styling - Session expires after 30 minutes of inactivity - Audit logging for login/logout/failed attempts with IP - Proper logout button in admin panel - Updated specs for session-based auth flow Addresses review feedback about HTTP Basic Auth limitations (no session management, no audit trail, credentials in every request). Co-Authored-By: Claude Opus 4.6 --- app/controllers/admin/base_controller.rb | 24 +++++++++-- app/controllers/admin/sessions_controller.rb | 37 +++++++++++++++++ app/views/admin/info/edit.html.erb | 5 ++- app/views/admin/sessions/new.html.erb | 24 +++++++++++ config/routes.rb | 5 ++- spec/requests/admin/info_spec.rb | 42 +++++++++++++++----- 6 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 app/controllers/admin/sessions_controller.rb create mode 100644 app/views/admin/sessions/new.html.erb diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index f2302ad..d825a63 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -2,8 +2,11 @@ module Admin # Base controller for admin routes. - # Uses HTTP Basic Auth with a password set via ADMIN_PASSWORD env var. + # Uses session-based authentication with ADMIN_PASSWORD env var. + # Sessions expire after 30 minutes of inactivity. class BaseController < ApplicationController + SESSION_TIMEOUT = 30.minutes + before_action :authenticate_admin! private @@ -16,9 +19,24 @@ def authenticate_admin! return end - authenticate_or_request_with_http_basic("Project Daedalus Admin") do |_username, password| - ActiveSupport::SecurityUtils.secure_compare(password, admin_password) + if session[:admin_authenticated] + if session[:admin_last_active].present? && + Time.current - Time.zone.parse(session[:admin_last_active].to_s) < SESSION_TIMEOUT + session[:admin_last_active] = Time.current.iso8601 + return + end + + reset_admin_session + redirect_to admin_login_path, alert: "Session expired. Please log in again." + return end + + redirect_to admin_login_path + end + + def reset_admin_session + session.delete(:admin_authenticated) + session.delete(:admin_last_active) end end end diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb new file mode 100644 index 0000000..a671215 --- /dev/null +++ b/app/controllers/admin/sessions_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Admin + class SessionsController < ApplicationController + def new + # Login form + redirect_to admin_info_edit_path if session[:admin_authenticated] + end + + def create + admin_password = ENV.fetch("ADMIN_PASSWORD", nil) + + if admin_password.blank? + render plain: "Admin access is not configured.", status: :service_unavailable + return + end + + if ActiveSupport::SecurityUtils.secure_compare(params[:password].to_s, admin_password) + session[:admin_authenticated] = true + session[:admin_last_active] = Time.current.iso8601 + Rails.logger.info("[Admin] Successful login from #{request.remote_ip}") + redirect_to admin_info_edit_path, notice: "Logged in successfully." + else + Rails.logger.warn("[Admin] Failed login attempt from #{request.remote_ip}") + flash.now[:alert] = "Invalid password." + render :new, status: :unauthorized + end + end + + def destroy + Rails.logger.info("[Admin] Logout from #{request.remote_ip}") + session.delete(:admin_authenticated) + session.delete(:admin_last_active) + redirect_to root_path, notice: "Logged out." + end + end +end diff --git a/app/views/admin/info/edit.html.erb b/app/views/admin/info/edit.html.erb index 325148f..3e1a88d 100644 --- a/app/views/admin/info/edit.html.erb +++ b/app/views/admin/info/edit.html.erb @@ -3,7 +3,10 @@

Edit Info Page

- <%= link_to "View Info Page", info_path, class: "text-sm text-blue-500 hover:text-blue-400", target: "_blank" %> +
+ <%= link_to "View Info Page", info_path, class: "text-sm text-blue-500 hover:text-blue-400", target: "_blank" %> + <%= button_to "Logout", admin_logout_path, method: :delete, class: "text-sm text-slate-500 hover:text-red-500 cursor-pointer" %> +
<% if flash[:notice] %> diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb new file mode 100644 index 0000000..c0a2257 --- /dev/null +++ b/app/views/admin/sessions/new.html.erb @@ -0,0 +1,24 @@ + + +
+
+

Admin Login

+

Enter the admin password to continue.

+
+
+ +
+ <%= form_tag admin_login_path, method: :post do %> +
+ + <%= password_field_tag :password, nil, class: "w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-icarus-500 focus:border-transparent", autofocus: true, autocomplete: "current-password" %> +
+ +
+ <%= submit_tag "Log in", class: "w-full px-6 py-3 rounded-lg font-semibold text-white bg-icarus-500 hover:bg-icarus-600 focus:outline-none focus:ring-2 focus:ring-icarus-400 focus:ring-offset-2 cursor-pointer transition-colors" %> +
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 3acabcc..745a200 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,8 +6,11 @@ get "home", to: "home#index" get "info", to: "info#index" - # Admin routes (HTTP Basic Auth protected) + # Admin routes (session-based auth with ADMIN_PASSWORD) namespace :admin do + get "login", to: "sessions#new", as: "login" + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy", as: "logout" get "info", to: "info#edit", as: "info_edit" patch "info", to: "info#update", as: "info_update" post "info/sections", to: "info#add_section", as: "info_add_section" diff --git a/spec/requests/admin/info_spec.rb b/spec/requests/admin/info_spec.rb index 2a3526e..c2715dc 100644 --- a/spec/requests/admin/info_spec.rb +++ b/spec/requests/admin/info_spec.rb @@ -6,9 +6,6 @@ let(:firestore_client) { instance_double(Google::Cloud::Firestore::Client) } let(:doc_ref) { instance_double(Google::Cloud::Firestore::DocumentReference) } let(:admin_password) { "test-admin-password" } - let(:auth_headers) do - { "HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic.encode_credentials("admin", admin_password) } - end before do allow(Google::Cloud::Firestore).to receive(:new).and_return(firestore_client) @@ -18,23 +15,26 @@ Rails.cache.clear end + # Helper to log in via the session-based admin auth + def admin_login + post admin_login_path, params: { password: admin_password } + end + describe "GET /admin/info" do context "without authentication" do - it "returns 401" do - allow(doc_ref).to receive(:get).and_return( - instance_double(Google::Cloud::Firestore::DocumentSnapshot, exists?: false) - ) + it "redirects to login" do get admin_info_edit_path - expect(response).to have_http_status(:unauthorized) + expect(response).to redirect_to(admin_login_path) end end - context "with valid authentication" do + context "with valid session" do it "returns 200 and renders edit form" do allow(doc_ref).to receive(:get).and_return( instance_double(Google::Cloud::Firestore::DocumentSnapshot, exists?: false) ) - get admin_info_edit_path, headers: auth_headers + admin_login + get admin_info_edit_path expect(response).to have_http_status(:ok) end end @@ -45,9 +45,29 @@ end it "returns 503" do - get admin_info_edit_path, headers: auth_headers + get admin_info_edit_path expect(response).to have_http_status(:service_unavailable) end end end + + describe "POST /admin/login" do + it "logs in with correct password and redirects" do + post admin_login_path, params: { password: admin_password } + expect(response).to redirect_to(admin_info_edit_path) + end + + it "rejects incorrect password" do + post admin_login_path, params: { password: "wrong" } + expect(response).to have_http_status(:unauthorized) + end + end + + describe "DELETE /admin/logout" do + it "clears session and redirects to root" do + admin_login + delete admin_logout_path + expect(response).to redirect_to(root_path) + end + end end From 9621ce7092da59f4b0b70534f94e1521b6d38f15 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:35:57 +0100 Subject: [PATCH 27/28] Stub SiteContent.find globally in test support Prevents info page specs from hitting Firestore. Returns nil so the controller falls back to default_info_sections. Co-Authored-By: Claude Opus 4.6 --- spec/support/firestore.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/spec/support/firestore.rb b/spec/support/firestore.rb index 1f7d8a9..6d8ffa9 100644 --- a/spec/support/firestore.rb +++ b/spec/support/firestore.rb @@ -14,6 +14,11 @@ { type: "service_account", project_id: "test-project" } ) end + + # Stub SiteContent.find globally so info page specs don't hit Firestore + if defined?(SiteContent) + allow(SiteContent).to receive(:find).and_return(nil) + end end # Reset memoized Firestore clients after each test to prevent From f3ca7f9abd83afb17c253dbcd4937df96f591877 Mon Sep 17 00:00:00 2001 From: AgentKush Date: Sat, 4 Apr 2026 18:50:01 +0100 Subject: [PATCH 28/28] Fix SiteContent spec failure and RuboCop offenses - Override global SiteContent.find stub with and_call_original in spec so tests exercise real implementation through mocked Firestore - Use described_class instead of explicit SiteContent in specs - Fix array indentation in spec - Use modifier if for SiteContent stub in firestore support - Break long lines in base_controller and site_content - Avoid multi-line block chain in info_controller Co-Authored-By: Claude Opus 4.6 --- app/controllers/admin/base_controller.rb | 3 ++- app/controllers/admin/info_controller.rb | 5 +++-- app/models/site_content.rb | 6 ++++-- spec/models/site_content_spec.rb | 12 +++++++----- spec/support/firestore.rb | 4 +--- 5 files changed, 17 insertions(+), 13 deletions(-) diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index d825a63..cb9e22c 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -15,7 +15,8 @@ def authenticate_admin! admin_password = ENV.fetch("ADMIN_PASSWORD", nil) if admin_password.blank? - render plain: "Admin access is not configured. Set the ADMIN_PASSWORD environment variable.", status: :service_unavailable + render plain: "Admin access is not configured. Set the ADMIN_PASSWORD environment variable.", + status: :service_unavailable return end diff --git a/app/controllers/admin/info_controller.rb b/app/controllers/admin/info_controller.rb index 54b5958..021ca88 100644 --- a/app/controllers/admin/info_controller.rb +++ b/app/controllers/admin/info_controller.rb @@ -38,14 +38,15 @@ def remove_section def build_sections_from_params return [] unless params[:sections].is_a?(ActionController::Parameters) - params[:sections].values.map do |section_params| + sections = params[:sections].values.map do |section_params| SiteContent::Section.new( title: section_params[:title].to_s.strip, description: section_params[:description].to_s.strip, link_text: section_params[:link_text].to_s.strip, link_url: section_params[:link_url].to_s.strip ) - end.reject { |s| s.title.blank? && s.description.blank? } + end + sections.reject { |s| s.title.blank? && s.description.blank? } end end end diff --git a/app/models/site_content.rb b/app/models/site_content.rb index b2ff127..d6150b0 100644 --- a/app/models/site_content.rb +++ b/app/models/site_content.rb @@ -58,13 +58,15 @@ def self.default_info_sections [ Section.new( title: "The Icarus Modding Discord Server", - description: "This is the unofficial modding Discord server for Icarus. It's a great place to get help with modding, ask questions, and meet other modders.", + description: "This is the unofficial modding Discord server for Icarus. " \ + "It's a great place to get help with modding, ask questions, and meet other modders.", link_text: "Join our Discord", link_url: "https://discord.gg/linkarus-icarus-modding-936621749733302292" ), Section.new( title: "The Icarus Modding Upvote Page", - description: "This is a page where you can upvote or add new mods you'd like to see for Icarus. It's a great way to let us know what you want to see in the future.", + description: "This is a page where you can upvote or add new mods you'd like to see " \ + "for Icarus. It's a great way to let us know what you want to see in the future.", link_text: "Modding Upvote Page", link_url: "https://feedback.projectdaedalus.app/" ) diff --git a/spec/models/site_content_spec.rb b/spec/models/site_content_spec.rb index ca78892..ff96161 100644 --- a/spec/models/site_content_spec.rb +++ b/spec/models/site_content_spec.rb @@ -9,6 +9,8 @@ before do allow(Google::Cloud::Firestore).to receive(:new).and_return(firestore_client) allow(firestore_client).to receive(:doc).and_return(doc_ref) + # Override global SiteContent.find stub so we test real implementation + allow(described_class).to receive(:find).and_call_original Rails.cache.clear end @@ -22,15 +24,15 @@ before do allow(doc_ref).to receive(:get).and_return(doc_snapshot) - allow(doc_snapshot).to receive(:[]).with(:sections).and_return([ - { title: "Test Section", description: "A description", link_text: "Click", link_url: "https://example.com" } - ]) + allow(doc_snapshot).to receive(:[]).with(:sections).and_return( + [{ title: "Test Section", description: "A description", link_text: "Click", link_url: "https://example.com" }] + ) allow(doc_snapshot).to receive(:[]).with(:updated_at).and_return(Time.utc(2026, 1, 1)) end it "returns a SiteContent instance with sections" do content = described_class.find("info_page") - expect(content).to be_a(SiteContent) + expect(content).to be_a(described_class) expect(content.sections.size).to eq(1) expect(content.sections.first.title).to eq("Test Section") end @@ -61,7 +63,7 @@ it "writes to Firestore and returns a SiteContent instance" do result = described_class.save!("info_page", sections) - expect(result).to be_a(SiteContent) + expect(result).to be_a(described_class) expect(result.sections.size).to eq(1) expect(doc_ref).to have_received(:set).once end diff --git a/spec/support/firestore.rb b/spec/support/firestore.rb index 6d8ffa9..b864e56 100644 --- a/spec/support/firestore.rb +++ b/spec/support/firestore.rb @@ -16,9 +16,7 @@ end # Stub SiteContent.find globally so info page specs don't hit Firestore - if defined?(SiteContent) - allow(SiteContent).to receive(:find).and_return(nil) - end + allow(SiteContent).to receive(:find).and_return(nil) if defined?(SiteContent) end # Reset memoized Firestore clients after each test to prevent