require "fileutils" module Maptool # Filesystem-backed store for uploaded GPX files, organized in # user-chosen folders (arbitrary nesting). All paths coming from the # outside (folder names, file paths in URLs) are resolved and checked # against the store root before touching the filesystem, so path # traversal (e.g. "../../etc") can't escape it. class GpxStore class InvalidPath < StandardError; end def initialize(root) @root = File.expand_path(root) FileUtils.mkdir_p(@root) end # Nested { type:, name:, path:, children: } hash describing every # folder and .gpx file under the store root. def tree { "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") } end # Saves `io`'s contents as a .gpx file under `folder` (created if # needed), deriving a safe filename from `filename`. Returns the # stored file's path relative to the store root. def save(folder:, filename:, io:) dir = resolve_dir(folder.to_s) FileUtils.mkdir_p(dir) name = unique_filename(dir, safe_filename(filename)) File.binwrite(File.join(dir, name), io.read) relative = folder.to_s.empty? ? name : "#{folder}/#{name}" relative end # Absolute filesystem path for a store-relative file path, after # confirming it stays within the store root. def resolve_file(relative_path) path = File.expand_path(File.join(@root, relative_path.to_s)) raise InvalidPath unless within_root?(path) path end def create_folder(path) raise InvalidPath if path.to_s.strip.empty? FileUtils.mkdir_p(resolve_dir(path.to_s)) end def delete_file(relative_path) path = resolve_file(relative_path) raise Errno::ENOENT, path unless File.file?(path) File.delete(path) end def delete_folder(relative_path) raise InvalidPath if relative_path.to_s.strip.empty? dir = resolve_dir(relative_path.to_s) raise InvalidPath if dir == @root raise Errno::ENOENT, dir unless File.directory?(dir) FileUtils.rm_rf(dir) end # Moves a file into `to_folder` (created if needed), disambiguating the # filename on collision. Returns the new store-relative path. def move(from:, to_folder:) source = resolve_file(from) raise Errno::ENOENT, source unless File.file?(source) dest_dir = resolve_dir(to_folder.to_s) FileUtils.mkdir_p(dest_dir) name = unique_filename(dest_dir, File.basename(source)) FileUtils.mv(source, File.join(dest_dir, name)) to_folder.to_s.empty? ? name : "#{to_folder}/#{name}" end private def resolve_dir(folder) path = File.expand_path(File.join(@root, folder)) raise InvalidPath unless within_root?(path) path end def within_root?(path) path == @root || path.start_with?(@root + File::SEPARATOR) end def safe_filename(filename) base = File.basename(filename.to_s) base = "upload.gpx" if base.empty? || base == "." || base == ".." base = "#{base}.gpx" unless base.downcase.end_with?(".gpx") base end def unique_filename(dir, name) return name unless File.exist?(File.join(dir, name)) ext = File.extname(name) stem = name[0...-ext.length] n = 1 n += 1 while File.exist?(File.join(dir, "#{stem}-#{n}#{ext}")) "#{stem}-#{n}#{ext}" end def list_children(dir, relative) entries = Dir.children(dir).sort_by { |entry| [File.directory?(File.join(dir, entry)) ? 0 : 1, entry] } entries.filter_map do |entry| full = File.join(dir, entry) rel = relative.empty? ? entry : "#{relative}/#{entry}" if File.directory?(full) { "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) } elsif entry.downcase.end_with?(".gpx") { "type" => "file", "name" => entry, "path" => rel } end end end end end