Each tree entry now shows a small waypoint/track icon based on a cheap substring check for <wpt>/<trk> (Maptool::Gpx.kinds) rather than a full REXML parse, since re-parsing a several-thousand-waypoint file on every tree fetch would make the panel noticeably slower to open. Also fix a real drop-target bug: .gpx-tree had no flex:1, so a short/empty tree left blank panel space below it that wasn't part of the drag & drop target — dropping a file there silently did nothing.
127 lines
4.0 KiB
Ruby
127 lines
4.0 KiB
Ruby
require "fileutils"
|
|
require_relative "gpx"
|
|
|
|
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, "kinds" => Gpx.kinds(full) }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|