mirror of
https://github.com/neocities/neocities.git
synced 2025-08-22 00:50:56 +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
|
post '/site_files/delete' do
|
||||||
require_login
|
require_login
|
||||||
path = HTMLEntities.new.decode params[:filename]
|
path = HTMLEntities.new.decode params[:filename]
|
||||||
|
|
|
@ -761,6 +761,11 @@ class Site < Sequel::Model
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.valid_file_mime_type_and_ext?(mime_type, extname)
|
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_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)
|
valid_extension = Site::VALID_EXTENSIONS.include?(extname.sub(/^./, '').downcase)
|
||||||
unless valid_extension
|
unless valid_extension
|
||||||
|
@ -773,11 +778,6 @@ class Site < Sequel::Model
|
||||||
mime_type = Magic.guess_file_mime_type uploaded_file[:tempfile].path
|
mime_type = Magic.guess_file_mime_type uploaded_file[:tempfile].path
|
||||||
extname = File.extname uploaded_file[:filename]
|
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)
|
return false unless valid_file_mime_type_and_ext?(mime_type, extname)
|
||||||
|
|
||||||
# clamdscan doesn't work on continuous integration for testing
|
# clamdscan doesn't work on continuous integration for testing
|
||||||
|
|
|
@ -36,8 +36,54 @@ $('#createDir').on('shown', function () {
|
||||||
|
|
||||||
$('#createFile').on('shown', function () {
|
$('#createFile').on('shown', function () {
|
||||||
$('#newFileInput').focus();
|
$('#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() {
|
function listView() {
|
||||||
if(localStorage)
|
if(localStorage)
|
||||||
localStorage.setItem('viewType', 'list')
|
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
|
// for first time load
|
||||||
reInitDashboardFiles();
|
reInitDashboardFiles();
|
||||||
|
|
|
@ -13,6 +13,10 @@ describe 'dashboard' do
|
||||||
page.set_rack_session id: @site.id
|
page.set_rack_session id: @site.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
Capybara.default_driver = :rack_test
|
||||||
|
end
|
||||||
|
|
||||||
it 'records a dashboard access' do
|
it 'records a dashboard access' do
|
||||||
_(@site.reload.dashboard_accessed).must_equal false
|
_(@site.reload.dashboard_accessed).must_equal false
|
||||||
visit '/dashboard'
|
visit '/dashboard'
|
||||||
|
@ -30,13 +34,20 @@ describe 'dashboard' do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a new file' do
|
it 'creates a new file' do
|
||||||
|
Capybara.default_driver = :selenium_chrome_headless_largewindow
|
||||||
random = SecureRandom.uuid.gsub('-', '')
|
random = SecureRandom.uuid.gsub('-', '')
|
||||||
|
|
||||||
|
page.set_rack_session id: @site.id
|
||||||
visit '/dashboard'
|
visit '/dashboard'
|
||||||
|
_(page).must_have_content('Home')
|
||||||
|
_(page).must_have_link('New File')
|
||||||
click_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"
|
fill_in 'filename', with: "#{random}.html"
|
||||||
#click_button 'Create'
|
find('#createFile .btn-Action').click
|
||||||
all('#createFile button[type=submit]').first.click
|
# Wait for the file to appear in the listing
|
||||||
_(page).must_have_content /#{random}\.html/
|
_(page).must_have_content(/#{Regexp.escape(random)}\.html/)
|
||||||
_(File.exist?(@site.files_path("#{random}.html"))).must_equal true
|
_(File.exist?(@site.files_path("#{random}.html"))).must_equal true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -467,11 +467,21 @@ describe 'api' do
|
||||||
_(res[:error_type]).must_equal 'invalid_file_type'
|
_(res[:error_type]).must_equal 'invalid_file_type'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'fails for file with no extension' do
|
it 'succeeds for plain text file with no extension' do
|
||||||
create_site
|
create_site
|
||||||
basic_authorize @user, @pass
|
basic_authorize @user, @pass
|
||||||
post '/api/upload', {
|
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'
|
_(res[:error_type]).must_equal 'invalid_file_type'
|
||||||
end
|
end
|
||||||
|
|
|
@ -24,34 +24,6 @@ describe 'site_files' do
|
||||||
ScreenshotWorker.jobs.clear
|
ScreenshotWorker.jobs.clear
|
||||||
end
|
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
|
describe 'rename' do
|
||||||
before do
|
before do
|
||||||
PurgeCacheWorker.jobs.clear
|
PurgeCacheWorker.jobs.clear
|
||||||
|
|
|
@ -155,22 +155,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal hide" id="createFile" tabindex="-1" role="dialog" aria-labelledby="createFileLabel" aria-hidden="true">
|
<div class="modal hide" id="createFile" tabindex="-1" role="dialog" aria-labelledby="createFileLabel" aria-hidden="true">
|
||||||
<form method="post" action="/site_files/create">
|
<form id="createFileForm" onsubmit="return false;">
|
||||||
<input type="hidden" value="<%= csrf_token %>" name="csrf_token">
|
<input type="hidden" value="<%= csrf_token %>" name="csrf_token" id="createFileCSRFToken">
|
||||||
<input type="hidden" value="<%= @dir %>" name="dir">
|
<input type="hidden" value="<%= @dir %>" name="dir" id="createFileDir">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<button class="close" type="button" data-dismiss="modal" aria-hidden="true"><i class="fa fa-times"></i></button>
|
<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>
|
<h3 id="createFileLabel">Create New File</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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">
|
<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>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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn cancel" data-dismiss="modal" aria-hidden="true">Cancel</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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