diff --git a/web/Dockerfile b/web/Dockerfile index 9be0022..86d7f97 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -53,9 +53,9 @@ COPY --chown=potatomesh:potatomesh web/views/ ./views/ COPY --chown=potatomesh:potatomesh data/*.sql /data/ # Create data and configuration directories with correct ownership -RUN mkdir -p /app/data \ - && mkdir -p /app/.config/well-known \ - && chown -R potatomesh:potatomesh /app/data /app/.config +RUN mkdir -p /app/.local/share/potato-mesh \ + && mkdir -p /app/.config/potato-mesh/well-known \ + && chown -R potatomesh:potatomesh /app/.local/share /app/.config # Switch to non-root user USER potatomesh @@ -66,6 +66,8 @@ EXPOSE 41447 # Default environment variables (can be overridden by host) ENV RACK_ENV=production \ APP_ENV=production \ + XDG_DATA_HOME=/app/.local/share \ + XDG_CONFIG_HOME=/app/.config \ MESH_DB=/app/data/mesh.db \ DB_BUSY_TIMEOUT_MS=5000 \ DB_BUSY_MAX_RETRIES=5 \ diff --git a/web/lib/potato_mesh/application.rb b/web/lib/potato_mesh/application.rb index 78045e2..83efb9c 100644 --- a/web/lib/potato_mesh/application.rb +++ b/web/lib/potato_mesh/application.rb @@ -47,6 +47,7 @@ require_relative "application/federation" require_relative "application/prometheus" require_relative "application/queries" require_relative "application/data_processing" +require_relative "application/filesystem" require_relative "application/routes/api" require_relative "application/routes/ingest" require_relative "application/routes/root" @@ -61,6 +62,7 @@ module PotatoMesh extend App::Prometheus extend App::Queries extend App::DataProcessing + extend App::Filesystem helpers App::Helpers include App::Database @@ -70,6 +72,7 @@ module PotatoMesh include App::Prometheus include App::Queries include App::DataProcessing + include App::Filesystem register App::Routes::Api register App::Routes::Ingest @@ -119,6 +122,7 @@ module PotatoMesh apply_logger_level! + perform_initial_filesystem_setup! cleanup_legacy_well_known_artifacts init_db unless db_schema_present? ensure_schema_upgrades diff --git a/web/lib/potato_mesh/application/filesystem.rb b/web/lib/potato_mesh/application/filesystem.rb new file mode 100644 index 0000000..1e176ca --- /dev/null +++ b/web/lib/potato_mesh/application/filesystem.rb @@ -0,0 +1,99 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "fileutils" + +module PotatoMesh + module App + # Filesystem helpers responsible for migrating legacy assets to XDG compliant + # directories and preparing runtime storage locations. + module Filesystem + # Execute all filesystem migrations required before the application boots. + # + # @return [void] + def perform_initial_filesystem_setup! + migrate_legacy_database! + migrate_legacy_keyfile! + end + + private + + # Copy the legacy database file into the configured XDG data directory. + # + # @return [void] + def migrate_legacy_database! + return unless default_database_destination? + + migrate_legacy_file( + PotatoMesh::Config.legacy_db_path, + PotatoMesh::Config.db_path, + chmod: 0o600, + context: "filesystem.db", + ) + end + + # Copy the legacy keyfile into the configured XDG configuration directory. + # + # @return [void] + def migrate_legacy_keyfile! + migrate_legacy_file( + PotatoMesh::Config.legacy_keyfile_path, + PotatoMesh::Config.keyfile_path, + chmod: 0o600, + context: "filesystem.keys", + ) + end + + # Migrate a legacy file if it exists and the destination has not been created yet. + # + # @param source_path [String] absolute path to the legacy file. + # @param destination_path [String] absolute path to the new file location. + # @param chmod [Integer, nil] optional permission bits applied to the destination file. + # @param context [String] logging context describing the migration target. + # @return [void] + def migrate_legacy_file(source_path, destination_path, chmod:, context:) + return if source_path == destination_path + return unless File.exist?(source_path) + return if File.exist?(destination_path) + + FileUtils.mkdir_p(File.dirname(destination_path)) + FileUtils.cp(source_path, destination_path) + File.chmod(chmod, destination_path) if chmod + + debug_log( + "Migrated legacy file to XDG directory", + context: context, + source: source_path, + destination: destination_path, + ) + rescue SystemCallError => e + warn_log( + "Failed to migrate legacy file", + context: context, + source: source_path, + destination: destination_path, + error_class: e.class.name, + error_message: e.message, + ) + end + + # Determine whether the database destination matches the configured default. + # + # @return [Boolean] true when the destination should receive migrated data. + def default_database_destination? + PotatoMesh::Config.db_path == PotatoMesh::Config.default_db_path + end + end + end +end diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 3680f1e..0629a74 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -32,10 +32,31 @@ module PotatoMesh @repo_root ||= File.expand_path("..", web_root) end + # Resolve the current XDG data directory for PotatoMesh content. + # + # @return [String] absolute path to the PotatoMesh data directory. + def data_directory + File.join(resolve_xdg_home("XDG_DATA_HOME", %w[.local share]), "potato-mesh") + end + + # Resolve the current XDG configuration directory for PotatoMesh files. + # + # @return [String] absolute path to the PotatoMesh configuration directory. + def config_directory + File.join(resolve_xdg_home("XDG_CONFIG_HOME", %w[.config]), "potato-mesh") + end + # Build the default SQLite database path inside the data directory. # - # @return [String] absolute path to +data/mesh.db+. + # @return [String] absolute path to the managed +mesh.db+ file. def default_db_path + File.join(data_directory, "mesh.db") + end + + # Legacy database path bundled alongside the repository. + # + # @return [String] absolute path to the repository managed database file. + def legacy_db_path File.expand_path("../data/mesh.db", web_root) end @@ -166,7 +187,7 @@ module PotatoMesh # # @return [String] absolute location of the PEM file. def keyfile_path - File.join(web_root, ".config", "keyfile") + File.join(config_directory, "keyfile") end # Sub-path used when exposing well known configuration files. @@ -180,7 +201,21 @@ module PotatoMesh # # @return [String] absolute storage path. def well_known_storage_root - File.join(web_root, ".config", "well-known") + File.join(config_directory, "well-known") + end + + # Legacy configuration directory bundled with the repository. + # + # @return [String] absolute path to the repository managed configuration directory. + def legacy_config_directory + File.join(web_root, ".config") + end + + # Legacy keyfile location used before introducing XDG directories. + # + # @return [String] absolute filesystem path to the legacy keyfile. + def legacy_keyfile_path + File.join(legacy_config_directory, "keyfile") end # Legacy location for well known assets within the public folder. @@ -308,5 +343,31 @@ module PotatoMesh trimmed = value.strip trimmed.empty? ? default : trimmed end + + # Resolve the effective XDG directory honoring environment overrides. + # + # @param env_key [String] name of the environment variable to inspect. + # @param fallback_segments [Array] path segments appended to the user home directory. + # @return [String] absolute base directory referenced by the XDG variable. + def resolve_xdg_home(env_key, fallback_segments) + raw = fetch_string(env_key, nil) + candidate = raw && !raw.empty? ? raw : nil + return File.expand_path(candidate) if candidate + + base_home = safe_home_directory + File.expand_path(File.join(base_home, *fallback_segments)) + end + + # Retrieve the current user's home directory handling runtime failures. + # + # @return [String] absolute path to the user home or web root fallback. + def safe_home_directory + home = Dir.home + return web_root if home.nil? || home.empty? + + home + rescue ArgumentError, RuntimeError + web_root + end end end diff --git a/web/spec/config_spec.rb b/web/spec/config_spec.rb index 48fc97f..33d9daa 100644 --- a/web/spec/config_spec.rb +++ b/web/spec/config_spec.rb @@ -15,6 +15,91 @@ require "spec_helper" RSpec.describe PotatoMesh::Config do + describe ".data_directory" do + it "uses the configured XDG data home when provided" do + Dir.mktmpdir do |dir| + data_home = File.join(dir, "xdg-data") + within_env("XDG_DATA_HOME" => data_home) do + expect(described_class.data_directory).to eq(File.join(data_home, "potato-mesh")) + end + end + end + + it "falls back to the user home directory" do + within_env("XDG_DATA_HOME" => nil) do + allow(Dir).to receive(:home).and_return("/home/spec") + expect(described_class.data_directory).to eq("/home/spec/.local/share/potato-mesh") + end + ensure + allow(Dir).to receive(:home).and_call_original + end + + it "falls back to the web root when the home directory is unavailable" do + within_env("XDG_DATA_HOME" => nil) do + allow(Dir).to receive(:home).and_raise(ArgumentError) + expected = File.join(described_class.web_root, ".local", "share", "potato-mesh") + expect(described_class.data_directory).to eq(expected) + end + ensure + allow(Dir).to receive(:home).and_call_original + end + + it "falls back to the web root when the home directory is nil" do + within_env("XDG_DATA_HOME" => nil) do + allow(Dir).to receive(:home).and_return(nil) + expected = File.join(described_class.web_root, ".local", "share", "potato-mesh") + expect(described_class.data_directory).to eq(expected) + end + ensure + allow(Dir).to receive(:home).and_call_original + end + end + + describe ".config_directory" do + it "uses the configured XDG config home when provided" do + Dir.mktmpdir do |dir| + config_home = File.join(dir, "xdg-config") + within_env("XDG_CONFIG_HOME" => config_home) do + expect(described_class.config_directory).to eq(File.join(config_home, "potato-mesh")) + end + end + end + + it "falls back to the web root when the home directory is empty" do + within_env("XDG_CONFIG_HOME" => nil) do + allow(Dir).to receive(:home).and_return("") + expected = File.join(described_class.web_root, ".config", "potato-mesh") + expect(described_class.config_directory).to eq(expected) + end + ensure + allow(Dir).to receive(:home).and_call_original + end + end + + describe ".legacy_config_directory" do + it "returns the repository managed configuration directory" do + expect(described_class.legacy_config_directory).to eq( + File.join(described_class.web_root, ".config"), + ) + end + end + + describe ".legacy_keyfile_path" do + it "returns the legacy keyfile location" do + expect(described_class.legacy_keyfile_path).to eq( + File.join(described_class.web_root, ".config", "keyfile"), + ) + end + end + + describe ".legacy_db_path" do + it "returns the bundled database location" do + expect(described_class.legacy_db_path).to eq( + File.expand_path("../data/mesh.db", described_class.web_root), + ) + end + end + describe ".federation_announcement_interval" do it "returns eight hours in seconds" do expect(described_class.federation_announcement_interval).to eq(8 * 60 * 60) diff --git a/web/spec/filesystem_spec.rb b/web/spec/filesystem_spec.rb new file mode 100644 index 0000000..c30b1aa --- /dev/null +++ b/web/spec/filesystem_spec.rb @@ -0,0 +1,188 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe PotatoMesh::App::Filesystem do + let(:harness_class) do + Class.new do + extend PotatoMesh::App::Filesystem + + class << self + def debug_entries + @debug_entries ||= [] + end + + def warning_entries + @warning_entries ||= [] + end + + def debug_log(message, context:, **metadata) + debug_entries << { message: message, context: context, metadata: metadata } + end + + def warn_log(message, context:, **metadata) + warning_entries << { message: message, context: context, metadata: metadata } + end + + def reset_logs! + @debug_entries = [] + @warning_entries = [] + end + end + end + end + + around do |example| + harness_class.reset_logs! + example.run + harness_class.reset_logs! + end + + describe "#perform_initial_filesystem_setup!" do + it "migrates the legacy database and keyfile" do + Dir.mktmpdir do |dir| + legacy_db = File.join(dir, "legacy", "mesh.db") + legacy_key = File.join(dir, "legacy-config", "keyfile") + new_db = File.join(dir, "data", "potato-mesh", "mesh.db") + new_key = File.join(dir, "config", "potato-mesh", "keyfile") + + FileUtils.mkdir_p(File.dirname(legacy_db)) + File.write(legacy_db, "db") + FileUtils.mkdir_p(File.dirname(legacy_key)) + File.write(legacy_key, "key") + + allow(PotatoMesh::Config).to receive_messages( + legacy_db_path: legacy_db, + db_path: new_db, + default_db_path: new_db, + legacy_keyfile_path: legacy_key, + keyfile_path: new_key, + ) + + harness_class.perform_initial_filesystem_setup! + + expect(File).to exist(new_db) + expect(File).to exist(new_key) + expect(File.read(new_db)).to eq("db") + expect(File.read(new_key)).to eq("key") + expect(File.stat(new_key).mode & 0o777).to eq(0o600) + expect(File.stat(new_db).mode & 0o777).to eq(0o600) + expect(harness_class.debug_entries.size).to eq(2) + expect(harness_class.warning_entries).to be_empty + end + end + + it "skips database migration when using a custom destination" do + Dir.mktmpdir do |dir| + legacy_db = File.join(dir, "legacy", "mesh.db") + new_db = File.join(dir, "custom", "database.db") + + FileUtils.mkdir_p(File.dirname(legacy_db)) + File.write(legacy_db, "db") + + allow(PotatoMesh::Config).to receive_messages( + legacy_db_path: legacy_db, + db_path: new_db, + default_db_path: File.join(dir, "default", "mesh.db"), + legacy_keyfile_path: File.join(dir, "old", "keyfile"), + keyfile_path: File.join(dir, "config", "keyfile"), + ) + + harness_class.perform_initial_filesystem_setup! + + expect(File).not_to exist(new_db) + end + end + end + + describe "private migration helpers" do + it "does not migrate when the source is missing" do + Dir.mktmpdir do |dir| + destination = File.join(dir, "target", "file") + harness_class.send( + :migrate_legacy_file, + File.join(dir, "missing"), + destination, + chmod: 0o600, + context: "spec.context", + ) + + expect(File).not_to exist(destination) + expect(harness_class.debug_entries).to be_empty + end + end + + it "does not overwrite existing destinations" do + Dir.mktmpdir do |dir| + source = File.join(dir, "source") + destination = File.join(dir, "destination") + + File.write(source, "alpha") + FileUtils.mkdir_p(File.dirname(destination)) + File.write(destination, "beta") + + harness_class.send( + :migrate_legacy_file, + source, + destination, + chmod: 0o600, + context: "spec.context", + ) + + expect(File.read(destination)).to eq("beta") + end + end + + it "ignores migrations when the source and destination are identical" do + Dir.mktmpdir do |dir| + path = File.join(dir, "shared") + File.write(path, "same") + + harness_class.send( + :migrate_legacy_file, + path, + path, + chmod: 0o600, + context: "spec.context", + ) + + expect(harness_class.debug_entries).to be_empty + end + end + + it "logs warnings when the migration fails" do + Dir.mktmpdir do |dir| + source = File.join(dir, "source") + destination = File.join(dir, "destination") + File.write(source, "data") + + allow(FileUtils).to receive(:mkdir_p).and_raise(Errno::EACCES) + + harness_class.send( + :migrate_legacy_file, + source, + destination, + chmod: 0o600, + context: "spec.context", + ) + + expect(harness_class.warning_entries.size).to eq(1) + expect(harness_class.debug_entries).to be_empty + end + ensure + allow(FileUtils).to receive(:mkdir_p).and_call_original + end + end +end diff --git a/web/spec/spec_helper.rb b/web/spec/spec_helper.rb index d58616a..d3ed236 100644 --- a/web/spec/spec_helper.rb +++ b/web/spec/spec_helper.rb @@ -37,6 +37,11 @@ ENV["RACK_ENV"] = "test" SPEC_TMPDIR = Dir.mktmpdir("potato-mesh-spec-") ENV["MESH_DB"] = File.join(SPEC_TMPDIR, "mesh.db") +ENV["XDG_DATA_HOME"] = File.join(SPEC_TMPDIR, "xdg-data") +ENV["XDG_CONFIG_HOME"] = File.join(SPEC_TMPDIR, "xdg-config") + +FileUtils.mkdir_p(ENV["XDG_DATA_HOME"]) +FileUtils.mkdir_p(ENV["XDG_CONFIG_HOME"]) require_relative "../app"