use api for new file create via dashboard, deprecate old method

This commit is contained in:
Kyle Drake 2025-08-10 11:46:50 -05:00
parent c7dd74bb0d
commit 3f34b3ae62
8 changed files with 177 additions and 176 deletions

View file

@ -1,104 +1,3 @@
get '/site_files/new_page' do
require_login
@title = 'New Page'
erb :'site_files/new_page'
end
# Redirect from original path
get '/site_files/new' do
require_login
redirect '/site_files/new_page'
end
post '/site_files/create' do
require_login
@errors = []
filename = params[:filename]
filename.gsub!(/[^a-zA-Z0-9_\-.]/, '')
redirect_uri = '/dashboard'
redirect_uri += "?dir=#{Rack::Utils.escape params[:dir]}" if params[:dir]
if filename.nil? || filename.strip.empty?
flash[:error] = 'You must provide a file name.'
redirect redirect_uri
end
name = "#{filename}"
name = "#{params[:dir]}/#{name}" if params[:dir]
name = current_site.scrubbed_path name
if current_site.file_exists?(name)
flash[:error] = %{Web page "#{Rack::Utils.escape_html name}" already exists! Choose another name.}
redirect redirect_uri
end
if SiteFile.name_too_long?(name)
flash[:error] = "File name is too long (exceeds #{SiteFile::FILE_NAME_CHARACTER_LIMIT} characters)."
redirect redirect_uri
end
extname = File.extname name
unless extname.empty? || extname.match(/^\.#{Site::EDITABLE_FILE_EXT}/i)
flash[:error] = "Must be an editable text file type (#{Site::VALID_EDITABLE_EXTENSIONS.join(', ')})."
redirect redirect_uri
end
site_file = current_site.site_files_dataset.where(path: name).first
if site_file
flash[:error] = 'File already exists, cannot create.'
redirect redirect_uri
end
if extname.match(/^\.html|^\.htm/i)
begin
current_site.install_new_html_file name
rescue Sequel::UniqueConstraintViolation
end
else
file_path = current_site.files_path(name)
File.open(file_path, 'a', 0640) {}
site_file ||= SiteFile.new site_id: current_site.id, path: name
site_file.size = 0
site_file.set size: 0
site_file.set sha1_hash: Digest::SHA1.hexdigest('')
site_file.set updated_at: Time.now
site_file.save
end
escaped_name = Rack::Utils.escape_html name
flash[:success] = %{#{escaped_name} was created! <a style="color: #FFFFFF; text-decoration: underline" href="/site_files/text_editor/#{escaped_name}">Click here to edit it</a>.}
redirect redirect_uri
end
def file_upload_response(error=nil)
if error
flash[:error] = error
end
if params[:from_button]
query_string = params[:dir] ? "?"+Rack::Utils.build_query(dir: params[:dir]) : ''
redirect "/dashboard#{query_string}"
else
halt 406, error if error
halt 200, 'File(s) successfully uploaded.'
end
end
def require_login_file_upload_ajax
file_upload_response 'You are not signed in!' unless signed_in?
end
post '/site_files/delete' do
require_login
path = HTMLEntities.new.decode params[:filename]

View file

@ -761,6 +761,11 @@ class Site < Sequel::Model
end
def self.valid_file_mime_type_and_ext?(mime_type, extname)
# For files with no extension, only check mime type
if extname == ''
return mime_type =~ /text/ || mime_type == 'application/json' || mime_type =~ /inode\/x-empty/
end
valid_mime_type = Site::VALID_MIME_TYPES.include?(mime_type) || mime_type =~ /text/ || mime_type =~ /inode\/x-empty/
valid_extension = Site::VALID_EXTENSIONS.include?(extname.sub(/^./, '').downcase)
unless valid_extension
@ -773,11 +778,6 @@ class Site < Sequel::Model
mime_type = Magic.guess_file_mime_type uploaded_file[:tempfile].path
extname = File.extname uploaded_file[:filename]
# Possibly needed logic for .dotfiles
#if extname == ''
# extname = uploaded_file[:filename]
#end
return false unless valid_file_mime_type_and_ext?(mime_type, extname)
# clamdscan doesn't work on continuous integration for testing

View file

@ -36,8 +36,54 @@ $('#createDir').on('shown', function () {
$('#createFile').on('shown', function () {
$('#newFileInput').focus();
$('#newFileInput').val('');
clearCreateFileError();
})
function showCreateFileError(message) {
var errorDiv = $('#createFileError');
errorDiv.text(message);
errorDiv.show();
}
function clearCreateFileError() {
var errorDiv = $('#createFileError');
errorDiv.hide();
errorDiv.text('');
}
function fileExists(filePath) {
// Check if a file with this path already exists in the current file listing
// Each file has a hidden div containing the file path
var exists = false;
var lowerFilePath = filePath.toLowerCase();
$('#filesDisplay .file .overlay div[style*="display: none"]').each(function() {
var existingPath = $(this).text().trim();
if (existingPath.toLowerCase() === lowerFilePath) {
exists = true;
return false; // Break out of each loop
}
});
return exists;
}
$('#newFileInput').on('keypress', function(e) {
if (e.which === 13) { // Enter key
handleCreateFile();
}
})
function handleCreateFile() {
var filename = $('#newFileInput').val();
var dir = $('#createFileDir').val();
var csrfToken = $('#createFileCSRFToken').val();
// Don't hide modal yet - wait for success or error
createFileViaAPI(filename, dir, csrfToken);
}
function listView() {
if(localStorage)
localStorage.setItem('viewType', 'list')
@ -153,5 +199,98 @@ function reloadDashboardFiles() {
});
}
function createFileViaAPI(filename, dir, csrfToken) {
clearCreateFileError();
showUploadProgress();
filename = filename.replace(/[^a-zA-Z0-9_\-.]/g, '');
if (!filename || filename.trim() === '') {
hideUploadProgress();
showCreateFileError('You must provide a file name.');
return;
}
var extMatch = filename.match(/\.([^.]+)$/);
// Check if extension is allowed for editing (if there is one)
if (extMatch) {
var extension = extMatch[1].toLowerCase();
var validExtensions = [
'html', 'htm', 'txt', 'js', 'css', 'scss', 'md', 'manifest', 'less',
'webmanifest', 'xml', 'json', 'opml', 'rdf', 'svg', 'gpg', 'pgp',
'resolvehandle', 'pls', 'yaml', 'yml', 'toml', 'osdx', 'mjs', 'cjs',
'ts', 'py', 'rss', 'glsl'
];
if (validExtensions.indexOf(extension) === -1) {
hideUploadProgress();
showCreateFileError('Must be an editable file type (' + validExtensions.join(', ') + ') or a file with no extension.');
return;
}
}
// Files with no extension are allowed (they're treated as text files)
var fullPath = dir ? joinPaths(dir, filename) : filename;
// Check if file already exists
if (fileExists(fullPath)) {
hideUploadProgress();
showCreateFileError('A file with this name already exists. Please choose a different name.');
return;
}
var isHTML = /\.(html|htm)$/i.test(filename);
// Create default HTML template content
var htmlTemplate = '<!DOCTYPE html>\n' +
'<html>\n' +
' <head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <title>My Page</title>\n' +
' <link href="/style.css" rel="stylesheet" type="text/css" media="all">\n' +
' </head>\n' +
' <body>\n' +
' </body>\n' +
'</html>';
var content = isHTML ? htmlTemplate : '';
// Create a blob with the content
var blob = new Blob([content], { type: 'text/plain' });
// Create FormData for the API upload
var formData = new FormData();
formData.append('csrf_token', csrfToken);
formData.append(fullPath, blob, filename);
$.ajax({
url: '/api/upload',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(response) {
hideUploadProgress();
alertClear();
alertType('success');
var escapedName = $('<div>').text(fullPath).html(); // HTML escape
alertAdd(escapedName + ' was created! <a style="color: #FFFFFF; text-decoration: underline" href="/site_files/text_editor/' + encodeURIComponent(fullPath) + '">Click here to edit it</a>.');
reloadDashboardFiles();
$('#createFile').modal('hide'); // Hide modal on success
},
error: function(xhr) {
hideUploadProgress();
try {
var errorData = JSON.parse(xhr.responseText);
showCreateFileError(errorData.message || 'Failed to create file');
} catch(e) {
showCreateFileError('Failed to create file');
}
}
});
}
// for first time load
reInitDashboardFiles();

View file

@ -13,6 +13,10 @@ describe 'dashboard' do
page.set_rack_session id: @site.id
end
after do
Capybara.default_driver = :rack_test
end
it 'records a dashboard access' do
_(@site.reload.dashboard_accessed).must_equal false
visit '/dashboard'
@ -30,13 +34,20 @@ describe 'dashboard' do
end
it 'creates a new file' do
Capybara.default_driver = :selenium_chrome_headless_largewindow
random = SecureRandom.uuid.gsub('-', '')
page.set_rack_session id: @site.id
visit '/dashboard'
_(page).must_have_content('Home')
_(page).must_have_link('New File')
click_link 'New File'
# Wait for modal to appear
_(page).must_have_css('#createFile', visible: true)
fill_in 'filename', with: "#{random}.html"
#click_button 'Create'
all('#createFile button[type=submit]').first.click
_(page).must_have_content /#{random}\.html/
find('#createFile .btn-Action').click
# Wait for the file to appear in the listing
_(page).must_have_content(/#{Regexp.escape(random)}\.html/)
_(File.exist?(@site.files_path("#{random}.html"))).must_equal true
end
end

View file

@ -467,11 +467,21 @@ describe 'api' do
_(res[:error_type]).must_equal 'invalid_file_type'
end
it 'fails for file with no extension' do
it 'succeeds for plain text file with no extension' do
create_site
basic_authorize @user, @pass
post '/api/upload', {
'derpie' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
'LICENSE' => Rack::Test::UploadedFile.new('./tests/files/text-file', 'text/plain')
}
_(res[:result]).must_equal 'success'
_(site_file_exists?('LICENSE')).must_equal true
end
it 'fails for non-text file with no extension' do
create_site
basic_authorize @user, @pass
post '/api/upload', {
'binaryfile' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
}
_(res[:error_type]).must_equal 'invalid_file_type'
end

View file

@ -24,34 +24,6 @@ describe 'site_files' do
ScreenshotWorker.jobs.clear
end
describe 'install' do
it 'installs new html file' do
post '/site_files/create', {filename: 'test.html', csrf_token: 'abcd'}, {'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
_(last_response.body).must_equal ""
_(last_response.status).must_equal 302
_(last_response.headers['Location']).must_match /dashboard/
testfile = @site.site_files_dataset.where(path: 'test.html').first
_(testfile).wont_equal nil
_(File.exists?(@site.files_path('test.html'))).must_equal true
_(PurgeCacheWorker.jobs.length).must_equal 1
_(PurgeCacheWorker.jobs.first['args'].last).must_equal '/test'
end
it 'rejects filenames that exceed the character limit' do
long_filename = 'a' * (SiteFile::FILE_NAME_CHARACTER_LIMIT + 1) + '.html'
post '/site_files/create', {filename: long_filename, csrf_token: 'abcd'}, {'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
_(last_response.status).must_equal 302
_(last_response.headers['Location']).must_match /dashboard/
# Check for error message by following the redirect
get '/dashboard', {}, {'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
_(last_response.body).must_match /file name is too long/i
_(last_response.body).must_match /exceeds #{SiteFile::FILE_NAME_CHARACTER_LIMIT} characters/
end
end
describe 'rename' do
before do
PurgeCacheWorker.jobs.clear

View file

@ -155,22 +155,24 @@
</div>
<div class="modal hide" id="createFile" tabindex="-1" role="dialog" aria-labelledby="createFileLabel" aria-hidden="true">
<form method="post" action="/site_files/create">
<input type="hidden" value="<%= csrf_token %>" name="csrf_token">
<input type="hidden" value="<%= @dir %>" name="dir">
<form id="createFileForm" onsubmit="return false;">
<input type="hidden" value="<%= csrf_token %>" name="csrf_token" id="createFileCSRFToken">
<input type="hidden" value="<%= @dir %>" name="dir" id="createFileDir">
<div class="modal-header">
<button class="close" type="button" data-dismiss="modal" aria-hidden="true"><i class="fa fa-times"></i></button>
<h3 id="createFileLabel">Create New File</h3>
</div>
<div class="modal-body">
<div id="createFileError" class="alert alert-error" style="display: none; margin-bottom: 15px;"></div>
<input id="newFileInput" name="filename" type="text" placeholder="newfile.html">
<p>Note: We will automatically scrub any characters not matching: a-z A-Z 0-9 _ - .</p>
<p>Allowed file types: html, htm, txt, js, css, md, json, xml, py, and others. Must be an editable file type.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn cancel" data-dismiss="modal" aria-hidden="true">Cancel</button>
<button type="submit" class="btn-Action">Create</button>
<button type="button" class="btn-Action" onclick="handleCreateFile()">Create</button>
</div>
</form>
</div>
<script src="/js/dashboard.js"><script>
<script src="/js/dashboard.js"></script>

View file

@ -1,32 +0,0 @@
<div class="header-Outro">
<div class="row content single-Col">
<h1>New Page</h1>
<h3 class="subtitle">Create a new HTML page</h3>
</div>
</div>
<div class="content single-Col misc-page txt-Center">
<article>
<section>
<% if @errors %>
<div class="alert alert-error alert-block">
<% @errors.each do |error| %>
<%= error %>
<% end %>
</div>
<% end %>
</section>
<section>
<form method="POST" action="/site_files/create" enctype="multipart/form-data">
<%== csrf_token_input_html %>
<input name="dir" type="hidden" value="<%= params[:dir] %>">
<h2>What's the name of your page?</h2>
<p><input type="text" name="filename" autocapitalize="off" autocorrect="off">.html</p>
<p><input class="btn-Action" type="submit" value="Create Page"></p>
<p>Note: We will automatically scrub any characters not matching: a-z A-Z 0-9 _ - .</p>
<p>Page must not already exist.</p>
<p>If you want to make this the index page (and an index page doesn't exist), name it <strong>index.html</strong>.</p>
</section>
</article>
</div>