massive update to deprecate site_file/upload in favor of api/upload, improve dashboard. todo: webdav switchover, dashboard error/result messages

This commit is contained in:
Kyle Drake 2024-03-06 20:37:44 -06:00
parent 577cd0a82a
commit 943271b509
10 changed files with 467 additions and 373 deletions

View file

@ -42,23 +42,57 @@ get '/api/list' do
end
def extract_files(params, files = [])
# Check if the entire input is directly an array of files
if params.is_a?(Array)
params.each do |item|
# Call extract_files on each item if it's an Array or Hash to handle nested structures
if item.is_a?(Array) || item.is_a?(Hash)
extract_files(item, files)
end
end
elsif params.is_a?(Hash)
params.each do |key, value|
# If the value is a Hash and contains a :tempfile key, it's considered an uploaded file.
if value.is_a?(Hash) && value.has_key?(:tempfile) && !value[:tempfile].nil?
files << {filename: value[:name], tempfile: value[:tempfile]}
elsif value.is_a?(Hash) || value.is_a?(Array)
# If the value is a Hash or Array, recursively search for more files.
elsif value.is_a?(Array)
value.each do |val|
if val.is_a?(Hash) && val.has_key?(:tempfile) && !val[:tempfile].nil?
# Directly add the file info if it's an uploaded file within an array
files << {filename: val[:name], tempfile: val[:tempfile]}
elsif val.is_a?(Hash) || val.is_a?(Array)
# Recursively search for more files if the element is a Hash or Array
extract_files(val, files)
end
end
elsif value.is_a?(Hash)
# Recursively search for more files if the value is a Hash
extract_files(value, files)
end
end
end
files
end
post '/api/upload' do
require_api_credentials
files = extract_files params
if !params[:username].blank?
site = Site[username: params[:username]]
if site.nil? || site.is_deleted
api_error 400, 'site_not_found', "could not find site"
end
if site.owned_by?(current_site)
@_site = site
else
api_error 400, 'site_not_allowed', "not allowed to change this site with your current logged in site"
end
end
api_error 400, 'missing_files', 'you must provide files to upload' if files.empty?
uploaded_size = files.collect {|f| f[:tempfile].size}.inject{|sum,x| sum + x }
@ -73,11 +107,23 @@ post '/api/upload' do
files.each do |file|
if !current_site.okay_to_upload?(file)
api_error 400, 'invalid_file_type', "#{file[:filename]} is not a valid file type (or contains not allowed content) for this site, files have not been uploaded"
api_error 400, 'invalid_file_type', "#{file[:filename]} is not a valid file type (or contains not allowed content) for this site, please upgrade to a supporter account to upload this file type"
end
if File.directory? file[:filename]
api_error 400, 'directory_exists', 'this name is being used by a directory, cannot continue'
api_error 400, 'directory_exists', "#{file[:filename]} being used by a directory"
end
if current_site.file_size_too_large? file[:tempfile].size
api_error 400, 'file_too_large' "#{file[:filename]} is too large"
end
if SiteFile.path_too_long? file[:filename]
api_error 400, 'file_path_too_long', "#{file[:filename]} path is too long"
end
if SiteFile.name_too_long? file[:filename]
api_error 400, 'file_name_too_long', "#{file[:filename]} filename is too long"
end
end
@ -191,7 +237,7 @@ post '/api/:name' do
end
def require_api_credentials
return true if current_site
return true if current_site && csrf_safe?
if !request.env['HTTP_AUTHORIZATION'].nil?
init_api_credentials

View file

@ -75,7 +75,9 @@ post '/site_files/create' do
end
def file_upload_response(error=nil)
flash[:error] = error if error
if error
flash[:error] = error
end
if params[:from_button]
query_string = params[:dir] ? "?"+Rack::Utils.build_query(dir: params[:dir]) : ''
@ -90,77 +92,6 @@ def require_login_file_upload_ajax
file_upload_response 'You are not signed in!' unless signed_in?
end
post '/site_files/upload' do
if params[:filename]
require_login_file_upload_ajax
tempfile = Tempfile.new 'neocities_saving_file'
input = request.body.read
tempfile.set_encoding input.encoding
tempfile.write input
tempfile.close
params[:files] = [{filename: params[:filename], tempfile: tempfile}]
else
require_login
end
@errors = []
if params[:files].nil?
file_upload_response "Uploaded files were not seen by the server, cancelled. We don't know what's causing this yet. Please contact us so we can help fix it. Thanks!"
end
# For migration from original design.. some pages out there won't have the site_id param yet for a while.
site = params[:site_id].nil? ? current_site : Site[params[:site_id]]
unless site.owned_by?(current_site)
file_upload_response 'You do not have permission to save this file. Did you sign in as a different user?'
end
params[:files].each_with_index do |file,i|
dir_name = ''
dir_name = params[:dir] if params[:dir]
unless params[:file_paths].nil? || params[:file_paths].empty? || params[:file_paths].length == 0
file_path = params[:file_paths][i]
unless file_path.nil?
dir_name += '/' + Pathname(file_path).dirname.to_s
end
end
file_base_name = site.scrubbed_path file[:filename].force_encoding('UTF-8')
file[:filename] = "#{dir_name.force_encoding('UTF-8')}/#{file_base_name}"
if current_site.file_size_too_large? file[:tempfile].size
file_upload_response "#{Rack::Utils.escape_html file[:filename]} is too large, upload cancelled."
end
if !site.okay_to_upload? file
file_upload_response %{#{Rack::Utils.escape_html file[:filename]}: file type (or content in file) is only supported by <a href="/supporter">supporter accounts</a>. <a href="/site_files/allowed_types">Why We Do This</a>}
end
if SiteFile.path_too_long? file[:filename]
file_upload_response "#{Rack::Utils.escape_html file[:filename]}: path is too long, upload cancelled."
end
if SiteFile.name_too_long? file_base_name
file_upload_response "#{Rack::Utils.escape_html file[:filename]}: file name is too long, upload cancelled."
end
end
uploaded_size = params[:files].collect {|f| f[:tempfile].size}.inject{|sum,x| sum + x }
if site.file_size_too_large? uploaded_size
file_upload_response "File(s) do not fit in your available free space, upload cancelled."
end
if site.too_many_files? params[:files].length
file_upload_response "Your site has exceeded the maximum number of files, please delete some files first."
end
results = site.store_files params[:files]
file_upload_response
end
post '/site_files/delete' do
require_login
path = HTMLEntities.new.decode params[:filename]

View file

@ -131,3 +131,15 @@ def hcaptcha_valid?
false
end
end
JS_ESCAPE_MAP = {"\\" => "\\\\", "</" => '<\/', "\r\n" => '\n', "\n" => '\n', "\r" => '\n', '"' => '\\"', "'" => "\\'", "`" => "\\`", "$" => "\\$"}
def escape_javascript(javascript)
javascript = javascript.to_s
if javascript.empty?
result = ""
else
result = javascript.gsub(/(\\|<\/|\r\n|\342\200\250|\342\200\251|[\n\r"']|[`]|[$])/u, JS_ESCAPE_MAP)
end
result
end

280
public/js/dashboard.js Normal file
View file

@ -0,0 +1,280 @@
if(localStorage && localStorage.getItem('viewType') == 'list')
$('#filesDisplay').addClass('list-view')
function uploadFileFromButton() {
var form = $('#uploadFilesButtonForm')[0];
var dirValue = $('#dir').val();
var formData = new FormData();
// Append other form data
formData.append('csrf_token', $(form).find('input[name="csrf_token"]').val());
formData.append('from_button', $(form).find('input[name="from_button"]').val());
formData.append('dir', dirValue);
// Append files with modified filenames
$.each($('#uploadFiles')[0].files, function(i, file) {
var modifiedFileName = dirValue + '/' + file.name;
formData.append(modifiedFileName, file);
});
// Submit the form data using jQuery's AJAX
$.ajax({
url: '/api/upload',
type: 'POST',
data: formData,
contentType: false, // This is required for FormData
processData: false, // This is required for FormData
success: function(data) {
console.log('Files successfully uploaded.');
location.reload()
},
error: function(xhr, status, error) {
console.error('Upload failed: ' + error);
location.reload()
}
});
}
$('#uploadFiles').change(function() {
$('#uploadFilesButtonForm').submit();
});
var uploadForm = $('#uploadFilesButtonForm')[0];
var deleteForm = $('#deleteFilenameForm')[0];
function moveFileToFolder(event) {
var link = event.dataTransfer.getData("Text");
if(link) link = link.trim();
if(!link || link.startsWith('https://neocities.org/dashboard')) return;
event.preventDefault();
var name = link.split('.neocities.org/').slice(1).join('.neocities.org/');
var oReq = new XMLHttpRequest();
oReq.open("GET", "/site_files/download/" + name, true);
oReq.responseType = "arraybuffer";
$('#movingOverlay').css('display', 'block')
oReq.onload = function() {
var newFile = new File([oReq.response], name);
var dataTransfer = new DataTransfer();
var currentFolder = new URL(location.href).searchParams.get('dir');
if(!currentFolder) currentFolder = '';
else currentFolder = currentFolder + '/';
dataTransfer.items.add(newFile);
$('#uploadFilesButtonForm > input[name="dir"]')[0].value = currentFolder + event.target.parentElement.parentElement.getElementsByClassName('title')[0].innerText.trim();
$('#uploadFiles')[0].files = dataTransfer.files;
$.ajax({
type: uploadForm.method,
url: uploadForm.action,
data: new FormData(uploadForm),
processData: false,
contentType: false,
success: function() {
let csrf = $('#uploadFilesButtonForm > input[name="csrf_token"]')[0].value;
var dReq = new XMLHttpRequest();
dReq.open(deleteForm.method, deleteForm.action, true);
dReq.onload = function() {
location.reload()
}
dReq.setRequestHeader("content-type", 'application/x-www-form-urlencoded');
dReq.send("csrf_token=" + encodeURIComponent(csrf) + "&filename=" + name.replace(/\s/g, '+'));
},
error: function() {
location.reload()
}
});
};
oReq.send();
}
function confirmFileRename(path) {
console.log(path)
$('#renamePathInput').val(path);
$('#renameNewPathInput').val(path);
$('#renameModal').modal();
}
function confirmFileDelete(name) {
$('#deleteFileName').text(name);
$('#deleteConfirmModal').modal();
}
function fileDelete() {
$('#deleteFilenameInput').val($('#deleteFileName').html());
$('#deleteFilenameForm').submit();
}
function clickUploadFiles() {
$("input[id='uploadFiles']").click()
}
function showUploadProgress() {
$('#uploadingOverlay').css('display', 'block')
}
function hideUploadProgress() {
$('#progressBar').css('display', 'none')
$('#uploadingOverlay').css('display', 'none')
}
/*
this.on("totaluploadprogress", function(progress, totalBytes, totalBytesSent) {
if(progress == 100)
allUploadsComplete = true
showUploadProgress()
$('#progressBar').css('display', 'block')
$('#uploadingProgress').css('width', progress+'%')
})
*/
allUploadsComplete = false
$('#createDir').on('shown', function () {
$('#newDirInput').focus();
})
$('#createFile').on('shown', function () {
$('#newFileInput').focus();
})
function listView() {
if(localStorage)
localStorage.setItem('viewType', 'list')
$('#filesDisplay').addClass('list-view')
}
function iconView() {
if(localStorage)
localStorage.removeItem('viewType')
$('#filesDisplay').removeClass('list-view')
}
// Drop handler function to get all files
async function getAllFileEntries(dataTransferItemList) {
let fileEntries = [];
// Use BFS to traverse entire directory/file structure
let queue = [];
for (let i = 0; i < dataTransferItemList.length; i++) {
queue.push(dataTransferItemList[i].webkitGetAsEntry());
}
while (queue.length > 0) {
let entry = queue.shift();
if (entry.isFile) {
fileEntries.push(entry);
} else if (entry.isDirectory) {
let reader = entry.createReader();
queue.push(...await readAllDirectoryEntries(reader));
}
}
return fileEntries;
}
// Get all the entries (files or sub-directories) in a directory
async function readAllDirectoryEntries(directoryReader) {
let entries = [];
let readEntries = await readEntriesPromise(directoryReader);
while (readEntries.length > 0) {
entries.push(...readEntries);
readEntries = await readEntriesPromise(directoryReader);
}
return entries;
}
// Wrap readEntries in a promise
async function readEntriesPromise(directoryReader) {
try {
return await new Promise((resolve, reject) => {
directoryReader.readEntries(resolve, reject);
});
} catch (err) {
console.log(err);
}
}
async function uploadFile(file, dir, additionalFormData) {
const formData = new FormData();
// Append additional form data (from other input fields) to each file's FormData
for (const [key, value] of Object.entries(additionalFormData)) {
formData.append(key, value);
}
// Append the file to the FormData, using the file name as key
var modifiedFileName = dir + '/' + file.webkitRelativePath || file.name;
formData.append(modifiedFileName, file, modifiedFileName);
$('#uploadFileName').text(modifiedFileName).prepend('<i class="icon-file"></i> ');
// Send the FormData with the file and additional data
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const result = await response.json();
console.log('Upload successful for', file.name, result);
} catch (err) {
console.error('Upload error for', file.name, err);
}
}
async function processEntry(entry, dir, additionalFormData) {
await new Promise((resolve) => {
entry.file((file) => {
uploadFile(file, dir, additionalFormData).then(resolve);
});
});
}
async function uploadFiles(fileEntries) {
// Collect additional form data
const form = document.getElementById('dropzone');
let additionalFormData = {};
for (let i = 0; i < form.elements.length; i++) {
const input = form.elements[i];
if (input.name && input.type !== "file") { // Avoid file inputs
additionalFormData[input.name] = input.value;
}
}
const dir = additionalFormData['dir'] || '';
var totalFiles = fileEntries.length;
$('#progressBar').css('display', 'block')
fileUploadCount = 0
for (let entry of fileEntries) {
await processEntry(entry, dir, additionalFormData);
fileUploadCount++;
var progress = (fileUploadCount / totalFiles) * 100;
$('#uploadingProgress').css('width', progress+'%');
}
allUploadsComplete = true
location.reload();
}
var elDrop = document.getElementById('dropzone');
elDrop.addEventListener('dragover', function (event) {
event.preventDefault();
});
elDrop.addEventListener('drop', async function (event) {
event.preventDefault();
showUploadProgress();
let items = await getAllFileEntries(event.dataTransfer.items);
await uploadFiles(items);
});

View file

@ -1,8 +0,0 @@
/*
HTML5 Shiv v3.7.0 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed
*/
(function(l,f){function m(){var a=e.elements;return"string"==typeof a?a.split(" "):a}function i(a){var b=n[a[o]];b||(b={},h++,a[o]=h,n[h]=b);return b}function p(a,b,c){b||(b=f);if(g)return b.createElement(a);c||(c=i(b));b=c.cache[a]?c.cache[a].cloneNode():r.test(a)?(c.cache[a]=c.createElem(a)).cloneNode():c.createElem(a);return b.canHaveChildren&&!s.test(a)?c.frag.appendChild(b):b}function t(a,b){if(!b.cache)b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag();
a.createElement=function(c){return!e.shivMethods?b.createElem(c):p(c,a,b)};a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+m().join().replace(/[\w\-]+/g,function(a){b.createElem(a);b.frag.createElement(a);return'c("'+a+'")'})+");return n}")(e,b.frag)}function q(a){a||(a=f);var b=i(a);if(e.shivCSS&&!j&&!b.hasCSS){var c,d=a;c=d.createElement("p");d=d.getElementsByTagName("head")[0]||d.documentElement;c.innerHTML="x<style>article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}</style>";
c=d.insertBefore(c.lastChild,d.firstChild);b.hasCSS=!!c}g||t(a,b);return a}var k=l.html5||{},s=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,r=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,j,o="_html5shiv",h=0,n={},g;(function(){try{var a=f.createElement("a");a.innerHTML="<xyz></xyz>";j="hidden"in a;var b;if(!(b=1==a.childNodes.length)){f.createElement("a");var c=f.createDocumentFragment();b="undefined"==typeof c.cloneNode||
"undefined"==typeof c.createDocumentFragment||"undefined"==typeof c.createElement}g=b}catch(d){g=j=!0}})();var e={elements:k.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output progress section summary template time video",version:"3.7.0",shivCSS:!1!==k.shivCSS,supportsUnknownElements:g,shivMethods:!1!==k.shivMethods,type:"default",shivDocument:q,createElement:p,createDocumentFragment:function(a,b){a||(a=f);
if(g)return a.createDocumentFragment();for(var b=b||i(a),c=b.frag.cloneNode(),d=0,e=m(),h=e.length;d<h;d++)c.createElement(e[d]);return c}};l.html5=e;q(f)})(this,document);

View file

@ -308,6 +308,18 @@ describe 'api' do
_(site_file_exists?('test.jpg')).must_equal true
end
it 'fails api_key auth unless controls site' do
create_site
@site.generate_api_key!
@other_site = Fabricate :site
header 'Authorization', "Bearer #{@site.api_key}"
post '/api/upload', 'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg'), 'username' => @other_site.username
_(res[:result]).must_equal 'error'
_(@other_site.site_files.select {|s| s.path == 'test.jpg'}).must_equal []
_(res[:error_type]).must_equal 'site_not_allowed'
end
it 'succeeds with square bracket in filename' do
create_site
@site.generate_api_key!
@ -329,6 +341,34 @@ describe 'api' do
_(site_file_exists?('test.jpg')).must_equal true
end
it 'succeeds with valid user session controlled site' do
create_site
@other_site = Fabricate :site, parent_site_id: @site.id
post '/api/upload',
{'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg'),
'csrf_token' => 'abcd',
'username' => @other_site.username},
{'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
_(res[:result]).must_equal 'success'
_(last_response.status).must_equal 200
_(@other_site.site_files.select {|sf| sf.path == 'test.jpg'}.length).must_equal 1
end
it 'fails session upload unless controls site' do
create_site
@other_site = Fabricate :site
post '/api/upload', {
'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg'),
'username' => @other_site.username,
'csrf_token' => 'abcd'},
{'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
_(res[:result]).must_equal 'error'
_(@other_site.site_files.select {|s| s.path == 'test.jpg'}).must_equal []
_(res[:error_type]).must_equal 'site_not_allowed'
end
it 'fails with bad api key' do
create_site
@site.generate_api_key!

View file

@ -9,7 +9,7 @@ describe 'site_files' do
end
def upload(hash)
post '/site_files/upload', hash.merge(csrf_token: 'abcd'), {'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
post '/api/upload', hash.merge(csrf_token: 'abcd'), {'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
end
def delete_file(hash)
@ -45,7 +45,7 @@ describe 'site_files' do
it 'works with html file' do
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/notindex.html', 'text/html')
upload 'files[]' => uploaded_file
upload 'notindex.html' => uploaded_file
PurgeCacheWorker.jobs.clear
testfile = @site.site_files_dataset.where(path: 'notindex.html').first
testfile.rename 'notindex2.html'
@ -55,7 +55,7 @@ describe 'site_files' do
it 'renames in same path' do
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
upload 'test.jpg' => uploaded_file
testfile = @site.site_files_dataset.where(path: 'test.jpg').first
_(testfile).wont_equal nil
@ -66,9 +66,6 @@ describe 'site_files' do
end
it 'fails when file does not exist' do
#uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
#upload 'files[]' => uploaded_file
post '/site_files/rename', {path: 'derp.jpg', new_path: 'derp2.jpg', csrf_token: 'abcd'}, {'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
_(last_response.headers['Location']).must_match /dashboard/
get '/dashboard', {}, {'rack.session' => { 'id' => @site.id, '_csrf_token' => 'abcd' }}
@ -77,7 +74,7 @@ describe 'site_files' do
it 'fails for bad extension change' do
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
upload 'test.jpg' => uploaded_file
testfile = @site.site_files_dataset.where(path: 'test.jpg').first
res = testfile.rename('dasharezone.exe')
@ -89,7 +86,7 @@ describe 'site_files' do
no_file_restriction_plans = Site::PLAN_FEATURES.select {|p,v| v[:no_file_restrictions] == true}
no_file_restriction_plans.each do |plan_type,hash|
@site = Fabricate :site, plan_type: plan_type
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/flowercrime.wav', 'audio/x-wav')
upload 'flowercrime.wav' => Rack::Test::UploadedFile.new('./tests/files/flowercrime.wav', 'audio/x-wav')
testfile = @site.site_files_dataset.where(path: 'flowercrime.wav').first
res = testfile.rename('flowercrime.exe')
_(res.first).must_equal true
@ -126,18 +123,13 @@ describe 'site_files' do
end
it 'changes path of files and dirs within directory when changed' do
upload(
'dir' => 'test',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload(
'dir' => 'test',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'image/jpeg')
)
upload 'test/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'test/index.html' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'image/jpeg')
PurgeCacheWorker.jobs.clear
@site.site_files.select {|s| s.path == 'test'}.first.rename('test2')
_(@site.site_files.select {|sf| sf.path =~ /test2\/index.html/}.length).must_equal 1
_(@site.site_files.select {|sf| sf.path =~ /test2\/test.jpg/}.length).must_equal 1
_(@site.site_files.select {|sf| sf.path =~ /test\/test.jpg/}.length).must_equal 0
@ -146,14 +138,8 @@ describe 'site_files' do
end
it 'doesnt wipe out existing file' do
upload(
'dir' => 'test',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload(
'dir' => 'test',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'image/jpeg')
)
upload 'test/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'test/index.html' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'image/jpeg')
res = @site.site_files_dataset.where(path: 'test/index.html').first.rename('test/test.jpg')
_(res).must_equal [false, 'file already exists']
@ -172,15 +158,14 @@ describe 'site_files' do
end
it 'works with unicode characters' do
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
upload 'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
@site.site_files_dataset.where(path: 'test.jpg').first.rename("HELL💩؋.jpg")
_(@site.site_files_dataset.where(path: "HELL💩؋.jpg").first).wont_equal nil
end
it 'scrubs weird carriage return shit characters' do
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
upload 'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
_(proc {
@site.site_files_dataset.where(path: 'test.jpg').first.rename("\r\n\t.jpg")
}).must_raise ArgumentError
@ -196,7 +181,7 @@ describe 'site_files' do
it 'works' do
initial_space_used = @site.space_used
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
upload 'test.jpg' => uploaded_file
PurgeCacheWorker.jobs.clear
@ -217,30 +202,21 @@ describe 'site_files' do
end
it 'property deletes directories with regexp special chars in them' do
upload 'dir' => '8)', 'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload '8)/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
delete_file filename: '8)'
_(@site.reload.site_files.select {|f| f.path =~ /#{Regexp.quote '8)'}/}.length).must_equal 0
end
it 'deletes with escaped apostrophe' do
upload(
'dir' => "test'ing",
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload "test'ing/test.jpg" => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
_(@site.reload.site_files.select {|s| s.path == "test'ing"}.length).must_equal 1
delete_file filename: "test'ing"
_(@site.reload.site_files.select {|s| s.path == "test'ing"}.length).must_equal 0
end
it 'deletes a directory and all files in it' do
upload(
'dir' => 'test',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload(
'dir' => '',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload 'test/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
space_used = @site.reload.space_used
delete_file filename: 'test'
@ -253,10 +229,7 @@ describe 'site_files' do
end
it 'deletes records for nested directories' do
upload(
'dir' => 'derp/ing/tons',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload 'derp/ing/tons/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
expected_site_file_paths = ['derp', 'derp/ing', 'derp/ing/tons', 'derp/ing/tons/test.jpg']
@ -274,16 +247,11 @@ describe 'site_files' do
end
it 'goes back to deleting directory' do
upload(
'dir' => 'test',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload 'test/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
delete_file filename: 'test/test.jpg'
_(last_response.headers['Location']).must_equal "http://example.org/dashboard?dir=test"
upload(
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload 'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
delete_file filename: 'test.jpg'
_(last_response.headers['Location']).must_equal "http://example.org/dashboard"
end
@ -291,17 +259,17 @@ describe 'site_files' do
describe 'upload' do
it 'works with empty files' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/empty.js', 'text/javascript')
upload 'empty.js' => Rack::Test::UploadedFile.new('./tests/files/empty.js', 'text/javascript')
_(File.exists?(@site.files_path('empty.js'))).must_equal true
end
it 'manages files with invalid UTF8' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/invalidutf8.html', 'text/html')
upload 'invalidutf8.html' => Rack::Test::UploadedFile.new('./tests/files/invalidutf8.html', 'text/html')
_(File.exists?(@site.files_path('invalidutf8.html'))).must_equal true
end
it 'works with manifest files' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/cache.manifest', 'text/cache-manifest')
upload 'cache.manifest' => Rack::Test::UploadedFile.new('./tests/files/cache.manifest', 'text/cache-manifest')
_(File.exists?(@site.files_path('cache.manifest'))).must_equal true
end
@ -312,7 +280,7 @@ describe 'site_files' do
file.write("derp")
end
upload 'files[]' => Rack::Test::UploadedFile.new(file_path, 'text/html')
upload file_path => Rack::Test::UploadedFile.new(file_path, 'text/html')
_(last_response.body).must_match /name is too long/i
ensure
FileUtils.rm file_path
@ -320,20 +288,17 @@ describe 'site_files' do
end
it 'fails with path greater than limit' do
upload(
'dir' => (("a" * 50 + "/") * (SiteFile::FILE_PATH_CHARACTER_LIMIT / 50 - 1) + "a" * 50),
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload "#{(("a" * 50 + "/") * (SiteFile::FILE_PATH_CHARACTER_LIMIT / 50 - 1) + "a" * 50)}/test.jpg" => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
_(last_response.body).must_match /path is too long/i
end
it 'works with otf fonts' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/chunkfive.otf', 'application/vnd.ms-opentype')
upload 'chunkfive.otf' => Rack::Test::UploadedFile.new('./tests/files/chunkfive.otf', 'application/vnd.ms-opentype')
_(File.exists?(@site.files_path('chunkfive.otf'))).must_equal true
end
it 'purges cache for html file with extension removed' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/notindex.html', 'text/html')
upload 'notindex.html' => Rack::Test::UploadedFile.new('./tests/files/notindex.html', 'text/html')
_(PurgeCacheWorker.jobs.length).must_equal 1
PurgeCacheWorker.new.perform @site.username, '/notindex.html'
_(PurgeCacheWorker.jobs.first['args'].last).must_equal '/notindex'
@ -341,7 +306,7 @@ describe 'site_files' do
it 'succeeds with index.html file' do
_(@site.site_changed).must_equal false
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
upload 'index.html' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
_(last_response.body).must_match /successfully uploaded/i
_(File.exists?(@site.files_path('index.html'))).must_equal true
@ -367,9 +332,9 @@ describe 'site_files' do
it 'provides the correct space used after overwriting an existing file' do
initial_space_used = @site.space_used
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
upload 'test.jpg' => uploaded_file
second_uploaded_file = Rack::Test::UploadedFile.new('./tests/files/img/test.jpg', 'image/jpeg')
upload 'files[]' => second_uploaded_file
upload 'test.jpg' => second_uploaded_file
_(@site.reload.space_used).must_equal initial_space_used + second_uploaded_file.size
_(@site.space_used).must_equal @site.actual_space_used
end
@ -377,27 +342,22 @@ describe 'site_files' do
it 'does not change title for subdir index.html' do
title = @site.title
upload(
'dir' => 'derpie',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
'derpie/index.html' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
)
_(@site.reload.title).must_equal title
end
it 'purges cache for /subdir/' do # (not /subdir which is just a redirect to /subdir/)
upload(
'dir' => 'subdir',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
'subdir/index.html' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
)
_(PurgeCacheWorker.jobs.select {|j| j['args'].last == '/subdir/'}.length).must_equal 1
end
it 'succeeds with multiple files' do
upload(
'file_paths' => ['one/test.jpg', 'two/test.jpg'],
'files' => [
Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg'),
Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
]
'one/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg'),
'two/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
_(@site.site_files.select {|s| s.path == 'one'}.length).must_equal 1
@ -409,7 +369,7 @@ describe 'site_files' do
it 'succeeds with valid file' do
initial_space_used = @site.space_used
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
upload 'test.jpg' => uploaded_file
_(last_response.body).must_match /successfully uploaded/i
_(File.exists?(@site.files_path('test.jpg'))).must_equal true
@ -434,22 +394,23 @@ describe 'site_files' do
it 'works with square bracket filename' do
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/te[s]t.jpg', 'image/jpeg')
upload 'files[]' => uploaded_file
upload 'te[s]t.jpg' => uploaded_file
_(last_response.body).must_match /successfully uploaded/i
_(File.exists?(@site.files_path('te[s]t.jpg'))).must_equal true
end
it 'sets site changed to false if index is empty' do
uploaded_file = Rack::Test::UploadedFile.new('./tests/files/blankindex/index.html', 'text/html')
upload 'files[]' => uploaded_file
upload 'index.html' => uploaded_file
_(last_response.body).must_match /successfully uploaded/i
_(@site.empty_index?).must_equal true
_(@site.site_changed).must_equal false
end
it 'fails with unsupported file' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/flowercrime.wav', 'audio/x-wav')
_(last_response.body).must_match /only supported by.+supporter account/i
upload 'flowercrime.wav' => Rack::Test::UploadedFile.new('./tests/files/flowercrime.wav', 'audio/x-wav')
_(JSON.parse(last_response.body)['message']).must_match /please upgrade to a supporter account/i
_(File.exists?(@site.files_path('flowercrime.wav'))).must_equal false
_(@site.site_changed).must_equal false
end
@ -458,17 +419,17 @@ describe 'site_files' do
no_file_restriction_plans = Site::PLAN_FEATURES.select {|p,v| v[:no_file_restrictions] == true}
no_file_restriction_plans.each do |plan_type,hash|
@site = Fabricate :site, plan_type: plan_type
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/flowercrime.wav', 'audio/x-wav')
upload 'flowercrime.wav' => Rack::Test::UploadedFile.new('./tests/files/flowercrime.wav', 'audio/x-wav')
_(last_response.body).must_match /successfully uploaded/i
_(File.exists?(@site.files_path('flowercrime.wav'))).must_equal true
end
end
it 'overwrites existing file with new file' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
upload 'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
_(last_response.body).must_match /successfully uploaded/i
digest = @site.reload.site_files.first.sha1_hash
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/img/test.jpg', 'image/jpeg')
upload 'test.jpg' => Rack::Test::UploadedFile.new('./tests/files/img/test.jpg', 'image/jpeg')
_(last_response.body).must_match /successfully uploaded/i
_(@site.reload.changed_count).must_equal 2
_(@site.site_files.select {|f| f.path == 'test.jpg'}.length).must_equal 1
@ -476,10 +437,7 @@ describe 'site_files' do
end
it 'works with directory path' do
upload(
'dir' => 'derpie/derptest',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload 'derpie/derptest/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
_(last_response.body).must_match /successfully uploaded/i
_(File.exists?(@site.files_path('derpie/derptest/test.jpg'))).must_equal true
@ -504,41 +462,29 @@ describe 'site_files' do
end
it 'works with unicode chars on filename and dir' do
upload(
'dir' => '詩經',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/詩經.jpg', 'image/jpeg')
)
upload '詩經/詩經.jpg' => Rack::Test::UploadedFile.new('./tests/files/詩經.jpg', 'image/jpeg')
_(@site.site_files_dataset.where(path: '詩經/詩經.jpg').count).must_equal 1
end
it 'does not register site changing until root index.html is changed' do
upload(
'dir' => 'derpie/derptest',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload 'derpie/derptest/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
_(@site.reload.site_changed).must_equal false
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
upload 'index.html' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
_(@site.reload.site_changed).must_equal true
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/chunkfive.otf', 'application/vnd.ms-opentype')
upload 'chunkfive.otf' => Rack::Test::UploadedFile.new('./tests/files/chunkfive.otf', 'application/vnd.ms-opentype')
_(@site.reload.site_changed).must_equal true
end
it 'does not store new file if hash matches' do
upload(
'dir' => 'derpie/derptest',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload 'derpie/derptest/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
_(@site.reload.changed_count).must_equal 1
upload(
'dir' => 'derpie/derptest',
'files[]' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
)
upload 'derpie/derptest/test.jpg' => Rack::Test::UploadedFile.new('./tests/files/test.jpg', 'image/jpeg')
_(@site.reload.changed_count).must_equal 1
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
upload 'index.html' => Rack::Test::UploadedFile.new('./tests/files/index.html', 'text/html')
_(@site.reload.changed_count).must_equal 2
end
@ -554,21 +500,6 @@ describe 'site_files' do
puts "TODO FINISH CLASSIFIER"
#$trainer.instance_variable_get('@db').redis.flushall
end
=begin
it 'trains files' do
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/classifier/ham.html', 'text/html')
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/classifier/spam.html', 'text/html')
upload 'files[]' => Rack::Test::UploadedFile.new('./tests/files/classifier/phishing.html', 'text/html')
@site.train 'ham.html'
@site.train 'spam.html', 'spam'
@site.train 'phishing.html', 'phishing'
_(@site.classify('ham.html')).must_equal 'ham'
_(@site.classify('spam.html')).must_equal 'spam'
_(@site.classify('phishing.html')).must_equal 'phishing'
end
=end
end
end
end

View file

@ -61,13 +61,10 @@
<%== flash_display %>
<div id="filesDisplay" class="files">
<script>
if(localStorage && localStorage.getItem('viewType') == 'list')
$('#filesDisplay').addClass('list-view')
</script>
<div id="uploadingOverlay" class="uploading-overlay" style="display: none">
<div class="uploading">
<p>Uploading, please wait...</p>
<p id="uploadFileName"></p>
<div id="progressBar" class="progress-bar" style="display: none"><div id="uploadingProgress" class="progress" style="width: 0%"></div></div>
</div>
</div>
@ -106,7 +103,7 @@
</div>
</div>
<div class="list">
<form action="/site_files/upload" class="dropzone" id="uploads">
<form action="/site_files/upload" id="dropzone">
<div class="dz-message" style="display: none"></div>
<input name="csrf_token" type="hidden" value="<%= csrf_token %>">
<input name="dir" type="hidden" value="<%= @dir %>">
@ -227,14 +224,13 @@
</div>
</main>
<form id="uploadFilesButtonForm" method="POST" action="/site_files/upload" enctype="multipart/form-data" style="display: none" onsubmit="showUploadProgress()">
<form id="uploadFilesButtonForm" method="POST" action="/api/upload" enctype="multipart/form-data" style="display: none" onsubmit="event.preventDefault(); showUploadProgress(); uploadFileFromButton();">
<input name="csrf_token" type="hidden" value="<%= csrf_token %>">
<input name="from_button" type="hidden" value="true">
<input name="dir" type="hidden" value="<%= @dir %>">
<input id="uploadFiles" type="file" name="files[]" multiple onchange="$('#uploadFilesButtonForm').submit()">
<input name="dir" type="hidden" id="dir" value="<%= @dir %>">
<input id="uploadFiles" type="file" name="file" multiple>
</form>
<div class="modal hide fade" id="createDir" tabindex="-1" role="dialog" aria-labelledby="createDirLabel" aria-hidden="true">
<form method="post" action="/site/create_directory">
<input type="hidden" value="<%= csrf_token %>" name="csrf_token">
@ -272,148 +268,4 @@
</form>
</div>
<script src="/js/dropzone.min.js"></script>
<script>
var uploadForm = $('#uploadFilesButtonForm')[0];
var deleteForm = $('#deleteFilenameForm')[0];
function moveFileToFolder(event) {
var link = event.dataTransfer.getData("Text");
if(link) link = link.trim();
if(!link || link.startsWith('https://neocities.org/dashboard')) return;
event.preventDefault();
var name = link.split('.neocities.org/').slice(1).join('.neocities.org/');
var oReq = new XMLHttpRequest();
oReq.open("GET", "/site_files/download/" + name, true);
oReq.responseType = "arraybuffer";
$('#movingOverlay').css('display', 'block')
oReq.onload = function() {
var newFile = new File([oReq.response], name);
var dataTransfer = new DataTransfer();
var currentFolder = new URL(location.href).searchParams.get('dir');
if(!currentFolder) currentFolder = '';
else currentFolder = currentFolder + '/';
dataTransfer.items.add(newFile);
$('#uploadFilesButtonForm > input[name="dir"]')[0].value = currentFolder + event.target.parentElement.parentElement.getElementsByClassName('title')[0].innerText.trim();
$('#uploadFiles')[0].files = dataTransfer.files;
$.ajax({
type: uploadForm.method,
url: uploadForm.action,
data: new FormData(uploadForm),
processData: false,
contentType: false,
success: function() {
let csrf = $('#uploadFilesButtonForm > input[name="csrf_token"]')[0].value;
var dReq = new XMLHttpRequest();
dReq.open(deleteForm.method, deleteForm.action, true);
dReq.onload = function() {
location.reload()
}
dReq.setRequestHeader("content-type", 'application/x-www-form-urlencoded');
dReq.send("csrf_token=" + encodeURIComponent(csrf) + "&filename=" + name.replace(/\s/g, '+'));
},
error: function() {
location.reload()
}
});
};
oReq.send();
}
function confirmFileRename(path) {
console.log(path)
$('#renamePathInput').val(path);
$('#renameNewPathInput').val(path);
$('#renameModal').modal();
}
function confirmFileDelete(name) {
$('#deleteFileName').text(name);
$('#deleteConfirmModal').modal();
}
function fileDelete() {
$('#deleteFilenameInput').val($('#deleteFileName').html());
$('#deleteFilenameForm').submit();
}
function clickUploadFiles() {
$("input[id='uploadFiles']").click()
}
function showUploadProgress() {
$('#uploadingOverlay').css('display', 'block')
}
function hideUploadProgress() {
$('#progressBar').css('display', 'none')
$('#uploadingOverlay').css('display', 'none')
}
allUploadsComplete = false
Dropzone.options.uploads = {
paramName: 'files',
maxFilesize: <%= current_site.remaining_space.to_mb %>,
clickable: false,
addRemoveLinks: false,
dictDefaultMessage: '',
uploadMultiple: true,
init: function() {
this.on("completemultiple", function(file) {
if(allUploadsComplete == true)
location.reload()
})
this.on("error", function(file, errorMessage) {
hideUploadProgress()
// Guess a directory upload error
if(file.status == 'error' && file.name.match(/.+\..+/) == null && errorMessage == 'Server responded with 0 code.') {
alert('Recursive directory upload is only supported by the Chrome web browser.')
} else {
location.href = '/dashboard<%= @dir ? "?dir=#{Rack::Utils.escape @dir}" : "" %>'
}
})
this.on("totaluploadprogress", function(progress, totalBytes, totalBytesSent) {
if(progress == 100)
allUploadsComplete = true
showUploadProgress()
$('#progressBar').css('display', 'block')
$('#uploadingProgress').css('width', progress+'%')
})
this.on("sending", function(file) {
if(file.fullPath !== undefined)
$('#uploads').append('<input type="hidden" name="file_paths[]" value="'+file.fullPath+'">')
})
}
}
$('#createDir').on('shown', function () {
$('#newDirInput').focus();
})
$('#createFile').on('shown', function () {
$('#newFileInput').focus();
})
function listView() {
if(localStorage)
localStorage.setItem('viewType', 'list')
$('#filesDisplay').addClass('list-view')
}
function iconView() {
if(localStorage)
localStorage.removeItem('viewType')
$('#filesDisplay').removeClass('list-view')
}
</script>
<script src="/js/dashboard.js"><script>

View file

@ -17,10 +17,6 @@
<meta http-equiv="Expires" content="0">
<% end %>
<!--[if lt IE 9]>
<script type="text/javascript" src="/js/html5.min.js"></script>
<![endif]-->
<script src="/js/jquery-1.11.0.min.js"></script>
<script src="/js/highlight.pack.js"></script>

View file

@ -132,17 +132,31 @@
function saveTextFile(quit) {
if(unsavedChanges == false)
return
var formData = new FormData();
var fileContent = new Blob([editor.getValue()], { type: 'text/html' });
formData.append('<%= escape_javascript @filename %>', fileContent, '<%= escape_javascript @filename %>');
formData.append('csrf_token', '<%= escape_javascript csrf_token %>');
formData.append('username', '<%= escape_javascript current_site.username %>');
$.ajax({
url: "/site_files/upload?csrf_token=<%= Rack::Utils.escape csrf_token %>&filename=<%= Rack::Utils.escape @filename %>&site_id=<%= current_site.id %>",
data: editor.getValue(),
url: '/api/upload',
data: formData,
processData: false,
contentType: false,
type: 'POST',
error: function(jqXHR, textStatus, errorThrown) {
var errorMessage = 'There has been an error saving your file, please try again. If it continues to fail, make a copy of the file locally so you don\'t lose your changes!'
if(jqXHR.responseText)
errorMessage += ' ERROR MESSAGE: '+jqXHR.responseText
if(jqXHR.responseText) {
try {
// Attempt to parse the JSON responseText to get the error message
var parsedResponse = JSON.parse(jqXHR.responseText);
errorMessage += ' ERROR MESSAGE: ' + parsedResponse.message;
} catch (error) {
}
}
$('#saveButton').tooltip('show')
$('#editorUpdates span').text(errorMessage)