file manager
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user