mirror of
https://github.com/neocities/neocities.git
synced 2025-08-22 09:00:52 +02:00
use api for new file create via dashboard, deprecate old method
This commit is contained in:
parent
c7dd74bb0d
commit
3f34b3ae62
8 changed files with 177 additions and 176 deletions
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
Loading…
Add table
Add a link
Reference in a new issue