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..6b33d91 100644 --- a/.gitignore +++ b/.gitignore @@ -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 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/.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: [] diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 0000000..cb9e22c --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -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 diff --git a/app/controllers/admin/info_controller.rb b/app/controllers/admin/info_controller.rb new file mode 100644 index 0000000..021ca88 --- /dev/null +++ b/app/controllers/admin/info_controller.rb @@ -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 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/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/controllers/mods_controller.rb b/app/controllers/mods_controller.rb index 41858dd..e0a2bbc 100644 --- a/app/controllers/mods_controller.rb +++ b/app/controllers/mods_controller.rb @@ -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 @@ -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) 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 diff --git a/app/helpers/pagination_helper.rb b/app/helpers/pagination_helper.rb new file mode 100644 index 0000000..cc6988a --- /dev/null +++ b/app/helpers/pagination_helper.rb @@ -0,0 +1,71 @@ +# 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.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 + ) + 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/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) { 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/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 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 diff --git a/app/models/site_content.rb b/app/models/site_content.rb new file mode 100644 index 0000000..d6150b0 --- /dev/null +++ b/app/models/site_content.rb @@ -0,0 +1,75 @@ +# 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/models/tool.rb b/app/models/tool.rb index 1db43fb..06c3344 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 if url.blank? + + url.split("?").first.split("/").last end def name_slug diff --git a/app/views/admin/info/edit.html.erb b/app/views/admin/info/edit.html.erb new file mode 100644 index 0000000..3e1a88d --- /dev/null +++ b/app/views/admin/info/edit.html.erb @@ -0,0 +1,74 @@ +<% 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" %> + <%= button_to "Logout", admin_logout_path, method: :delete, class: "text-sm text-slate-500 hover:text-red-500 cursor-pointer" %> +
+
+ + <% 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/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/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/app/views/layouts/_header.html.erb b/app/views/layouts/_header.html.erb index 369e3e1..3ae9325 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,10 @@ +
+ +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 022e8e6..4898c3b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -15,6 +15,21 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> + @@ -27,5 +42,6 @@ <%= yield %> + diff --git a/app/views/mods/_mod.html.erb b/app/views/mods/_mod.html.erb index e9482c0..c82b217 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 %> @@ -26,4 +26,4 @@ <% end %> <%= truncate(mod.description.to_s, length: 120) %> - \ No newline at end of file + 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 %> 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. 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 diff --git a/config/routes.rb b/config/routes.rb index 0630084..745a200 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,17 @@ get "home", to: "home#index" get "info", to: "info#index" + # 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" + 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/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", diff --git a/spec/helpers/pagination_helper_spec.rb b/spec/helpers/pagination_helper_spec.rb new file mode 100644 index 0000000..5308667 --- /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 described_class + + 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 described_class::PaginationResult do + subject(:result) do + described_class.new( + items: [], current_page: current_page, total_pages: 5, total_count: 100, per_page: 20 + ) + end + + context "when 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 "when 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 "when 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 + described_class.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 = 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 = described_class.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 diff --git a/spec/models/site_content_spec.rb b/spec/models/site_content_spec.rb new file mode 100644 index 0000000..ff96161 --- /dev/null +++ b/spec/models/site_content_spec.rb @@ -0,0 +1,80 @@ +# 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) + # Override global SiteContent.find stub so we test real implementation + allow(described_class).to receive(:find).and_call_original + 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(described_class) + 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(described_class) + 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..c2715dc --- /dev/null +++ b/spec/requests/admin/info_spec.rb @@ -0,0 +1,73 @@ +# 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" } + + 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 + + # 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 "redirects to login" do + get admin_info_edit_path + expect(response).to redirect_to(admin_login_path) + end + end + + 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) + ) + admin_login + get admin_info_edit_path + 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 + 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 diff --git a/spec/support/firestore.rb b/spec/support/firestore.rb index f1859cc..b864e56 100644 --- a/spec/support/firestore.rb +++ b/spec/support/firestore.rb @@ -14,5 +14,21 @@ { type: "service_account", project_id: "test-project" } ) end + + # Stub SiteContent.find globally so info page specs don't hit Firestore + allow(SiteContent).to receive(:find).and_return(nil) if defined?(SiteContent) + 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_defined?(:@firestore) + SiteContent.instance_variable_set(:@firestore, nil) + end end end 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