Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
144047d
Fix .ruby-version to match Gemfile ruby requirement (3.4.9)
AgentKush Apr 4, 2026
1092b92
Enable Content-Security-Policy headers in report-only mode
AgentKush Apr 4, 2026
36c75f0
Add nil guard to Tool#filename to prevent NoMethodError
AgentKush Apr 4, 2026
a04a12a
Add nil guard to author_slug to prevent NoMethodError on index page
AgentKush Apr 4, 2026
2df3a31
Fix Tool#filename to match Mod#filename safe approach
AgentKush Apr 4, 2026
e79cca6
Add dark/light theme toggle to header nav
AgentKush Apr 4, 2026
70bcd09
Respect OS dark mode preference when no saved theme exists
AgentKush Apr 4, 2026
bb586a8
Add pagination to mods listing
AgentKush Apr 4, 2026
32451bd
Add test coverage for PaginationHelper and PaginationResult
AgentKush Apr 4, 2026
d4d482f
Remove .env from version control and add .env.example template
AgentKush Apr 4, 2026
c96f8f0
Use author_slug instead of raw author name in mod partial URLs
AgentKush Apr 4, 2026
4439ce2
Increase search debounce from 200ms to 400ms to reduce requests
AgentKush Apr 4, 2026
f1413b8
Memoize markdown renderer to avoid recreating objects per call
AgentKush Apr 4, 2026
8118346
Enable require_master_key in production environment
AgentKush Apr 4, 2026
f306d35
Memoize Firestore client to avoid redundant connections
AgentKush Apr 4, 2026
30ae0e3
Fix Firestore memoization leaking test doubles across examples
AgentKush Apr 4, 2026
6577841
Fix RuboCop offenses in pagination helper and specs
AgentKush Apr 4, 2026
b6614e1
Update _mod view spec to expect author_slug in path param
AgentKush Apr 4, 2026
b047264
Add .serena/ and .playwright-mcp/ to .gitignore
dyoung522 Apr 4, 2026
4c163a5
Move theme toggle to Stimulus controller for scoped JS
AgentKush Apr 4, 2026
e025efd
Fix RuboCop SoleNestedConditional in firestore support
AgentKush Apr 4, 2026
fb7f18e
Refactor index action to reduce complexity
AgentKush Apr 4, 2026
4591a38
Use Rails/Blank guard clauses in filter methods
AgentKush Apr 4, 2026
b2ab9c6
Merge pull request #106 from AgentKush/fix/ruby-version-mismatch
dyoung522 Apr 4, 2026
ca136fa
Merge pull request #80 from AgentKush/fix/medium-severity-nil-guards-…
dyoung522 Apr 4, 2026
8f187a8
Merge pull request #81 from AgentKush/fix/low-severity-cleanup-and-pe…
dyoung522 Apr 4, 2026
f5a9f09
Merge pull request #88 from AgentKush/feature/theme-toggle
dyoung522 Apr 4, 2026
80f4e98
Merge pull request #84 from AgentKush/fix/add-pagination
dyoung522 Apr 4, 2026
c50a4ea
Fix stray merge conflict marker in _mod partial
dyoung522 Apr 4, 2026
b339baa
Add dynamic info page editing via Firestore and admin panel
AgentKush Apr 4, 2026
e6bcd89
Replace HTTP Basic Auth with session-based admin authentication
AgentKush Apr 4, 2026
9621ce7
Stub SiteContent.find globally in test support
AgentKush Apr 4, 2026
f3ca7f9
Fix SiteContent spec failure and RuboCop offenses
AgentKush Apr 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .env

This file was deleted.

6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,16 @@

/app/assets/builds/*
!/app/assets/builds/.keep
.env
.env.*
!.env.example

# Claude Code local settings
.claude/

# Serena MCP plugin
.serena/

# Playwright MCP plugin
.playwright-mcp/
firebase-debug.log
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.4.8
3.4.9
1 change: 0 additions & 1 deletion .serena/.gitignore

This file was deleted.

87 changes: 0 additions & 87 deletions .serena/project.yml

This file was deleted.

43 changes: 43 additions & 0 deletions app/controllers/admin/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

module Admin
# Base controller for admin routes.
# 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

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

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
52 changes: 52 additions & 0 deletions app/controllers/admin/info_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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)

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
sections.reject { |s| s.title.blank? && s.description.blank? }
end
end
end
37 changes: 37 additions & 0 deletions app/controllers/admin/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion app/controllers/info_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 41 additions & 19 deletions app/controllers/mods_controller.rb
Original file line number Diff line number Diff line change
@@ -1,33 +1,22 @@
# 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?

# 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
@mods = find_mods(sanitize(params[:query])) if params[:query].present?
@filtered = false

# 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

if turbo_frame_request?
render partial: "mods", locals: { mods: @mods }
else
render :index
end
paginate_mods unless @filtered
render_index
end

def show
Expand All @@ -46,6 +35,39 @@ def show

private

def filter_by_author
return if params[:author].blank?

@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 if params[:query].blank?

@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)
Expand Down
28 changes: 18 additions & 10 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading