From 3f34b3ae62fcc599550c79ca3c1d4bdc103d1cfe Mon Sep 17 00:00:00 2001 From: Kyle Drake Date: Sun, 10 Aug 2025 11:46:50 -0500 Subject: [PATCH] use api for new file create via dashboard, deprecate old method --- app/site_files.rb | 101 -------------------- models/site.rb | 10 +- public/js/dashboard.js | 139 ++++++++++++++++++++++++++++ tests/acceptance/dashboard_tests.rb | 17 +++- tests/api_tests.rb | 14 ++- tests/site_file_tests.rb | 28 ------ views/dashboard/index.erb | 12 ++- views/site_files/new_page.erb | 32 ------- 8 files changed, 177 insertions(+), 176 deletions(-) delete mode 100644 views/site_files/new_page.erb diff --git a/app/site_files.rb b/app/site_files.rb index 02d97edf..42ae30e8 100644 --- a/app/site_files.rb +++ b/app/site_files.rb @@ -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! Click here to edit it.} - - 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] diff --git a/models/site.rb b/models/site.rb index 1931d817..0fc2f8d9 100644 --- a/models/site.rb +++ b/models/site.rb @@ -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 diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 33eb40d2..6de63671 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -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 = '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' My Page\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ''; + + 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 = $('
').text(fullPath).html(); // HTML escape + alertAdd(escapedName + ' was created! Click here to edit it.'); + 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(); diff --git a/tests/acceptance/dashboard_tests.rb b/tests/acceptance/dashboard_tests.rb index c3afe4dd..b89947ce 100644 --- a/tests/acceptance/dashboard_tests.rb +++ b/tests/acceptance/dashboard_tests.rb @@ -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 diff --git a/tests/api_tests.rb b/tests/api_tests.rb index a4e3174d..45ad37b5 100644 --- a/tests/api_tests.rb +++ b/tests/api_tests.rb @@ -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 diff --git a/tests/site_file_tests.rb b/tests/site_file_tests.rb index d9cb23a7..958e691e 100644 --- a/tests/site_file_tests.rb +++ b/tests/site_file_tests.rb @@ -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 diff --git a/views/dashboard/index.erb b/views/dashboard/index.erb index 7772cf30..71b7590e 100644 --- a/views/dashboard/index.erb +++ b/views/dashboard/index.erb @@ -155,22 +155,24 @@
- \ No newline at end of file diff --git a/views/site_files/new_page.erb b/views/site_files/new_page.erb deleted file mode 100644 index 31839b31..00000000 --- a/views/site_files/new_page.erb +++ /dev/null @@ -1,32 +0,0 @@ -
-
-

New Page

-

Create a new HTML page

-
-
- -
-
-
- <% if @errors %> -
- <% @errors.each do |error| %> - <%= error %> - <% end %> -
- <% end %> -
-
-
- <%== csrf_token_input_html %> - -

What's the name of your page?

-

.html

-

- -

Note: We will automatically scrub any characters not matching: a-z A-Z 0-9 _ - .

-

Page must not already exist.

-

If you want to make this the index page (and an index page doesn't exist), name it index.html.

-
-
-