file manager

This commit is contained in:
2026-07-27 09:23:55 +02:00
parent a6bfe60d65
commit a9d6cedd1e
4 changed files with 346 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
ActiveAdmin.register_page "Files" do
menu priority: 6, label: "Files"
content do
service = FileManagerService.new
current_dir = params[:dir].to_s.presence || ""
picker_mode = params[:picker].present?
picker_field = params[:field].to_s
begin
entries = service.list(current_dir)
rescue ArgumentError
entries = []
end
div class: "file-manager" do
h2 do
text_node "File Manager"
if picker_mode
span " (Select a file)", style: "font-size:14px; color:#888; font-weight:normal;"
end
end
# Breadcrumbs
div class: "fm-breadcrumbs" do
parts = current_dir.split("/").reject(&:blank?)
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
a "softwares/", href: admin_files_path(picker_params)
accumulated = ""
parts.each do |part|
accumulated = [accumulated, part].reject(&:blank?).join("/")
a "#{part}/", href: admin_files_path(picker_params.merge(dir: accumulated))
end
end
# Action bar
div class: "fm-actions" do
form action: admin_files_upload_path, method: "post", enctype: "multipart/form-data", class: "fm-inline-form" do |_f|
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
input type: "hidden", name: "dir", value: current_dir
input type: "file", name: "file", required: true
input type: "submit", value: "Upload", class: "fm-btn"
end
form action: admin_files_mkdir_path, method: "post", class: "fm-inline-form" do |_f|
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
input type: "hidden", name: "dir", value: current_dir
input type: "text", name: "name", placeholder: "New folder name", required: true, class: "fm-text-input"
input type: "submit", value: "Create Folder", class: "fm-btn"
end
end
# File listing
table class: "fm-table" do
thead do
tr do
th "Type", style: "width:60px"
th "Name"
th "Size", style: "width:100px"
th "Modified", style: "width:160px"
th "Actions", style: "width:#{picker_mode ? '280' : '180'}px"
end
end
tbody do
# Parent directory link
if current_dir.present?
parent = File.dirname(current_dir)
parent = "" if parent == "."
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
tr do
td "DIR"
td do
a "..", href: admin_files_path(picker_params.merge(dir: parent))
end
td ""
td ""
td ""
end
end
entries.each do |entry|
tr do
td class: "fm-type" do
entry[:type] == :directory ? "DIR" : "FILE"
end
td do
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
if entry[:type] == :directory
a entry[:name], href: admin_files_path(picker_params.merge(dir: entry[:path]))
else
span entry[:name]
end
end
td do
text_node(entry[:size] ? number_to_human_size(entry[:size]) : "")
end
td do
text_node(entry[:mtime]&.strftime("%Y-%m-%d %H:%M") || "")
end
td class: "fm-entry-actions" do
entry_id = entry[:path].parameterize
# Rename
a "Rename", href: "#", class: "fm-action-link", onclick: "var n=prompt('New name:','#{j entry[:name]}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;"
text_node " "
# Delete
a "Delete", href: "#", class: "fm-action-link fm-danger",
onclick: "if(confirm('Delete \\'#{j entry[:name]}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;"
# Picker mode: Select button
if picker_mode
text_node " "
abs_path = "/softwares/#{entry[:path]}"
a "Select", href: "#", class: "fm-btn fm-btn-select",
onclick: "window.opener.postMessage({type:'file-picked',field:'#{j picker_field}',path:'#{j abs_path}'},'*');window.close();return false;"
end
# Hidden rename form
form action: admin_files_rename_path, method: "post", id: "rename-#{entry_id}", style: "display:none" do
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
input type: "hidden", name: "path", value: entry[:path]
input type: "hidden", name: "new_name", value: ""
input type: "hidden", name: "dir", value: current_dir
end
# Hidden delete form
form action: admin_files_delete_path, method: "post", id: "delete-#{entry_id}", style: "display:none" do
input type: "hidden", name: "authenticity_token", value: form_authenticity_token
input type: "hidden", name: "_method", value: "delete"
input type: "hidden", name: "path", value: entry[:path]
input type: "hidden", name: "dir", value: current_dir
end
end
end
end
end
end
if entries.empty? && current_dir.blank?
para "No files found in /softwares", style: "color:#999; text-align:center; padding:40px 0;"
end
end
end
page_action :upload, method: :post do
service = FileManagerService.new
service.upload(params[:dir].to_s, params[:file])
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "File uploaded."
rescue => e
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Upload failed: #{e.message}"
end
page_action :mkdir, method: :post do
service = FileManagerService.new
service.mkdir(params[:dir].to_s, params[:name].to_s)
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "Folder created."
rescue => e
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Failed: #{e.message}"
end
page_action :rename, method: :post do
service = FileManagerService.new
service.rename(params[:path].to_s, params[:new_name].to_s)
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), notice: "Renamed."
rescue => e
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Rename failed: #{e.message}"
end
page_action :delete, method: :delete do
service = FileManagerService.new
dir = params[:dir].to_s.presence || begin
d = File.dirname(params[:path].to_s)
d == "." ? "" : d
end
service.delete(params[:path].to_s)
redirect_to admin_files_path(dir: dir, picker: params[:picker], field: params[:field]), notice: "Deleted."
rescue => e
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Delete failed: #{e.message}"
end
end
@@ -1 +1,46 @@
//= require active_admin/base
(function() {
var PATH_FIELDS = ['html_folder_path', 'cartridge_path', 'source_path', 'docs_folder_path'];
function addBrowseButtons() {
PATH_FIELDS.forEach(function(fieldName) {
var inputs = document.querySelectorAll('input[id*="' + fieldName + '"]:not([data-browse-added])');
inputs.forEach(function(input) {
input.setAttribute('data-browse-added', 'true');
var btn = document.createElement('a');
btn.href = '#';
btn.textContent = 'Browse';
btn.className = 'fm-browse-btn';
btn.addEventListener('click', function(e) {
e.preventDefault();
window.open(
'/admin/files?picker=1&field=' + encodeURIComponent(input.id),
'file_picker',
'width=960,height=640,scrollbars=yes,resizable=yes'
);
});
input.parentNode.insertBefore(btn, input.nextSibling);
});
});
}
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'file-picked') {
var input = document.getElementById(event.data.field);
if (input) {
input.value = event.data.path;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
});
document.addEventListener('DOMContentLoaded', addBrowseButtons);
// ActiveAdmin has_many dynamic rows
if (typeof jQuery !== 'undefined') {
jQuery(document).on('has_many_add:after', function() {
setTimeout(addBrowseButtons, 50);
});
}
})();
@@ -1,3 +1,34 @@
@import "active_admin/mixins";
@import "active_admin/base";
@import "activeadmin_blaze_theme/theme";
// File Manager
.fm-breadcrumbs {
background: #f5f5f5; padding: 10px 14px; border-radius: 4px; margin-bottom: 16px; font-family: monospace; font-size: 14px;
a { color: #5a6268; text-decoration: none; &:hover { text-decoration: underline; } }
}
.fm-actions {
display: flex; gap: 16px; align-items: center; margin-bottom: 16px; padding: 12px; background: #fafafa; border: 1px solid #e0e0e0; border-radius: 4px;
}
.fm-inline-form { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
.fm-text-input { padding: 4px 8px; border: 1px solid #ccc; border-radius: 3px; font-size: 13px; }
.fm-btn { display: inline-block; padding: 5px 12px; background: #5a6268; color: #fff; border: none; border-radius: 3px; font-size: 12px; cursor: pointer; text-decoration: none;
&:hover { background: #484e53; color: #fff; }
}
.fm-btn-select { background: #28a745; &:hover { background: #218838; } }
.fm-table {
width: 100%; border-collapse: collapse;
th { background: #f0f0f0; padding: 8px 12px; text-align: left; font-size: 12px; text-transform: uppercase; color: #666; border-bottom: 2px solid #ddd; }
td { padding: 8px 12px; border-bottom: 1px solid #eee; font-size: 13px; }
tr:hover td { background: #f9f9f9; }
}
.fm-type { font-family: monospace; font-size: 11px; color: #999; }
.fm-action-link { font-size: 12px; color: #5a6268; text-decoration: none; margin-right: 8px; &:hover { text-decoration: underline; } }
.fm-danger { color: #dc3545; }
.fm-entry-actions { white-space: nowrap; }
// Browse button in release forms
.fm-browse-btn {
display: inline-block; margin-left: 8px; padding: 4px 10px; background: #6c757d; color: #fff; border-radius: 3px; font-size: 11px; text-decoration: none; vertical-align: middle;
&:hover { background: #5a6268; color: #fff; }
}
@@ -0,0 +1,85 @@
class FileManagerService
BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares"))
def list(relative_path = "")
full = safe_path!(relative_path)
raise ArgumentError, "Not a directory" unless full.directory?
entries = full.children.sort_by { |c| [c.directory? ? 0 : 1, c.basename.to_s.downcase] }
entries.map do |child|
stat = child.stat
{
name: child.basename.to_s,
path: child.relative_path_from(BASE_PATH).to_s,
type: child.directory? ? :directory : :file,
size: child.directory? ? nil : stat.size,
mtime: stat.mtime
}
end
end
def upload(relative_dir, uploaded_file)
dir = safe_path!(relative_dir)
raise ArgumentError, "Not a directory" unless dir.directory?
safe_name = sanitize_name(uploaded_file.original_filename)
target = dir.join(safe_name)
raise ArgumentError, "Path escape" unless target.to_s.start_with?(BASE_PATH.to_s)
IO.copy_stream(uploaded_file.to_io, target.to_s)
target.relative_path_from(BASE_PATH).to_s
end
def delete(relative_path)
full = safe_path!(relative_path)
raise ArgumentError, "Cannot delete root" if full == BASE_PATH
if full.directory?
full.rmdir
else
full.delete
end
end
def rename(relative_path, new_name)
full = safe_path!(relative_path)
raise ArgumentError, "Cannot rename root" if full == BASE_PATH
safe_name = sanitize_name(new_name)
new_full = full.parent.join(safe_name)
raise ArgumentError, "Path escape" unless new_full.to_s.start_with?(BASE_PATH.to_s)
full.rename(new_full)
new_full.relative_path_from(BASE_PATH).to_s
end
def mkdir(relative_path, folder_name)
parent = safe_path!(relative_path)
raise ArgumentError, "Not a directory" unless parent.directory?
safe_name = sanitize_name(folder_name)
new_dir = parent.join(safe_name)
raise ArgumentError, "Path escape" unless new_dir.to_s.start_with?(BASE_PATH.to_s)
new_dir.mkdir
new_dir.relative_path_from(BASE_PATH).to_s
end
private
def safe_path!(relative_path)
cleaned = relative_path.to_s.gsub("..", "").squeeze("/").gsub(%r{^/|/$}, "")
full = BASE_PATH.join(cleaned)
resolved = full.exist? ? full.realpath : full.cleanpath
unless resolved.to_s.start_with?(BASE_PATH.to_s)
raise ArgumentError, "Path traversal detected"
end
resolved
end
def sanitize_name(name)
name.to_s.gsub("..", "").gsub("/", "").gsub("\\", "").strip.tap do |n|
raise ArgumentError, "Invalid name" if n.blank?
end
end
end