From ab60744ac5eb6eecac3cf8943052791bee16c5ee Mon Sep 17 00:00:00 2001 From: Kyle Drake Date: Wed, 3 Dec 2014 08:29:01 -0800 Subject: [PATCH] refactor app.rb blob to partition routes into files --- app.rb | 1368 +---------------------------------------- app/admin.rb | 66 ++ app/api.rb | 165 +++++ app/blog.rb | 12 + app/comment.rb | 20 + app/contact.rb | 30 + app/event.rb | 47 ++ app/password_reset.rb | 70 +++ app/plan.rb | 62 ++ app/settings.rb | 270 ++++++++ app/signin.rb | 46 ++ app/site.rb | 135 ++++ app/site_files.rb | 151 +++++ app/stats.rb | 62 ++ app/sysops.rb | 84 +++ app/tags.rb | 26 + app_helpers.rb | 87 +++ views/new.erb | 180 ------ 18 files changed, 1347 insertions(+), 1534 deletions(-) create mode 100644 app/admin.rb create mode 100644 app/api.rb create mode 100644 app/blog.rb create mode 100644 app/comment.rb create mode 100644 app/contact.rb create mode 100644 app/event.rb create mode 100644 app/password_reset.rb create mode 100644 app/plan.rb create mode 100644 app/settings.rb create mode 100644 app/signin.rb create mode 100644 app/site.rb create mode 100644 app/site_files.rb create mode 100644 app/stats.rb create mode 100644 app/sysops.rb create mode 100644 app/tags.rb create mode 100644 app_helpers.rb delete mode 100644 views/new.erb diff --git a/app.rb b/app.rb index bbed199e..5b76a5a8 100644 --- a/app.rb +++ b/app.rb @@ -1,9 +1,6 @@ require 'base64' -require 'uri' -require 'net/http' -require 'zlib' -require 'rubygems/package' require './environment.rb' +require './app_helpers.rb' use Rack::Session::Cookie, key: 'neocities', path: '/', @@ -81,149 +78,6 @@ get '/tips_mockup' do end # :nocov: -get '/site/:username.rss' do |username| - site = Site[username: username] - content_type :xml - site.to_rss.to_xml -end - -get '/site/:username/?' do |username| - site = Site[username: username] - not_found if site.nil? || site.is_banned - - @title = site.title - - @current_page = params[:current_page] - @current_page = @current_page.to_i - @current_page = 1 if @current_page == 0 - - if params[:event_id] - event = Event.select(:id).where(id: params[:event_id]).first - not_found if event.nil? - events_dataset = Event.where(id: params[:event_id]).paginate(1, 1) - else - events_dataset = site.latest_events(@current_page, 10) - end - - @page_count = events_dataset.page_count || 1 - @latest_events = events_dataset.all - - erb :'site', locals: {site: site, is_current_site: site == current_site} -end - -post '/site/:username/set_editor_theme' do - require_login - current_site.update editor_theme: params[:editor_theme] - 'ok' -end - -post '/settings/create_child' do - require_login - - if !current_site.plan_feature(:unlimited_site_creation) - flash[:error] = 'Cannot create a new site with your current plan, please become a supporter.' - redirect '/settings#sites' - end - - site = Site.new - - site.parent_site_id = parent_site.id - site.username = params[:username] - - if site.valid? - site.save - flash[:success] = 'Your new site has been created! To manage it, click your username in the top right and go to "Switch Site".' - redirect '/settings#sites' - else - flash[:error] = site.errors.first.last.first - redirect '/settings#sites' - end -end - -post '/site/:username/comment' do |username| - require_login - - site = Site[username: username] - - if(site.profile_comments_enabled == false || - params[:message].empty? || - site.is_blocking?(current_site) || - current_site.is_blocking?(site) || - current_site.commenting_allowed? == false) - redirect "/site/#{username}" - end - - site.add_profile_comment( - actioning_site_id: current_site.id, - message: params[:message] - ) - - redirect "/site/#{username}" -end - -get '/stats/?' do - require_admin - - @stats = { - total_sites: Site.count, - total_unbanned_sites: Site.where(is_banned: false).count, - total_banned_sites: Site.where(is_banned: true).count, - total_nsfw_sites: Site.where(is_nsfw: true).count, - total_unbanned_nsfw_sites: Site.where(is_banned: false, is_nsfw: true).count, - total_banned_nsfw_sites: Site.where(is_banned: true, is_nsfw: true).count - } - - # Start with the date of the first created site - - start = Site.select(:created_at). - exclude(created_at: nil). - order(:created_at). - first[:created_at].to_date - - runner = start - - monthly_stats = [] - - now = Time.now - - until runner.year == now.year && runner.month == now.month+1 - monthly_stats.push( - date: runner, - sites_created: Site.where(created_at: runner..runner.next_month).count, - total_from_start: Site.where(created_at: start..runner.next_month).count, - supporters: Site.where(created_at: start..runner.next_month).exclude(stripe_customer_id: nil).count, - ) - - runner = runner.next_month - end - - @stats[:monthly_stats] = monthly_stats - - customers = Stripe::Customer.all - - @stats[:total_recurring_revenue] = 0.0 - - subscriptions = [] - cancelled_subscriptions = 0 - - customers.each do |customer| - sub = {created_at: Time.at(customer.created)} - if customer[:subscriptions] - if customer[:subscriptions][:data].empty? - sub[:status] = 'cancelled' - else - sub[:status] = 'active' - sub[:amount] = (customer[:subscriptions][:data].first[:plan][:amount] / 100.0).round(2) - @stats[:total_recurring_revenue] += sub[:amount] - end - end - subscriptions.push sub - end - - @stats[:subscriptions] = subscriptions - erb :'stats' -end - get '/?' do if current_site require_login @@ -263,121 +117,6 @@ get '/?' do erb :index, layout: false end -def generate_question - if ENV['RACK_ENV'] == 'test' - question_first_number = 1 - question_last_number = 1 - else - question_first_number = rand 5 - question_last_number = rand 5 - end - session[:question_answer] = (question_first_number + question_last_number).to_s - [question_first_number, question_last_number] -end - -get '/plan/?' do - @title = 'Support Us' - - if parent_site && parent_site.unconverted_legacy_supporter? - customer = Stripe::Customer.retrieve(parent_site.stripe_customer_id) - subscription = customer.subscriptions.first - parent_site.stripe_subscription_id = subscription.id - parent_site.plan_type = subscription.plan.id - parent_site.save_changes - end - - erb :'plan/index' -end - -post '/plan/update' do - require_login - - DB.transaction do - if parent_site.stripe_subscription_id - customer = Stripe::Customer.retrieve parent_site.stripe_customer_id - subscription = customer.subscriptions.retrieve parent_site.stripe_subscription_id - subscription.plan = params[:plan_type] - subscription.save - - parent_site.update( - plan_ended: false, - plan_type: params[:plan_type] - ) - else - customer = Stripe::Customer.create( - card: params[:stripe_token], - description: "#{parent_site.username} - #{parent_site.id}", - email: (current_site.email || parent_site.email), - plan: params[:plan_type] - ) - - parent_site.update( - stripe_customer_id: customer.id, - stripe_subscription_id: customer.subscriptions.first.id, - plan_ended: false, - plan_type: params[:plan_type] - ) - end - end - - if current_site.email || parent_site.email - EmailWorker.perform_async({ - from: 'web@neocities.org', - reply_to: 'contact@neocities.org', - to: current_site.email || parent_site.email, - subject: "[Neocities] You've become a supporter!", - body: Tilt.new('./views/templates/email_subscription.erb', pretty: true).render(self, plan_name: Site::PLAN_FEATURES[params[:plan_type].to_sym][:name], plan_space: Site::PLAN_FEATURES[params[:plan_type].to_sym][:space].to_space_pretty) - }) - end - - redirect params[:plan_type] == 'free' ? '/plan' : '/plan/thanks' -end - -get '/plan/thanks' do - require_login - erb :'plan/thanks' -end - -get '/site/:username/tip' do |username| - @site = Site[username: username] - @title = "Tip #{@site.title}" - erb :'tip' -end - -post '/site/:site_id/toggle_follow' do |site_id| - require_login - content_type :json - site = Site[id: site_id] - {result: (current_site.toggle_follow(site) ? 'followed' : 'unfollowed')}.to_json -end - -post '/tags/add' do - require_login - current_site.new_tags_string = params[:tags] - - if current_site.valid? - current_site.save_tags - else - flash[:errors] = current_site.errors.first - end - - redirect request.referer -end - -post '/tags/remove' do - require_login - - DB.transaction { - params[:tags].each {|tag| current_site.remove_tag Tag[name: tag]} - } - - redirect request.referer -end - -get '/tags/autocomplete/:name.json' do |name| - Tag.autocomplete(name).collect {|t| t[:name]}.to_json -end - def browse_sites_dataset @current_page = params[:current_page] @current_page = @current_page.to_i @@ -458,11 +197,6 @@ get '/surf/:username' do |username| erb :'surf', layout: false end -get '/api' do - @title = 'Developers API' - erb :'api' -end - get '/tutorials' do erb :'tutorials' end @@ -471,24 +205,6 @@ get '/donate' do erb :'donate' end -get '/blog' do - expires 500, :public, :must_revalidate - return Net::HTTP.get_response(URI('http://blog.neocities.org')).body -end - -get '/blog/:article' do |article| - expires 500, :public, :must_revalidate - return Net::HTTP.get_response(URI("http://blog.neocities.org/#{article}.html")).body -end - -get '/new' do - dashboard_if_signed_in - require_unbanned_ip - @site = Site.new - @site.username = params[:username] unless params[:username].nil? - erb :'new' -end - post '/create_validate_all' do content_type :json fields = params.select {|p| p.match /^username$|^password$|^email$|^new_tags_string$/} @@ -579,541 +295,23 @@ get '/dashboard' do erb :'dashboard' end -get '/settings/?' do - require_login - @site = parent_site - erb :'settings/account' -end - -def require_ownership_for_settings - @site = Site[username: params[:username]] - - not_found if @site.nil? - - unless @site.owned_by? parent_site - flash[:error] = 'Cannot edit this site, you do not have permission.' - redirect request.referrer - end -end - -get '/settings/:username/?' do - require_login - require_ownership_for_settings - erb :'settings/site' -end - -post '/settings/:username/profile' do - require_login - require_ownership_for_settings - - @site.update( - profile_comments_enabled: params[:site][:profile_comments_enabled] - ) - flash[:success] = 'Profile settings changed.' - redirect "/settings/#{@site.username}#profile" -end - -post '/settings/:username/ssl' do - require_login - require_ownership_for_settings - - unless params[:key] && params[:cert] - flash[:error] = 'SSL key and certificate are required.' - redirect "/settings/#{@site.username}#custom_domain" - end - - begin - key = OpenSSL::PKey::RSA.new params[:key][:tempfile].read, '' - rescue => e - flash[:error] = 'Could not process SSL key, file may be incorrect, damaged, or passworded (you need to remove the password).' - redirect "/settings/#{@site.username}#custom_domain" - end - - if !key.private? - flash[:error] = 'SSL Key file does not have private key data.' - redirect "/settings/#{@site.username}#custom_domain" - end - - certs_string = params[:cert][:tempfile].read - - cert_array = certs_string.lines.slice_before(/-----BEGIN CERTIFICATE-----/).to_a.collect {|a| a.join} - - if cert_array.empty? - flash[:error] = 'Cert file does not contain any certificates.' - redirect "/settings/#{@site.username}#custom_domain" - end - - cert_valid_for_domain = false - - cert_array.each do |cert_string| - begin - cert = OpenSSL::X509::Certificate.new cert_string - rescue => e - flash[:error] = 'Could not process SSL certificate, file may be incorrect or damaged.' - redirect "/settings/#{@site.username}#custom_domain" - end - - if cert.not_after < Time.now - flash[:error] = 'SSL Certificate has expired, please create a new one.' - redirect "/settings/#{@site.username}#custom_domain" - end - - cert_cn = cert.subject.to_a.select {|a| a.first == 'CN'}.flatten[1] - cert_valid_for_domain = true if cert_cn && cert_cn.match(@site.domain) - end - - unless cert_valid_for_domain - flash[:error] = "Your certificate CN (common name) does not match your domain: #{@site.domain}" - redirect "/settings/#{@site.username}#custom_domain" - end - - # Everything else was worse. - - crtfile = Tempfile.new 'crtfile' - crtfile.write cert_array.join - crtfile.close - - keyfile = Tempfile.new 'keyfile' - keyfile.write key.to_pem - keyfile.close - - if ENV['TRAVIS'] != 'true' - nginx_testfile = Tempfile.new 'nginx_testfile' - nginx_testfile.write %{ - pid /tmp/throwaway.pid; - events {} - error_log /dev/null error; - http { - access_log off; - server { - listen 60000 ssl; - server_name #{@site.domain} *.#{@site.domain}; - ssl_certificate #{crtfile.path}; - ssl_certificate_key #{keyfile.path}; - } - } - } - nginx_testfile.close - - line = Cocaine::CommandLine.new( - "nginx", "-t -c :path", - expected_outcodes: [0], - swallow_stderr: true - ) - - begin - output = line.run path: nginx_testfile.path - rescue Cocaine::ExitStatusError => e - flash[:error] = "There is something wrong with your certificate, please check with your issuing CA." - redirect "/settings/#{@site.username}#custom_domain" - end - end - - @site.update ssl_key: key.to_pem, ssl_cert: cert_array.join - - flash[:success] = 'Updated SSL key/certificate.' - redirect "/settings/#{@site.username}#custom_domain" -end - -post '/settings/:username/change_name' do - require_login - require_ownership_for_settings - - old_username = @site.username - - if params[:name] == nil || params[:name] == '' - flash[:error] = 'Name cannot be blank.' - redirect "/settings/#{@site.username}#username" - end - - if old_username == params[:name] - flash[:error] = 'You already have this name.' - redirect "/settings/#{@site.username}#username" - end - - old_host = @site.host - old_file_paths = @site.file_list.collect {|f| f[:path]} - - @site.username = params[:name] - - if @site.valid? - DB.transaction { - @site.save_changes - @site.move_files_from old_username - } - - old_file_paths.each do |file_path| - @site.purge_cache file_path - end - - flash[:success] = "Site/user name has been changed. You will need to use this name to login, don't forget it." - redirect "/settings/#{@site.username}#username" - else - flash[:error] = @site.errors.first.last.first - redirect "/settings/#{old_username}#username" - end -end - -post '/settings/:username/change_nsfw' do - require_login - require_ownership_for_settings - - @site.update is_nsfw: params[:is_nsfw] - flash[:success] = @site.is_nsfw ? 'Marked 18+' : 'Unmarked 18+' - redirect "/settings/#{@site.username}#nsfw" -end - -post '/settings/:username/custom_domain' do - require_login - require_ownership_for_settings - - @site.domain = params[:domain] - - if @site.valid? - @site.save_changes - flash[:success] = 'The domain has been successfully updated.' - redirect "/settings/#{@site.username}#custom_domain" - else - flash[:error] = @site.errors.first.last.first - redirect "/settings/#{@site.username}#custom_domain" - end -end - -post '/settings/change_password' do - require_login - - if !Site.valid_login?(parent_site.username, params[:current_password]) - flash[:error] = 'Your provided password does not match the current one.' - redirect "/settings#password" - end - - parent_site.password = params[:new_password] - parent_site.valid? - - if params[:new_password] != params[:new_password_confirm] - parent_site.errors.add :password, 'New passwords do not match.' - end - - if parent_site.errors.empty? - parent_site.save_changes - flash[:success] = 'Successfully changed password.' - redirect "/settings#password" - else - flash[:error] = current_site.errors.first.last.first - redirect '/settings#password' - end -end - -post '/settings/change_email' do - require_login - - if params[:email] == parent_site.email - flash[:error] = 'You are already using this email address for this account.' - redirect '/settings#email' - end - - parent_site.email = params[:email] - parent_site.email_confirmation_token = SecureRandom.hex 3 - parent_site.email_confirmed = false - - if parent_site.valid? - parent_site.save_changes - send_confirmation_email - flash[:success] = 'Successfully changed email. We have sent a confirmation email, please use it to confirm your email address.' - redirect '/settings#email' - end - - flash[:error] = parent_site.errors.first.last.first - redirect '/settings#email' -end - -get '/password_reset' do - erb :'password_reset' -end - -post '/send_password_reset' do - sites = Site.filter(email: params[:email]).all - - if sites.length > 0 - token = SecureRandom.uuid.gsub('-', '') - sites.each do |site| - site.update password_reset_token: token - end - - body = <<-EOT -Hello! This is the Neocities cat, and I have received a password reset request for your e-mail address. Purrrr. - -Go to this URL to reset your password: http://neocities.org/password_reset_confirm?token=#{token} - -After clicking on this link, your password for all the sites registered to this email address will be changed to this token. - -Token: #{token} - -If you didn't request this reset, you can ignore it. Or hide under a bed. Or take a nap. Your call. - -Meow, -the Neocities Cat - EOT - - body.strip! - - EmailWorker.perform_async({ - from: 'web@neocities.org', - to: params[:email], - subject: '[Neocities] Password Reset', - body: body - }) - end - - flash[:success] = 'If your email was valid (and used by a site), the Neocities Cat will send an e-mail to your account with password reset instructions.' - redirect '/' -end - -get '/password_reset_confirm' do - if params[:token].nil? || params[:token].empty? - flash[:error] = 'Could not find a site with this token.' - redirect '/' - end - - reset_site = Site[password_reset_token: params[:token]] - - if reset_site.nil? - flash[:error] = 'Could not find a site with this token.' - redirect '/' - end - - sites = Site.filter(email: reset_site.email).all - - if sites.length > 0 - sites.each do |site| - site.password = reset_site.password_reset_token - site.save_changes - end - - flash[:success] = 'Your password for all sites with your email address has been changed to the token sent in your e-mail. Please login and change your password as soon as possible.' - else - flash[:error] = 'Could not find a site with this token.' - end - - redirect '/' -end - -get '/signin/?' do - dashboard_if_signed_in - erb :'signin' -end - -post '/signin' do - dashboard_if_signed_in - - if Site.valid_login? params[:username], params[:password] - site = Site.get_with_identifier params[:username] - - if site.is_banned - flash[:error] = 'Invalid login.' - flash[:username] = params[:username] - redirect '/signin' - end - - session[:id] = site.id - redirect '/' - else - flash[:error] = 'Invalid login.' - flash[:username] = params[:username] - redirect '/signin' - end -end - -get '/signout' do - require_login - session[:id] = nil - redirect '/' -end - -get '/signin/:username' do - require_login - @site = Site[username: params[:username]] - - not_found if @site.nil? - - if @site.owned_by? current_site - session[:id] = @site.id - redirect request.referrer - end - - flash[:error] = 'You do not have permission to switch to this site.' - redirect request.referrer -end - get '/about' do erb :'about' end -get '/site_files/new_page' do - require_login - erb :'site_files/new_page' -end - -post '/site_files/create_page' do - require_login - @errors = [] - - params[:pagefilename].gsub!(/[^a-zA-Z0-9_\-.]/, '') - params[:pagefilename].gsub!(/\.html$/i, '') - - if params[:pagefilename].nil? || params[:pagefilename].empty? - @errors << 'You must provide a file name.' - halt erb(:'site_files/new_page') - end - - name = "#{params[:pagefilename]}.html" - - name = "#{params[:dir]}/#{name}" if params[:dir] - - if current_site.file_exists?(name) - @errors << %{Web page "#{name}" already exists! Choose another name.} - halt erb(:'site_files/new_page') - end - - current_site.install_new_html_file name - - flash[:success] = %{#{name} was created! Click here to edit it.} - - redirect params[:dir] ? "/dashboard?dir=#{Rack::Utils.escape params[:dir]}" : '/dashboard' -end - -get '/site_files/new' do - require_login - erb :'site_files/new' -end - -def file_upload_response(error=nil) - http_error_code = 406 - - if params[:from_button] - if error - @error = error - halt 200, erb(:'dashboard') - else - query_string = params[:dir] ? "?"+Rack::Utils.build_query(dir: params[:dir]) : '' - redirect "/dashboard#{query_string}" - end - else - halt http_error_code, error if error - halt 200, 'File(s) successfully uploaded.' - end -end - -post '/site/create_directory' do - require_login - - path = "#{params[:dir] || ''}/#{params[:name]}" - - result = current_site.create_directory path - - if result != true - flash[:error] = e.message - end - - redirect "/dashboard?dir=#{Rack::Utils.escape params[:dir]}" -end - -post '/site_files/upload' do - require_login - @errors = [] - http_error_code = 406 - - 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 - - params[:files].each do |file| - file[:filename] = "#{params[:dir]}/#{file[:filename]}" if params[:dir] - if current_site.file_size_too_large? file[:tempfile].size - file_upload_response "#{params[:dir]}/#{file[:filename]} is too large, upload cancelled." - end - if !current_site.okay_to_upload? file - file_upload_response "#{params[:dir]}/#{file[:filename]}: file type (or content in file) is not allowed on this site, upload cancelled. You can upgrade your account to remove the file type restrictions." - end - end - - uploaded_size = params[:files].collect {|f| f[:tempfile].size}.inject{|sum,x| sum + x } - - if current_site.file_size_too_large? uploaded_size - file_upload_response "File(s) do not fit in your available space, upload cancelled." - end - - results = [] - params[:files].each do |file| - results << current_site.store_file(file[:filename], file[:tempfile]) - end - current_site.increment_changed_count if results.include?(true) - - file_upload_response -end - -post '/site_files/delete' do - require_login - current_site.delete_file params[:filename] - - flash[:success] = "Deleted #{params[:filename]}." - redirect '/dashboard' -end - -get '/site_files/:username.zip' do |username| - require_login - zipfile_path = current_site.files_zip - content_type 'application/octet-stream' - attachment "neocities-#{current_site.username}.zip" - send_file zipfile_path -end - -get '/site_files/download/:filename' do |filename| - require_login - content_type 'application/octet-stream' - attachment filename - current_site.get_file filename -end - -get %r{\/site_files\/text_editor\/(.+)} do - require_login - @filename = params[:captures].first - begin - @file_data = current_site.get_file @filename - rescue Errno::ENOENT - flash[:error] = 'We could not find the requested file.' - redirect '/dashboard' - end - erb :'site_files/text_editor' -end - -post %r{\/site_files\/save\/(.+)} do - require_login_ajax - filename = params[:captures].first - - tempfile = Tempfile.new 'neocities_saving_file' - - input = request.body.read - tempfile.set_encoding input.encoding - tempfile.write input - tempfile.close - - if current_site.file_size_too_large? tempfile.size - halt 'File is too large to fit in your space, it has NOT been saved. You will need to reduce the size or upgrade to a new plan.' - end - - current_site.store_file filename, tempfile - - 'ok' -end - -get '/site_files/allowed_types' do - erb :'site_files/allowed_types' -end - -get '/site_files/mount_info' do - erb :'site_files/mount_info' -end +require './app/api.rb' +require './app/site.rb' +require './app/site_files.rb' +require './app/admin.rb' +require './app/settings.rb' +require './app/blog.rb' +require './app/signin.rb' +require './app/tags.rb' +require './app/plan.rb' +require './app/password_reset.rb' +require './app/contact.rb' +require './app/event.rb' +require './app/comment.rb' get '/terms' do erb :'terms' @@ -1127,101 +325,6 @@ get '/press' do erb :'press' end -get '/admin' do - require_admin - @banned_sites = Site.select(:username).filter(is_banned: true).order(:username).all - @nsfw_sites = Site.select(:username).filter(is_nsfw: true).order(:username).all - erb :'admin' -end - -post '/admin/banip' do - require_admin - site = Site[username: params[:username]] - - if site.nil? - flash[:error] = 'User not found' - redirect '/admin' - end - - if site.ip.nil? || site.ip.empty? - flash[:error] = 'IP is blank, cannot continue' - redirect '/admin' - end - sites = Site.filter(ip: Site.hash_ip(site.ip), is_banned: false).all - sites.each {|s| s.ban!} - flash[:error] = "#{sites.length} sites have been banned." - redirect '/admin' -end - -post '/admin/banhammer' do - require_admin - - site = Site[username: params[:username]] - - if site.nil? - flash[:error] = 'User not found' - redirect '/admin' - end - - if site.is_banned - flash[:error] = 'User is already banned' - redirect '/admin' - end - - site.ban! - - flash[:success] = 'MISSION ACCOMPLISHED' - redirect '/admin' -end - -post '/admin/mark_nsfw' do - require_admin - site = Site[username: params[:username]] - - if site.nil? - flash[:error] = 'User not found' - redirect '/admin' - end - - site.is_nsfw = true - site.save_changes validate: false - - flash[:success] = 'MISSION ACCOMPLISHED' - redirect '/admin' -end - -get '/contact' do - erb :'contact' -end - -post '/contact' do - - @errors = [] - - if params[:email].empty? || params[:subject].empty? || params[:body].empty? - @errors << 'Please fill out all fields' - end - - if !recaptcha_valid? - @errors << 'Captcha was not filled out (or was filled out incorrectly)' - end - - if !@errors.empty? - erb :'contact' - else - EmailWorker.perform_async({ - from: 'web@neocities.org', - reply_to: params[:email], - to: 'contact@neocities.org', - subject: "[Neocities Contact]: #{params[:subject]}", - body: params[:body] - }) - - flash[:success] = 'Your contact has been sent.' - redirect '/' - end -end - post '/stripe_webhook' do event = JSON.parse request.body.read if event['type'] == 'customer.created' @@ -1229,447 +332,4 @@ post '/stripe_webhook' do email = event['data']['object']['email'] end 'ok' -end - -post '/api/upload' do - require_api_credentials - files = [] - params.each do |k,v| - next unless v.is_a?(Hash) && v[:tempfile] - path = k.to_s - files << {filename: k || v[:filename], tempfile: v[:tempfile]} - 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 } - - if current_site.file_size_too_large? uploaded_size - api_error 400, 'too_large', 'files are too large to fit in your space, try uploading smaller (or less) files' - end - - 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" - end - - if File.directory? file[:filename] - api_error 400, 'directory_exists', 'this name is being used by a directory, cannot continue' - end - end - - results = [] - files.each do |file| - results << current_site.store_file(file[:filename], file[:tempfile]) - end - - current_site.increment_changed_count if results.include?(true) - - api_success 'your file(s) have been successfully uploaded' -end - -post '/api/delete' do - require_api_credentials - - api_error 400, 'missing_filenames', 'you must provide files to delete' if params[:filenames].nil? || params[:filenames].empty? - - paths = [] - params[:filenames].each do |path| - unless path.is_a?(String) - api_error 400, 'bad_filename', "#{path} is not a valid filename, canceled deleting" - end - - if !current_site.file_exists?(path) - api_error 400, 'missing_files', "#{path} was not found on your site, canceled deleting" - end - - if path == 'index.html' - api_error 400, 'cannot_delete_index', 'you cannot delete your index.html file, canceled deleting' - end - - paths << path - end - - paths.each do |path| - current_site.delete_file(path) - end - - api_success 'file(s) have been deleted' -end - -get '/api/info' do - if params[:sitename] - site = Site[username: params[:sitename]] - - api_error 400, 'site_not_found', "could not find site #{params[:sitename]}" if site.nil? || site.is_banned - api_success api_info_for(site) - else - init_api_credentials - api_success api_info_for(current_site) - end -end - -def api_info_for(site) - { - info: { - sitename: site.username, - views: site.views, - hits: site.hits, - created_at: site.created_at.rfc2822, - last_updated: site.site_updated_at ? site.site_updated_at.rfc2822 : nil, - domain: site.domain, - tags: site.tags.collect {|t| t.name} - } - } -end - -# Catch-all for missing api calls - -get '/api/:name' do - api_not_found -end - -post '/api/:name' do - api_not_found -end - -post '/event/:event_id/toggle_like' do |event_id| - require_login - content_type :json - event = Event[id: event_id] - liked_response = event.toggle_site_like(current_site) ? 'liked' : 'unliked' - {result: liked_response, event_like_count: event.likes_dataset.count, liking_site_names: event.liking_site_usernames}.to_json -end - -post '/event/:event_id/comment' do |event_id| - require_login - content_type :json - event = Event[id: event_id] - - site = event.site - - if site.is_blocking?(current_site) || - site.profile_comments_enabled == false || - current_site.commenting_allowed? == false - return {result: 'error'}.to_json - end - - event.add_site_comment current_site, params[:message] - {result: 'success'}.to_json -end - -post '/event/:event_id/update_profile_comment' do |event_id| - require_login - content_type :json - event = Event[id: event_id] - return {result: 'error'}.to_json unless current_site.id == event.profile_comment.actioning_site_id - - event.profile_comment.update message: params[:message] - return {result: 'success'}.to_json -end - -post '/event/:event_id/delete' do |event_id| - require_login - content_type :json - event = Event[id: event_id] - - if event.site_id == current_site.id || event.created_by?(current_site) - event.delete - return {result: 'success'}.to_json - end - - return {result: 'error'}.to_json -end - -post '/comment/:comment_id/toggle_like' do |comment_id| - require_login - content_type :json - comment = Comment[id: comment_id] - liked_response = comment.toggle_site_like(current_site) ? 'liked' : 'unliked' - {result: liked_response, comment_like_count: comment.comment_likes_dataset.count, liking_site_names: comment.liking_site_usernames}.to_json -end - -post '/comment/:comment_id/delete' do |comment_id| - require_login - content_type :json - comment = Comment[id: comment_id] - - if comment.event.site == current_site || comment.actioning_site == current_site - comment.delete - return {result: 'success'}.to_json - end - - return {result: 'error'}.to_json -end - -get '/site/:username/confirm_email/:token' do - site = Site[username: params[:username]] - if site.email_confirmation_token == params[:token] - site.email_confirmed = true - site.save_changes - - erb :'site_email_confirmed' - else - erb :'site_email_not_confirmed' - end -end - -post '/site/:username/report' do |username| - site = Site[username: username] - - redirect request.referer if site.nil? - - report = Report.new site_id: site.id, type: params[:type], comments: params[:comments] - - if current_site - redirect request.referer if current_site.id == site.id - report.reporting_site_id = current_site.id - else - report.ip = Site.hash_ip request.ip - end - - report.save - - EmailWorker.perform_async({ - from: 'web@neocities.org', - to: 'report@neocities.org', - subject: "[Neocities Report] #{site.username} has been reported for #{report.type}", - body: "Reported by #{report.reporting_site_id ? report.reporting_site.username : report.ip}: #{report.comments}" - }) - - redirect request.referer -end - -post '/site/:username/block' do |username| - require_login - site = Site[username: username] - redirect request.referer if current_site.id == site.id - - current_site.block! site - - if request.referer.match /\/site\/#{username}/i - redirect '/' - else - redirect request.referer - end -end - -get '/sysops/proxy/map.txt' do - require_proxy_auth - domains = '' - Site.exclude(domain: nil). - exclude(domain: ''). - select(:username,:domain). - all. - collect do |s| - domains << "#{s.domain} #{s.username};\n" - end - content_type :text - domains -end - -get '/sysops/proxy/sslcerts.tar.gz' do - require_proxy_auth - sites = Site.ssl_sites - - nginx_config = '' - - tar = StringIO.new - - Gem::Package::TarWriter.new(tar) do |writer| - writer.mkdir 'sslcerts', 0740 - writer.mkdir 'sslcerts/certs', 0740 - - sites.each do |site| - writer.add_file "sslcerts/certs/#{site.username}.key", 0640 do |f| - f.write site.ssl_key - end - - writer.add_file "sslcerts/certs/#{site.username}.crt", 0640 do |f| - f.write site.ssl_cert - end - - nginx_config << %{ - server { - listen 443 ssl; - server_name #{site.domain} *.#{site.domain}; - ssl_certificate sslsites/certs/#{site.username}.crt; - ssl_certificate_key sslsites/certs/#{site.username}.key; - - location / { - proxy_http_version 1.1; - proxy_set_header Host #{site.username}.neocities.org; - proxy_pass http://127.0.0.1$request_uri; - } - } - }.unindent - end - - writer.add_file "sslcerts/sslsites.conf", 0640 do |f| - f.write nginx_config - end - end - - tar.rewind - - package = StringIO.new 'b' - package.set_encoding 'binary' - gzip = Zlib::GzipWriter.new package - gzip.write tar.read - tar.close - gzip.finish - package.rewind - - attachment - package.read -end - -class ProxyAccessViolation < StandardError; end - -def require_proxy_auth - begin - auth = request.env['HTTP_AUTHORIZATION'] - user, pass = Base64.decode64(auth.match(/Basic (.+)/)[1]).split(':') - raise ProxyAccessViolation unless pass == $config['proxy_pass'] - rescue - raise ProxyAccessViolation, "Violator: #{request.ip}" unless pass == $config['proxy_pass'] - end -end - -def require_admin - redirect '/' unless signed_in? && current_site.is_admin -end - -def dashboard_if_signed_in - redirect '/dashboard' if signed_in? -end - -def require_login_ajax - halt 'You are not logged in!' unless signed_in? - halt 'You are banned.' if current_site.is_banned? || parent_site.is_banned? -end - -def csrf_safe? - csrf_token == params[:csrf_token] || csrf_token == request.env['HTTP_X_CSRF_TOKEN'] -end - -def csrf_token - session[:_csrf_token] ||= SecureRandom.base64(32) -end - -def require_login - redirect '/' unless signed_in? - if session[:banned] || current_site.is_banned || parent_site.is_banned - session[:id] = nil - session[:banned] = true - redirect '/' - end -end - -def signed_in? - !session[:id].nil? -end - -def current_site - return nil if session[:id].nil? - @_site ||= Site[id: session[:id]] -end - -def parent_site - return nil if current_site.nil? - current_site.parent? ? current_site : current_site.parent -end - -def require_unbanned_ip - if session[:banned] || Site.banned_ip?(request.ip) - session[:id] = nil - session[:banned] = true - flash[:error] = 'Site creation has been banned due to ToS violation/spam. '+ - 'If you believe this to be in error, contact the site admin.' - return {result: 'error'}.to_json - end -end - -def title - out = "Neocities" - return out if request.path == '/' - return "#{out} - #{@title}" if @title - "#{out} - #{request.path.gsub('/', '').capitalize}" -end - -def encoding_fix(file) - begin - Rack::Utils.escape_html file - rescue ArgumentError => e - return Rack::Utils.escape_html(file.force_encoding('BINARY')) if e.message =~ /invalid byte sequence in UTF-8/ - fail - end -end - -def require_api_credentials - if !request.env['HTTP_AUTHORIZATION'].nil? - init_api_credentials - else - api_error_invalid_auth - end -end - -def init_api_credentials - auth = request.env['HTTP_AUTHORIZATION'] - - begin - user, pass = Base64.decode64(auth.match(/Basic (.+)/)[1]).split(':') - rescue - api_error_invalid_auth - end - - if Site.valid_login? user, pass - site = Site[username: user] - - if site.nil? || site.is_banned - api_error_invalid_auth - end - - session[:id] = site.id - else - api_error_invalid_auth - end -end - -def api_success(message_or_obj) - output = {result: 'success'} - - if message_or_obj.is_a?(String) - output[:message] = message_or_obj - else - output.merge! message_or_obj - end - - api_response(200, output) -end - -def api_response(status, output) - halt status, JSON.pretty_generate(output)+"\n" -end - -def api_error(status, error_type, message) - api_response(status, result: 'error', error_type: error_type, message: message) -end - -def api_error_invalid_auth - api_error 403, 'invalid_auth', 'invalid credentials - please check your username and password' -end - -def api_not_found - api_error 404, 'not_found', 'the requested api call does not exist' -end - -def send_confirmation_email(site=current_site) - EmailWorker.perform_async({ - from: 'web@neocities.org', - reply_to: 'contact@neocities.org', - to: site.email, - subject: "[Neocities] Confirm your email address", - body: Tilt.new('./views/templates/email_confirm.erb', pretty: true).render(self, site: site) - }) end \ No newline at end of file diff --git a/app/admin.rb b/app/admin.rb new file mode 100644 index 00000000..6ef723c1 --- /dev/null +++ b/app/admin.rb @@ -0,0 +1,66 @@ +get '/admin' do + require_admin + @banned_sites = Site.select(:username).filter(is_banned: true).order(:username).all + @nsfw_sites = Site.select(:username).filter(is_nsfw: true).order(:username).all + erb :'admin' +end + +post '/admin/banip' do + require_admin + site = Site[username: params[:username]] + + if site.nil? + flash[:error] = 'User not found' + redirect '/admin' + end + + if site.ip.nil? || site.ip.empty? + flash[:error] = 'IP is blank, cannot continue' + redirect '/admin' + end + sites = Site.filter(ip: Site.hash_ip(site.ip), is_banned: false).all + sites.each {|s| s.ban!} + flash[:error] = "#{sites.length} sites have been banned." + redirect '/admin' +end + +post '/admin/banhammer' do + require_admin + + site = Site[username: params[:username]] + + if site.nil? + flash[:error] = 'User not found' + redirect '/admin' + end + + if site.is_banned + flash[:error] = 'User is already banned' + redirect '/admin' + end + + site.ban! + + flash[:success] = 'MISSION ACCOMPLISHED' + redirect '/admin' +end + +post '/admin/mark_nsfw' do + require_admin + site = Site[username: params[:username]] + + if site.nil? + flash[:error] = 'User not found' + redirect '/admin' + end + + site.is_nsfw = true + site.save_changes validate: false + + flash[:success] = 'MISSION ACCOMPLISHED' + redirect '/admin' +end + +def require_admin + redirect '/' unless signed_in? && current_site.is_admin +end \ No newline at end of file diff --git a/app/api.rb b/app/api.rb new file mode 100644 index 00000000..bfeca746 --- /dev/null +++ b/app/api.rb @@ -0,0 +1,165 @@ + +get '/api' do + @title = 'Developers API' + erb :'api' +end + +post '/api/upload' do + require_api_credentials + files = [] + params.each do |k,v| + next unless v.is_a?(Hash) && v[:tempfile] + path = k.to_s + files << {filename: k || v[:filename], tempfile: v[:tempfile]} + 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 } + + if current_site.file_size_too_large? uploaded_size + api_error 400, 'too_large', 'files are too large to fit in your space, try uploading smaller (or less) files' + end + + 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" + end + + if File.directory? file[:filename] + api_error 400, 'directory_exists', 'this name is being used by a directory, cannot continue' + end + end + + results = [] + files.each do |file| + results << current_site.store_file(file[:filename], file[:tempfile]) + end + + current_site.increment_changed_count if results.include?(true) + + api_success 'your file(s) have been successfully uploaded' +end + +post '/api/delete' do + require_api_credentials + + api_error 400, 'missing_filenames', 'you must provide files to delete' if params[:filenames].nil? || params[:filenames].empty? + + paths = [] + params[:filenames].each do |path| + unless path.is_a?(String) + api_error 400, 'bad_filename', "#{path} is not a valid filename, canceled deleting" + end + + if !current_site.file_exists?(path) + api_error 400, 'missing_files', "#{path} was not found on your site, canceled deleting" + end + + if path == 'index.html' + api_error 400, 'cannot_delete_index', 'you cannot delete your index.html file, canceled deleting' + end + + paths << path + end + + paths.each do |path| + current_site.delete_file(path) + end + + api_success 'file(s) have been deleted' +end + +get '/api/info' do + if params[:sitename] + site = Site[username: params[:sitename]] + + api_error 400, 'site_not_found', "could not find site #{params[:sitename]}" if site.nil? || site.is_banned + api_success api_info_for(site) + else + init_api_credentials + api_success api_info_for(current_site) + end +end + +def api_info_for(site) + { + info: { + sitename: site.username, + views: site.views, + hits: site.hits, + created_at: site.created_at.rfc2822, + last_updated: site.site_updated_at ? site.site_updated_at.rfc2822 : nil, + domain: site.domain, + tags: site.tags.collect {|t| t.name} + } + } +end + +# Catch-all for missing api calls + +get '/api/:name' do + api_not_found +end + +post '/api/:name' do + api_not_found +end + +def require_api_credentials + if !request.env['HTTP_AUTHORIZATION'].nil? + init_api_credentials + else + api_error_invalid_auth + end +end + +def init_api_credentials + auth = request.env['HTTP_AUTHORIZATION'] + + begin + user, pass = Base64.decode64(auth.match(/Basic (.+)/)[1]).split(':') + rescue + api_error_invalid_auth + end + + if Site.valid_login? user, pass + site = Site[username: user] + + if site.nil? || site.is_banned + api_error_invalid_auth + end + + session[:id] = site.id + else + api_error_invalid_auth + end +end + +def api_success(message_or_obj) + output = {result: 'success'} + + if message_or_obj.is_a?(String) + output[:message] = message_or_obj + else + output.merge! message_or_obj + end + + api_response(200, output) +end + +def api_response(status, output) + halt status, JSON.pretty_generate(output)+"\n" +end + +def api_error(status, error_type, message) + api_response(status, result: 'error', error_type: error_type, message: message) +end + +def api_error_invalid_auth + api_error 403, 'invalid_auth', 'invalid credentials - please check your username and password' +end + +def api_not_found + api_error 404, 'not_found', 'the requested api call does not exist' +end \ No newline at end of file diff --git a/app/blog.rb b/app/blog.rb new file mode 100644 index 00000000..606622a3 --- /dev/null +++ b/app/blog.rb @@ -0,0 +1,12 @@ +require 'net/http' +require 'uri' + +get '/blog' do + expires 500, :public, :must_revalidate + return Net::HTTP.get_response(URI('http://blog.neocities.org')).body +end + +get '/blog/:article' do |article| + expires 500, :public, :must_revalidate + return Net::HTTP.get_response(URI("http://blog.neocities.org/#{article}.html")).body +end \ No newline at end of file diff --git a/app/comment.rb b/app/comment.rb new file mode 100644 index 00000000..898982da --- /dev/null +++ b/app/comment.rb @@ -0,0 +1,20 @@ +post '/comment/:comment_id/toggle_like' do |comment_id| + require_login + content_type :json + comment = Comment[id: comment_id] + liked_response = comment.toggle_site_like(current_site) ? 'liked' : 'unliked' + {result: liked_response, comment_like_count: comment.comment_likes_dataset.count, liking_site_names: comment.liking_site_usernames}.to_json +end + +post '/comment/:comment_id/delete' do |comment_id| + require_login + content_type :json + comment = Comment[id: comment_id] + + if comment.event.site == current_site || comment.actioning_site == current_site + comment.delete + return {result: 'success'}.to_json + end + + return {result: 'error'}.to_json +end \ No newline at end of file diff --git a/app/contact.rb b/app/contact.rb new file mode 100644 index 00000000..69c178a7 --- /dev/null +++ b/app/contact.rb @@ -0,0 +1,30 @@ +get '/contact' do + erb :'contact' +end + +post '/contact' do + @errors = [] + + if params[:email].empty? || params[:subject].empty? || params[:body].empty? + @errors << 'Please fill out all fields' + end + + if !recaptcha_valid? + @errors << 'Captcha was not filled out (or was filled out incorrectly)' + end + + if !@errors.empty? + erb :'contact' + else + EmailWorker.perform_async({ + from: 'web@neocities.org', + reply_to: params[:email], + to: 'contact@neocities.org', + subject: "[Neocities Contact]: #{params[:subject]}", + body: params[:body] + }) + + flash[:success] = 'Your contact has been sent.' + redirect '/' + end +end \ No newline at end of file diff --git a/app/event.rb b/app/event.rb new file mode 100644 index 00000000..9c3abeb5 --- /dev/null +++ b/app/event.rb @@ -0,0 +1,47 @@ +post '/event/:event_id/toggle_like' do |event_id| + require_login + content_type :json + event = Event[id: event_id] + liked_response = event.toggle_site_like(current_site) ? 'liked' : 'unliked' + {result: liked_response, event_like_count: event.likes_dataset.count, liking_site_names: event.liking_site_usernames}.to_json +end + +post '/event/:event_id/comment' do |event_id| + require_login + content_type :json + event = Event[id: event_id] + + site = event.site + + if site.is_blocking?(current_site) || + site.profile_comments_enabled == false || + current_site.commenting_allowed? == false + return {result: 'error'}.to_json + end + + event.add_site_comment current_site, params[:message] + {result: 'success'}.to_json +end + +post '/event/:event_id/update_profile_comment' do |event_id| + require_login + content_type :json + event = Event[id: event_id] + return {result: 'error'}.to_json unless current_site.id == event.profile_comment.actioning_site_id + + event.profile_comment.update message: params[:message] + return {result: 'success'}.to_json +end + +post '/event/:event_id/delete' do |event_id| + require_login + content_type :json + event = Event[id: event_id] + + if event.site_id == current_site.id || event.created_by?(current_site) + event.delete + return {result: 'success'}.to_json + end + + return {result: 'error'}.to_json +end \ No newline at end of file diff --git a/app/password_reset.rb b/app/password_reset.rb new file mode 100644 index 00000000..e8a361e8 --- /dev/null +++ b/app/password_reset.rb @@ -0,0 +1,70 @@ +get '/password_reset' do + erb :'password_reset' +end + +post '/send_password_reset' do + sites = Site.filter(email: params[:email]).all + + if sites.length > 0 + token = SecureRandom.uuid.gsub('-', '') + sites.each do |site| + site.update password_reset_token: token + end + + body = <<-EOT +Hello! This is the Neocities cat, and I have received a password reset request for your e-mail address. Purrrr. + +Go to this URL to reset your password: http://neocities.org/password_reset_confirm?token=#{token} + +After clicking on this link, your password for all the sites registered to this email address will be changed to this token. + +Token: #{token} + +If you didn't request this reset, you can ignore it. Or hide under a bed. Or take a nap. Your call. + +Meow, +the Neocities Cat + EOT + + body.strip! + + EmailWorker.perform_async({ + from: 'web@neocities.org', + to: params[:email], + subject: '[Neocities] Password Reset', + body: body + }) + end + + flash[:success] = 'If your email was valid (and used by a site), the Neocities Cat will send an e-mail to your account with password reset instructions.' + redirect '/' +end + +get '/password_reset_confirm' do + if params[:token].nil? || params[:token].empty? + flash[:error] = 'Could not find a site with this token.' + redirect '/' + end + + reset_site = Site[password_reset_token: params[:token]] + + if reset_site.nil? + flash[:error] = 'Could not find a site with this token.' + redirect '/' + end + + sites = Site.filter(email: reset_site.email).all + + if sites.length > 0 + sites.each do |site| + site.password = reset_site.password_reset_token + site.save_changes + end + + flash[:success] = 'Your password for all sites with your email address has been changed to the token sent in your e-mail. Please login and change your password as soon as possible.' + else + flash[:error] = 'Could not find a site with this token.' + end + + redirect '/' +end \ No newline at end of file diff --git a/app/plan.rb b/app/plan.rb new file mode 100644 index 00000000..468baa0f --- /dev/null +++ b/app/plan.rb @@ -0,0 +1,62 @@ +get '/plan/?' do + @title = 'Support Us' + + if parent_site && parent_site.unconverted_legacy_supporter? + customer = Stripe::Customer.retrieve(parent_site.stripe_customer_id) + subscription = customer.subscriptions.first + parent_site.stripe_subscription_id = subscription.id + parent_site.plan_type = subscription.plan.id + parent_site.save_changes + end + + erb :'plan/index' +end + +post '/plan/update' do + require_login + + DB.transaction do + if parent_site.stripe_subscription_id + customer = Stripe::Customer.retrieve parent_site.stripe_customer_id + subscription = customer.subscriptions.retrieve parent_site.stripe_subscription_id + subscription.plan = params[:plan_type] + subscription.save + + parent_site.update( + plan_ended: false, + plan_type: params[:plan_type] + ) + else + customer = Stripe::Customer.create( + card: params[:stripe_token], + description: "#{parent_site.username} - #{parent_site.id}", + email: (current_site.email || parent_site.email), + plan: params[:plan_type] + ) + + parent_site.update( + stripe_customer_id: customer.id, + stripe_subscription_id: customer.subscriptions.first.id, + plan_ended: false, + plan_type: params[:plan_type] + ) + end + end + + if current_site.email || parent_site.email + EmailWorker.perform_async({ + from: 'web@neocities.org', + reply_to: 'contact@neocities.org', + to: current_site.email || parent_site.email, + subject: "[Neocities] You've become a supporter!", + body: Tilt.new('./views/templates/email_subscription.erb', pretty: true).render(self, plan_name: Site::PLAN_FEATURES[params[:plan_type].to_sym][:name], plan_space: Site::PLAN_FEATURES[params[:plan_type].to_sym][:space].to_space_pretty) + }) + end + + redirect params[:plan_type] == 'free' ? '/plan' : '/plan/thanks' +end + +get '/plan/thanks' do + require_login + erb :'plan/thanks' +end \ No newline at end of file diff --git a/app/settings.rb b/app/settings.rb new file mode 100644 index 00000000..37a39328 --- /dev/null +++ b/app/settings.rb @@ -0,0 +1,270 @@ +get '/settings/?' do + require_login + @site = parent_site + erb :'settings/account' +end + +def require_ownership_for_settings + @site = Site[username: params[:username]] + + not_found if @site.nil? + + unless @site.owned_by? parent_site + flash[:error] = 'Cannot edit this site, you do not have permission.' + redirect request.referrer + end +end + +get '/settings/:username/?' do + require_login + require_ownership_for_settings + erb :'settings/site' +end + +post '/settings/:username/profile' do + require_login + require_ownership_for_settings + + @site.update( + profile_comments_enabled: params[:site][:profile_comments_enabled] + ) + flash[:success] = 'Profile settings changed.' + redirect "/settings/#{@site.username}#profile" +end + +post '/settings/:username/ssl' do + require_login + require_ownership_for_settings + + unless params[:key] && params[:cert] + flash[:error] = 'SSL key and certificate are required.' + redirect "/settings/#{@site.username}#custom_domain" + end + + begin + key = OpenSSL::PKey::RSA.new params[:key][:tempfile].read, '' + rescue => e + flash[:error] = 'Could not process SSL key, file may be incorrect, damaged, or passworded (you need to remove the password).' + redirect "/settings/#{@site.username}#custom_domain" + end + + if !key.private? + flash[:error] = 'SSL Key file does not have private key data.' + redirect "/settings/#{@site.username}#custom_domain" + end + + certs_string = params[:cert][:tempfile].read + + cert_array = certs_string.lines.slice_before(/-----BEGIN CERTIFICATE-----/).to_a.collect {|a| a.join} + + if cert_array.empty? + flash[:error] = 'Cert file does not contain any certificates.' + redirect "/settings/#{@site.username}#custom_domain" + end + + cert_valid_for_domain = false + + cert_array.each do |cert_string| + begin + cert = OpenSSL::X509::Certificate.new cert_string + rescue => e + flash[:error] = 'Could not process SSL certificate, file may be incorrect or damaged.' + redirect "/settings/#{@site.username}#custom_domain" + end + + if cert.not_after < Time.now + flash[:error] = 'SSL Certificate has expired, please create a new one.' + redirect "/settings/#{@site.username}#custom_domain" + end + + cert_cn = cert.subject.to_a.select {|a| a.first == 'CN'}.flatten[1] + cert_valid_for_domain = true if cert_cn && cert_cn.match(@site.domain) + end + + unless cert_valid_for_domain + flash[:error] = "Your certificate CN (common name) does not match your domain: #{@site.domain}" + redirect "/settings/#{@site.username}#custom_domain" + end + + # Everything else was worse. + + crtfile = Tempfile.new 'crtfile' + crtfile.write cert_array.join + crtfile.close + + keyfile = Tempfile.new 'keyfile' + keyfile.write key.to_pem + keyfile.close + + if ENV['TRAVIS'] != 'true' + nginx_testfile = Tempfile.new 'nginx_testfile' + nginx_testfile.write %{ + pid /tmp/throwaway.pid; + events {} + error_log /dev/null error; + http { + access_log off; + server { + listen 60000 ssl; + server_name #{@site.domain} *.#{@site.domain}; + ssl_certificate #{crtfile.path}; + ssl_certificate_key #{keyfile.path}; + } + } + } + nginx_testfile.close + + line = Cocaine::CommandLine.new( + "nginx", "-t -c :path", + expected_outcodes: [0], + swallow_stderr: true + ) + + begin + output = line.run path: nginx_testfile.path + rescue Cocaine::ExitStatusError => e + flash[:error] = "There is something wrong with your certificate, please check with your issuing CA." + redirect "/settings/#{@site.username}#custom_domain" + end + end + + @site.update ssl_key: key.to_pem, ssl_cert: cert_array.join + + flash[:success] = 'Updated SSL key/certificate.' + redirect "/settings/#{@site.username}#custom_domain" +end + +post '/settings/:username/change_name' do + require_login + require_ownership_for_settings + + old_username = @site.username + + if params[:name] == nil || params[:name] == '' + flash[:error] = 'Name cannot be blank.' + redirect "/settings/#{@site.username}#username" + end + + if old_username == params[:name] + flash[:error] = 'You already have this name.' + redirect "/settings/#{@site.username}#username" + end + + old_host = @site.host + old_file_paths = @site.file_list.collect {|f| f[:path]} + + @site.username = params[:name] + + if @site.valid? + DB.transaction { + @site.save_changes + @site.move_files_from old_username + } + + old_file_paths.each do |file_path| + @site.purge_cache file_path + end + + flash[:success] = "Site/user name has been changed. You will need to use this name to login, don't forget it." + redirect "/settings/#{@site.username}#username" + else + flash[:error] = @site.errors.first.last.first + redirect "/settings/#{old_username}#username" + end +end + +post '/settings/:username/change_nsfw' do + require_login + require_ownership_for_settings + + @site.update is_nsfw: params[:is_nsfw] + flash[:success] = @site.is_nsfw ? 'Marked 18+' : 'Unmarked 18+' + redirect "/settings/#{@site.username}#nsfw" +end + +post '/settings/:username/custom_domain' do + require_login + require_ownership_for_settings + + @site.domain = params[:domain] + + if @site.valid? + @site.save_changes + flash[:success] = 'The domain has been successfully updated.' + redirect "/settings/#{@site.username}#custom_domain" + else + flash[:error] = @site.errors.first.last.first + redirect "/settings/#{@site.username}#custom_domain" + end +end + +post '/settings/change_password' do + require_login + + if !Site.valid_login?(parent_site.username, params[:current_password]) + flash[:error] = 'Your provided password does not match the current one.' + redirect "/settings#password" + end + + parent_site.password = params[:new_password] + parent_site.valid? + + if params[:new_password] != params[:new_password_confirm] + parent_site.errors.add :password, 'New passwords do not match.' + end + + if parent_site.errors.empty? + parent_site.save_changes + flash[:success] = 'Successfully changed password.' + redirect "/settings#password" + else + flash[:error] = current_site.errors.first.last.first + redirect '/settings#password' + end +end + +post '/settings/change_email' do + require_login + + if params[:email] == parent_site.email + flash[:error] = 'You are already using this email address for this account.' + redirect '/settings#email' + end + + parent_site.email = params[:email] + parent_site.email_confirmation_token = SecureRandom.hex 3 + parent_site.email_confirmed = false + + if parent_site.valid? + parent_site.save_changes + send_confirmation_email + flash[:success] = 'Successfully changed email. We have sent a confirmation email, please use it to confirm your email address.' + redirect '/settings#email' + end + + flash[:error] = parent_site.errors.first.last.first + redirect '/settings#email' +end + +post '/settings/create_child' do + require_login + + if !current_site.plan_feature(:unlimited_site_creation) + flash[:error] = 'Cannot create a new site with your current plan, please become a supporter.' + redirect '/settings#sites' + end + + site = Site.new + + site.parent_site_id = parent_site.id + site.username = params[:username] + + if site.valid? + site.save + flash[:success] = 'Your new site has been created! To manage it, click your username in the top right and go to "Switch Site".' + redirect '/settings#sites' + else + flash[:error] = site.errors.first.last.first + redirect '/settings#sites' + end +end \ No newline at end of file diff --git a/app/signin.rb b/app/signin.rb new file mode 100644 index 00000000..e59a180f --- /dev/null +++ b/app/signin.rb @@ -0,0 +1,46 @@ +get '/signin/?' do + dashboard_if_signed_in + erb :'signin' +end + +post '/signin' do + dashboard_if_signed_in + + if Site.valid_login? params[:username], params[:password] + site = Site.get_with_identifier params[:username] + + if site.is_banned + flash[:error] = 'Invalid login.' + flash[:username] = params[:username] + redirect '/signin' + end + + session[:id] = site.id + redirect '/' + else + flash[:error] = 'Invalid login.' + flash[:username] = params[:username] + redirect '/signin' + end +end + +get '/signin/:username' do + require_login + @site = Site[username: params[:username]] + + not_found if @site.nil? + + if @site.owned_by? current_site + session[:id] = @site.id + redirect request.referrer + end + + flash[:error] = 'You do not have permission to switch to this site.' + redirect request.referrer +end + +get '/signout' do + require_login + session[:id] = nil + redirect '/' +end \ No newline at end of file diff --git a/app/site.rb b/app/site.rb new file mode 100644 index 00000000..94a6894e --- /dev/null +++ b/app/site.rb @@ -0,0 +1,135 @@ +get '/site/:username.rss' do |username| + site = Site[username: username] + content_type :xml + site.to_rss.to_xml +end + +get '/site/:username/?' do |username| + site = Site[username: username] + not_found if site.nil? || site.is_banned + + @title = site.title + + @current_page = params[:current_page] + @current_page = @current_page.to_i + @current_page = 1 if @current_page == 0 + + if params[:event_id] + event = Event.select(:id).where(id: params[:event_id]).first + not_found if event.nil? + events_dataset = Event.where(id: params[:event_id]).paginate(1, 1) + else + events_dataset = site.latest_events(@current_page, 10) + end + + @page_count = events_dataset.page_count || 1 + @latest_events = events_dataset.all + + erb :'site', locals: {site: site, is_current_site: site == current_site} +end + +post '/site/:username/set_editor_theme' do + require_login + current_site.update editor_theme: params[:editor_theme] + 'ok' +end + +post '/site/:username/comment' do |username| + require_login + + site = Site[username: username] + + if(site.profile_comments_enabled == false || + params[:message].empty? || + site.is_blocking?(current_site) || + current_site.is_blocking?(site) || + current_site.commenting_allowed? == false) + redirect "/site/#{username}" + end + + site.add_profile_comment( + actioning_site_id: current_site.id, + message: params[:message] + ) + + redirect "/site/#{username}" +end + +get '/site/:username/tip' do |username| + @site = Site[username: username] + @title = "Tip #{@site.title}" + erb :'tip' +end + +post '/site/:site_id/toggle_follow' do |site_id| + require_login + content_type :json + site = Site[id: site_id] + {result: (current_site.toggle_follow(site) ? 'followed' : 'unfollowed')}.to_json +end + +post '/site/create_directory' do + require_login + + path = "#{params[:dir] || ''}/#{params[:name]}" + + result = current_site.create_directory path + + if result != true + flash[:error] = e.message + end + + redirect "/dashboard?dir=#{Rack::Utils.escape params[:dir]}" +end + +get '/site/:username/confirm_email/:token' do + site = Site[username: params[:username]] + if site.email_confirmation_token == params[:token] + site.email_confirmed = true + site.save_changes + + erb :'site_email_confirmed' + else + erb :'site_email_not_confirmed' + end +end + +post '/site/:username/report' do |username| + site = Site[username: username] + + redirect request.referer if site.nil? + + report = Report.new site_id: site.id, type: params[:type], comments: params[:comments] + + if current_site + redirect request.referer if current_site.id == site.id + report.reporting_site_id = current_site.id + else + report.ip = Site.hash_ip request.ip + end + + report.save + + EmailWorker.perform_async({ + from: 'web@neocities.org', + to: 'report@neocities.org', + subject: "[Neocities Report] #{site.username} has been reported for #{report.type}", + body: "Reported by #{report.reporting_site_id ? report.reporting_site.username : report.ip}: #{report.comments}" + }) + + redirect request.referer +end + +post '/site/:username/block' do |username| + require_login + site = Site[username: username] + redirect request.referer if current_site.id == site.id + + current_site.block! site + + if request.referer.match /\/site\/#{username}/i + redirect '/' + else + redirect request.referer + end +end \ No newline at end of file diff --git a/app/site_files.rb b/app/site_files.rb new file mode 100644 index 00000000..9de68caf --- /dev/null +++ b/app/site_files.rb @@ -0,0 +1,151 @@ +get '/site_files/new_page' do + require_login + erb :'site_files/new_page' +end + +post '/site_files/create_page' do + require_login + @errors = [] + + params[:pagefilename].gsub!(/[^a-zA-Z0-9_\-.]/, '') + params[:pagefilename].gsub!(/\.html$/i, '') + + if params[:pagefilename].nil? || params[:pagefilename].empty? + @errors << 'You must provide a file name.' + halt erb(:'site_files/new_page') + end + + name = "#{params[:pagefilename]}.html" + + name = "#{params[:dir]}/#{name}" if params[:dir] + + if current_site.file_exists?(name) + @errors << %{Web page "#{name}" already exists! Choose another name.} + halt erb(:'site_files/new_page') + end + + current_site.install_new_html_file name + + flash[:success] = %{#{name} was created! Click here to edit it.} + + redirect params[:dir] ? "/dashboard?dir=#{Rack::Utils.escape params[:dir]}" : '/dashboard' +end + +get '/site_files/new' do + require_login + erb :'site_files/new' +end + +def file_upload_response(error=nil) + http_error_code = 406 + + if params[:from_button] + if error + @error = error + halt 200, erb(:'dashboard') + else + query_string = params[:dir] ? "?"+Rack::Utils.build_query(dir: params[:dir]) : '' + redirect "/dashboard#{query_string}" + end + else + halt http_error_code, error if error + halt 200, 'File(s) successfully uploaded.' + end +end + +post '/site_files/upload' do + require_login + @errors = [] + http_error_code = 406 + + 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 + + params[:files].each do |file| + file[:filename] = "#{params[:dir]}/#{file[:filename]}" if params[:dir] + if current_site.file_size_too_large? file[:tempfile].size + file_upload_response "#{params[:dir]}/#{file[:filename]} is too large, upload cancelled." + end + if !current_site.okay_to_upload? file + file_upload_response "#{params[:dir]}/#{file[:filename]}: file type (or content in file) is not allowed on this site, upload cancelled. You can upgrade your account to remove the file type restrictions." + end + end + + uploaded_size = params[:files].collect {|f| f[:tempfile].size}.inject{|sum,x| sum + x } + + if current_site.file_size_too_large? uploaded_size + file_upload_response "File(s) do not fit in your available space, upload cancelled." + end + + results = [] + params[:files].each do |file| + results << current_site.store_file(file[:filename], file[:tempfile]) + end + current_site.increment_changed_count if results.include?(true) + + file_upload_response +end + +post '/site_files/delete' do + require_login + current_site.delete_file params[:filename] + + flash[:success] = "Deleted #{params[:filename]}." + redirect '/dashboard' +end + +get '/site_files/:username.zip' do |username| + require_login + zipfile_path = current_site.files_zip + content_type 'application/octet-stream' + attachment "neocities-#{current_site.username}.zip" + send_file zipfile_path +end + +get '/site_files/download/:filename' do |filename| + require_login + content_type 'application/octet-stream' + attachment filename + current_site.get_file filename +end + +get %r{\/site_files\/text_editor\/(.+)} do + require_login + @filename = params[:captures].first + begin + @file_data = current_site.get_file @filename + rescue Errno::ENOENT + flash[:error] = 'We could not find the requested file.' + redirect '/dashboard' + end + erb :'site_files/text_editor' +end + +post %r{\/site_files\/save\/(.+)} do + require_login_ajax + filename = params[:captures].first + + tempfile = Tempfile.new 'neocities_saving_file' + + input = request.body.read + tempfile.set_encoding input.encoding + tempfile.write input + tempfile.close + + if current_site.file_size_too_large? tempfile.size + halt 'File is too large to fit in your space, it has NOT been saved. You will need to reduce the size or upgrade to a new plan.' + end + + current_site.store_file filename, tempfile + + 'ok' +end + +get '/site_files/allowed_types' do + erb :'site_files/allowed_types' +end + +get '/site_files/mount_info' do + erb :'site_files/mount_info' +end \ No newline at end of file diff --git a/app/stats.rb b/app/stats.rb new file mode 100644 index 00000000..0706e2ab --- /dev/null +++ b/app/stats.rb @@ -0,0 +1,62 @@ +get '/stats/?' do + require_admin + + @stats = { + total_sites: Site.count, + total_unbanned_sites: Site.where(is_banned: false).count, + total_banned_sites: Site.where(is_banned: true).count, + total_nsfw_sites: Site.where(is_nsfw: true).count, + total_unbanned_nsfw_sites: Site.where(is_banned: false, is_nsfw: true).count, + total_banned_nsfw_sites: Site.where(is_banned: true, is_nsfw: true).count + } + + # Start with the date of the first created site + + start = Site.select(:created_at). + exclude(created_at: nil). + order(:created_at). + first[:created_at].to_date + + runner = start + + monthly_stats = [] + + now = Time.now + + until runner.year == now.year && runner.month == now.month+1 + monthly_stats.push( + date: runner, + sites_created: Site.where(created_at: runner..runner.next_month).count, + total_from_start: Site.where(created_at: start..runner.next_month).count, + supporters: Site.where(created_at: start..runner.next_month).exclude(stripe_customer_id: nil).count, + ) + + runner = runner.next_month + end + + @stats[:monthly_stats] = monthly_stats + + customers = Stripe::Customer.all + + @stats[:total_recurring_revenue] = 0.0 + + subscriptions = [] + cancelled_subscriptions = 0 + + customers.each do |customer| + sub = {created_at: Time.at(customer.created)} + if customer[:subscriptions] + if customer[:subscriptions][:data].empty? + sub[:status] = 'cancelled' + else + sub[:status] = 'active' + sub[:amount] = (customer[:subscriptions][:data].first[:plan][:amount] / 100.0).round(2) + @stats[:total_recurring_revenue] += sub[:amount] + end + end + subscriptions.push sub + end + + @stats[:subscriptions] = subscriptions + erb :'stats' +end \ No newline at end of file diff --git a/app/sysops.rb b/app/sysops.rb new file mode 100644 index 00000000..cfc0bc1c --- /dev/null +++ b/app/sysops.rb @@ -0,0 +1,84 @@ +require 'zlib' +require 'rubygems/package' + +get '/sysops/proxy/map.txt' do + require_proxy_auth + domains = '' + Site.exclude(domain: nil). + exclude(domain: ''). + select(:username,:domain). + all. + collect do |s| + domains << "#{s.domain} #{s.username};\n" + end + content_type :text + domains +end + +get '/sysops/proxy/sslcerts.tar.gz' do + require_proxy_auth + sites = Site.ssl_sites + + nginx_config = '' + + tar = StringIO.new + + Gem::Package::TarWriter.new(tar) do |writer| + writer.mkdir 'sslcerts', 0740 + writer.mkdir 'sslcerts/certs', 0740 + + sites.each do |site| + writer.add_file "sslcerts/certs/#{site.username}.key", 0640 do |f| + f.write site.ssl_key + end + + writer.add_file "sslcerts/certs/#{site.username}.crt", 0640 do |f| + f.write site.ssl_cert + end + + nginx_config << %{ + server { + listen 443 ssl; + server_name #{site.domain} *.#{site.domain}; + ssl_certificate sslsites/certs/#{site.username}.crt; + ssl_certificate_key sslsites/certs/#{site.username}.key; + + location / { + proxy_http_version 1.1; + proxy_set_header Host #{site.username}.neocities.org; + proxy_pass http://127.0.0.1$request_uri; + } + } + }.unindent + end + + writer.add_file "sslcerts/sslsites.conf", 0640 do |f| + f.write nginx_config + end + end + + tar.rewind + + package = StringIO.new 'b' + package.set_encoding 'binary' + gzip = Zlib::GzipWriter.new package + gzip.write tar.read + tar.close + gzip.finish + package.rewind + + attachment + package.read +end + +class ProxyAccessViolation < StandardError; end + +def require_proxy_auth + begin + auth = request.env['HTTP_AUTHORIZATION'] + user, pass = Base64.decode64(auth.match(/Basic (.+)/)[1]).split(':') + raise ProxyAccessViolation unless pass == $config['proxy_pass'] + rescue + raise ProxyAccessViolation, "Violator: #{request.ip}" unless pass == $config['proxy_pass'] + end +end \ No newline at end of file diff --git a/app/tags.rb b/app/tags.rb new file mode 100644 index 00000000..c68734bb --- /dev/null +++ b/app/tags.rb @@ -0,0 +1,26 @@ +post '/tags/add' do + require_login + current_site.new_tags_string = params[:tags] + + if current_site.valid? + current_site.save_tags + else + flash[:errors] = current_site.errors.first + end + + redirect request.referer +end + +post '/tags/remove' do + require_login + + DB.transaction { + params[:tags].each {|tag| current_site.remove_tag Tag[name: tag]} + } + + redirect request.referer +end + +get '/tags/autocomplete/:name.json' do |name| + Tag.autocomplete(name).collect {|t| t[:name]}.to_json +end \ No newline at end of file diff --git a/app_helpers.rb b/app_helpers.rb new file mode 100644 index 00000000..3ab58ae3 --- /dev/null +++ b/app_helpers.rb @@ -0,0 +1,87 @@ +def generate_question + if ENV['RACK_ENV'] == 'test' + question_first_number = 1 + question_last_number = 1 + else + question_first_number = rand 5 + question_last_number = rand 5 + end + session[:question_answer] = (question_first_number + question_last_number).to_s + [question_first_number, question_last_number] +end + +def dashboard_if_signed_in + redirect '/dashboard' if signed_in? +end + +def require_login_ajax + halt 'You are not logged in!' unless signed_in? + halt 'You are banned.' if current_site.is_banned? || parent_site.is_banned? +end + +def csrf_safe? + csrf_token == params[:csrf_token] || csrf_token == request.env['HTTP_X_CSRF_TOKEN'] +end + +def csrf_token + session[:_csrf_token] ||= SecureRandom.base64(32) +end + +def require_login + redirect '/' unless signed_in? + if session[:banned] || current_site.is_banned || parent_site.is_banned + session[:id] = nil + session[:banned] = true + redirect '/' + end +end + +def signed_in? + !session[:id].nil? +end + +def current_site + return nil if session[:id].nil? + @_site ||= Site[id: session[:id]] +end + +def parent_site + return nil if current_site.nil? + current_site.parent? ? current_site : current_site.parent +end + +def require_unbanned_ip + if session[:banned] || Site.banned_ip?(request.ip) + session[:id] = nil + session[:banned] = true + flash[:error] = 'Site creation has been banned due to ToS violation/spam. '+ + 'If you believe this to be in error, contact the site admin.' + return {result: 'error'}.to_json + end +end + +def title + out = "Neocities" + return out if request.path == '/' + return "#{out} - #{@title}" if @title + "#{out} - #{request.path.gsub('/', '').capitalize}" +end + +def encoding_fix(file) + begin + Rack::Utils.escape_html file + rescue ArgumentError => e + return Rack::Utils.escape_html(file.force_encoding('BINARY')) if e.message =~ /invalid byte sequence in UTF-8/ + fail + end +end + +def send_confirmation_email(site=current_site) + EmailWorker.perform_async({ + from: 'web@neocities.org', + reply_to: 'contact@neocities.org', + to: site.email, + subject: "[Neocities] Confirm your email address", + body: Tilt.new('./views/templates/email_confirm.erb', pretty: true).render(self, site: site) + }) +end \ No newline at end of file diff --git a/views/new.erb b/views/new.erb deleted file mode 100644 index 51773104..00000000 --- a/views/new.erb +++ /dev/null @@ -1,180 +0,0 @@ - - - -
-
-

Create a New Website

-

Your free web site is only a few minutes away!

-
-
- -
-
- - - <% if !@site.errors.empty? %> -
-
-
-

There were errors creating your home page:

- <% @site.errors.each do |error| %> -

<%= error.last.first %>

- <% end %> -
-
-
- <% end %> - -

- First, enter a username. This will also be used as your site name.
Do not forget this, it will be used to sign in to and manage your home page. It can only contain letters, numbers, underscores and hyphens, and can only be 32 characters long. -

-
Username
-

- .neocities.org -

- -
- -

- Next, enter a password. This will be used to allow you to login. Minimum 5 characters. If you don't make it a good password, Dade Murphy from the movie Hackers will come in and steal your "garbage files". -

-
Password
- - -
- -

- Now you need to enter your e-mail address. Your e-mail address is private, we will not display it or give it to third-parties. -

-
Email
- - -
- - -

- If your page will contain objectionable (adult) content, check this box:   > -

- -
- -

You can optionally enter a tag! A tag allows others to find your site by looking for sites with that tag (examples: pokemon, chess). Keep your tag simple and generic. Don't use tags like "johnspersonalsite", it's too unique and won't have related sites. A tag can only contain letters (A-Z) and numbers (0-9), no spaces. -

-

Don't think too hard about this! You don't have to add a tag if your site doesn't have a specific topic, and you can always add one later.

- -
Add a Tag
- - -
- -

The site you are creating will be free, forever. We will never charge you for your web site.

-

Neocities has to pay the bills though, and we like the idea of being able to work on the site full-time someday. So if you would like to help us reach this goal, we have created the Supporter Plan! - -

Right now, the Supporter Plan is the same as the free plan, except that Supporter Plan members get <%= Site::SUPPORTER_MAXIMUM.to_space_pretty %> of web space. You will also be listed as a supporter on our contributors page, and on your site profile page.

-

The base plan is $12 ($1/month) billed once per year, which is the cost of a delicious Yafa Combo with a lousy tip. If you ever decide to cancel, you get to keep the extra space. Thanks for helping us run this site!

- -
- > - Free Plan (<%= Site::FREE_MAXIMUM.to_space_pretty %>) -
- -
- > - Supporter Plan (<%= Site::SUPPORTER_MAXIMUM.to_space_pretty %>) -
- - - -
- -

Last thing! Enter the captcha correctly so we know you're not a robot (don't worry robots, we still love you).

- -
- <%== recaptcha_tag :challenge, ssl: request.ssl? %> -
- -
- -

You're done. Just click the button below!

- -
-
- -