diff --git a/apps/api/app/admin/files.rb b/apps/api/app/admin/files.rb new file mode 100644 index 0000000..d9e8720 --- /dev/null +++ b/apps/api/app/admin/files.rb @@ -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 diff --git a/apps/api/app/assets/javascripts/active_admin.js b/apps/api/app/assets/javascripts/active_admin.js index d2b66c5..320ebc5 100644 --- a/apps/api/app/assets/javascripts/active_admin.js +++ b/apps/api/app/assets/javascripts/active_admin.js @@ -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); + }); + } +})(); diff --git a/apps/api/app/assets/stylesheets/active_admin.scss b/apps/api/app/assets/stylesheets/active_admin.scss index 20a261f..711e0f7 100644 --- a/apps/api/app/assets/stylesheets/active_admin.scss +++ b/apps/api/app/assets/stylesheets/active_admin.scss @@ -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; } +} diff --git a/apps/api/app/services/file_manager_service.rb b/apps/api/app/services/file_manager_service.rb new file mode 100644 index 0000000..4735db7 --- /dev/null +++ b/apps/api/app/services/file_manager_service.rb @@ -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