The file manager's rename prompt was empty: Kernel#j, not escape_javascript

The admin file manager builds its rename and delete buttons with inline
handlers, and interpolated the file name through `j`:

    onclick: "var n=prompt('New name:','#{j entry[:name]}');..."

In a view `j` is `escape_javascript`. Inside an Arbre block it is not: Arbre
resolves unknown methods through `method_missing`, and `j` is not unknown — it
is `Kernel#j`, which prints its argument as JSON to stdout and returns nil. So
every page load wrote the file names to the server log, and the browser got

    prompt('New name:','')

An admin pressing rename saw an empty prompt, and the delete confirmation asked
"Delete ''?". `escape_javascript(...)` spelled out is what those three
interpolations use now.

The page has no test, which is why nothing caught it. It has one now
(spec/requests/admin_files_spec.rb), and it asserts the file name is in both
handlers — with the icons, the folder creation, the failed folder creation and
the delete-returns-to-parent path, because those are the behaviours the
refactoring below could break silently.

Also in the page: the twenty-branch extension-to-emoji `case` moved out of the
view into `WarpEngine::FileIcon`, and the five page actions share one
`redirect_to_files` instead of repeating
`admin_files_path(dir:, picker:, field:)` six times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 09:02:09 +02:00
co-authored by Claude Opus 5
parent 4fc68e1448
commit 5d50ac69a1
3 changed files with 134 additions and 46 deletions
@@ -0,0 +1,76 @@
require "rails_helper"
require "warden/test/helpers"
RSpec.describe "Admin file manager", type: :request do
include Warden::Test::Helpers
let(:admin) { AdminUser.create!(email: "files-spec@example.org", password: "password123") }
let(:container) { Rails.root.join("tmp/files-spec").to_s }
before do
FileUtils.mkdir_p(File.join(container, "mygame-1.0"))
File.write(File.join(container, "mygame-1.0.zip"), "zipdata")
allow(WarpEngine.config).to receive(:file_container_path).and_return(container)
Warden.test_mode!
login_as(admin, scope: :admin_user)
end
after do
Warden.test_reset!
FileUtils.rm_rf(container)
end
around do |example|
protection = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = false
example.run
ActionController::Base.allow_forgery_protection = protection
end
it "lists the artifact directory with an icon per entry" do
get "/admin/files"
expect(response).to have_http_status(:ok)
expect(response.body).to include("mygame-1.0.zip")
expect(response.body).to include(WarpEngine::FileIcon.for("mygame-1.0.zip"))
expect(response.body).to include(WarpEngine::FileIcon::DIRECTORY)
end
it "puts the file name into the rename and delete prompts" do
get "/admin/files"
expect(response.body).to include("prompt(&#39;New name:&#39;,&#39;mygame-1.0.zip&#39;)")
expect(response.body).to include("Delete \\&#39;mygame-1.0.zip\\&#39;")
end
it "shows the download count next to a file that was downloaded" do
create(:download, file_path: "mygame-1.0.zip")
get "/admin/files"
expect(response.body).to include("mygame-1.0.zip")
end
it "creates a folder and returns to the directory it was created in" do
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "docs" }
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
expect(File.directory?(File.join(container, "mygame-1.0", "docs"))).to be(true)
end
it "reports a failed folder creation instead of raising" do
post "/admin/files/mkdir", params: { dir: "mygame-1.0", name: "" }
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
expect(flash[:alert]).to include("Failed")
end
it "deletes a file and returns to its parent directory" do
File.write(File.join(container, "mygame-1.0", "readme.txt"), "hi")
delete "/admin/files/delete", params: { path: "mygame-1.0/readme.txt" }
expect(response).to redirect_to("/admin/files?dir=mygame-1.0")
expect(File.exist?(File.join(container, "mygame-1.0", "readme.txt"))).to be(false)
end
end
+32 -46
View File
@@ -63,26 +63,6 @@ ActiveAdmin.register_page "Files" do
{}
end
file_icon = ->(name) do
ext = File.extname(name).downcase
case ext
when ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg" then "🖼️"
when ".mp3", ".ogg", ".wav", ".flac", ".aac" then "🔊"
when ".mp4", ".avi", ".mkv", ".webm", ".mov" then "🎬"
when ".zip", ".gz", ".tar", ".rar", ".7z", ".bz2" then "📦"
when ".pdf" then "📕"
when ".doc", ".docx", ".odt", ".txt", ".md", ".rtf" then "📄"
when ".xls", ".xlsx", ".csv", ".ods" then "📊"
when ".html", ".htm", ".css", ".js", ".ts", ".json", ".xml" then "📝"
when ".rb", ".py", ".lua", ".c", ".cpp", ".h", ".rs", ".go" then "💻"
when ".tic", ".rom", ".bin", ".prg", ".crt", ".d64", ".t64" then "🎮"
when ".love" then "🎮"
when ".exe", ".dmg", ".appimage", ".msi" then "⚙️"
when ".wasm" then "⚙️"
else "📄"
end
end
thead do
tr do
th "Name"
@@ -101,7 +81,7 @@ ActiveAdmin.register_page "Files" do
tr do
td do
span "📁 ", style: "font-size:15px;"
span "#{WarpEngine::FileIcon::DIRECTORY} ", style: "font-size:15px;"
a "..", href: admin_files_path(picker_params.merge(dir: parent))
end
td ""
@@ -116,10 +96,10 @@ ActiveAdmin.register_page "Files" do
td do
picker_params = picker_mode ? { picker: 1, field: picker_field } : {}
if entry[:type] == :directory
span "📁 ", style: "font-size:15px;"
span "#{WarpEngine::FileIcon::DIRECTORY} ", style: "font-size:15px;"
a entry[:name], href: admin_files_path(picker_params.merge(dir: entry[:path]))
else
span "#{file_icon.call(entry[:name])} ", style: "font-size:15px;"
span "#{WarpEngine::FileIcon.for(entry[:name])} ", style: "font-size:15px;"
span entry[:name]
end
end
@@ -143,10 +123,10 @@ ActiveAdmin.register_page "Files" do
end
a "✏️", href: "#", class: "fm-icon-btn", title: "Rename",
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;"
onclick: "var n=prompt('New name:','#{escape_javascript(entry[:name])}');if(n){var f=document.getElementById('rename-#{entry_id}');f.querySelector('[name=new_name]').value=n;f.submit();}return false;"
a "🗑️", href: "#", class: "fm-icon-btn fm-icon-danger", title: "Delete",
onclick: "if(confirm('Delete \\'#{j entry[:name]}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;"
onclick: "if(confirm('Delete \\'#{escape_javascript(entry[:name])}\\'?')){document.getElementById('delete-#{entry_id}').submit();}return false;"
if entry[:type] != :directory
a "📊", href: admin_downloads_path(q: { file_path_cont: entry[:path] }), class: "fm-icon-btn", title: "Stats"
@@ -155,7 +135,7 @@ ActiveAdmin.register_page "Files" do
if picker_mode
abs_path = File.join(WarpEngine.config.file_container_path, entry[:path])
a "Select", href: "#", class: "fm-btn fm-btn-select",
onclick: "var inp=window.parent.document.getElementById('#{j picker_field}');if(inp){inp.value='#{j abs_path}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;"
onclick: "var inp=window.parent.document.getElementById('#{escape_javascript(picker_field)}');if(inp){inp.value='#{escape_javascript(abs_path)}';}var ov=window.parent.document.querySelector('.fm-modal-overlay');if(ov){ov.remove();window.parent.document.body.style.overflow='';}return false;"
end
form action: admin_files_rename_path, method: "post", id: "rename-#{entry_id}", style: "display:none" do
@@ -203,38 +183,44 @@ ActiveAdmin.register_page "Files" do
end
page_action :upload, method: :post do
service = WarpEngine::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."
WarpEngine::FileManagerService.new.upload(params[:dir].to_s, params[:file])
redirect_to_files notice: "File uploaded."
rescue => e
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Upload failed: #{e.message}"
redirect_to_files alert: "Upload failed: #{e.message}"
end
page_action :mkdir, method: :post do
service = WarpEngine::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."
WarpEngine::FileManagerService.new.mkdir(params[:dir].to_s, params[:name].to_s)
redirect_to_files notice: "Folder created."
rescue => e
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Failed: #{e.message}"
redirect_to_files alert: "Failed: #{e.message}"
end
page_action :rename, method: :post do
service = WarpEngine::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."
WarpEngine::FileManagerService.new.rename(params[:path].to_s, params[:new_name].to_s)
redirect_to_files notice: "Renamed."
rescue => e
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Rename failed: #{e.message}"
redirect_to_files alert: "Rename failed: #{e.message}"
end
page_action :delete, method: :delete do
service = WarpEngine::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."
WarpEngine::FileManagerService.new.delete(params[:path].to_s)
redirect_to_files dir: params[:dir].presence || parent_dir_of(params[:path]), notice: "Deleted."
rescue => e
redirect_to admin_files_path(dir: params[:dir], picker: params[:picker], field: params[:field]), alert: "Delete failed: #{e.message}"
redirect_to_files alert: "Delete failed: #{e.message}"
end
controller do
private
def redirect_to_files(dir: nil, notice: nil, alert: nil)
target = admin_files_path(dir: dir || params[:dir], picker: params[:picker], field: params[:field])
redirect_to target, notice: notice, alert: alert
end
def parent_dir_of(path)
parent = File.dirname(path.to_s)
parent == "." ? "" : parent
end
end
end
@@ -0,0 +1,26 @@
module WarpEngine
module FileIcon
BY_EXTENSION = {
%w[.png .jpg .jpeg .gif .bmp .webp .svg] => "🖼️",
%w[.mp3 .ogg .wav .flac .aac] => "🔊",
%w[.mp4 .avi .mkv .webm .mov] => "🎬",
%w[.zip .gz .tar .rar .7z .bz2] => "📦",
%w[.pdf] => "📕",
%w[.doc .docx .odt .txt .md .rtf] => "📄",
%w[.xls .xlsx .csv .ods] => "📊",
%w[.html .htm .css .js .ts .json .xml] => "📝",
%w[.rb .py .lua .c .cpp .h .rs .go] => "💻",
%w[.tic .rom .bin .prg .crt .d64 .t64 .love] => "🎮",
%w[.exe .dmg .appimage .msi .wasm] => "⚙️"
}.freeze
DEFAULT = "📄".freeze
DIRECTORY = "📁".freeze
def self.for(name)
ext = File.extname(name.to_s).downcase
BY_EXTENSION.each { |extensions, icon| return icon if extensions.include?(ext) }
DEFAULT
end
end
end