Uploading is now drag & drop (or click to browse) instead of a form; folders can be created any time, not just during upload; files can be dragged onto a folder to move them, or dropped elsewhere in the tree to move back to the top level; files and folders can be deleted. Both drag sources (an OS file drag and dragging an existing tree entry) land on the same drop targets, told apart by a custom drag payload type versus the browser's own "Files" type. Also sort folders before files in the tree (previously interleaved alphabetically).
126 lines
3.9 KiB
Ruby
126 lines
3.9 KiB
Ruby
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
|