require 'rubygems' require './app.rb' require 'sidekiq/web' require 'airbrake/sidekiq' require 'rack/mime' require 'rack/utils' require 'time' use Airbrake::Rack::Middleware class HostedSiteMiddleware def initialize(app) @app = app end def call(env) host = env['HTTP_HOST'].to_s.downcase.split(':').first site = Site.find_by_host(host) return @app.call(env) unless site path = env['PATH_INFO'].to_s begin path = Rack::Utils.unescape_path(path) scrubbed_path = site.scrubbed_path(path) rescue ArgumentError return [400, { 'Content-Type' => 'text/plain' }, ['Invalid path']] end if scrubbed_path.end_with?('.html') redirect_path = if scrubbed_path == 'index.html' '/' else "/#{scrubbed_path.sub(/\.html\z/, '')}" end return [ 301, { 'Location' => redirect_path, 'Content-Type' => 'text/plain' }, ['Moved Permanently'] ] end if scrubbed_path.empty? scrubbed_path = 'index.html' elsif site.is_directory?(scrubbed_path) scrubbed_path = File.join(scrubbed_path, 'index.html') elsif !site.file_exists?(scrubbed_path) html_path = "#{scrubbed_path}.html" scrubbed_path = html_path if site.file_exists?(html_path) end return [404, { 'Content-Type' => 'text/plain' }, ['Not Found']] unless site.file_exists?(scrubbed_path) file_path = site.files_path(scrubbed_path) begin stat = File.stat(file_path) rescue Errno::ENOENT return [404, { 'Content-Type' => 'text/plain' }, ['Not Found']] end headers = { 'Content-Type' => Rack::Mime.mime_type( File.extname(scrubbed_path), 'application/octet-stream' ), 'Content-Length' => stat.size.to_s, 'Last-Modified' => stat.mtime.httpdate } return [200, headers, []] if env['REQUEST_METHOD'] == 'HEAD' [200, headers, [File.binread(file_path)]] end end use HostedSiteMiddleware map('/') do run Sinatra::Application end map '/webdav' do run lambda { |env| auth = Rack::Auth::Basic::Request.new(env) unless auth.provided? && auth.basic? && auth.credentials return [401, { 'WWW-Authenticate' => 'Basic realm="Restricted Area"' }, ['Not authorized']] end username, password = auth.credentials site = Site.get_site_from_login(username, password) unless site return [401, { 'WWW-Authenticate' => 'Basic realm="Restricted Area"' }, ['Not authorized']] end request_method = env['REQUEST_METHOD'] begin path = Rack::Utils.unescape_path(env['PATH_INFO']) rescue ArgumentError return [400, {}, ['Invalid path']] end unless site.owner.supporter? return [ 402, { 'Content-Type' => 'application/xml', 'X-Upgrade-Required' => 'https://neocities.org/supporter' }, [ <<~XML WebDAV access requires a supporter account. XML ] ] end case request_method when 'OPTIONS' return [200, {'Allow' => 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, MKCOL, MOVE', 'DAV' => '1'}, ['']] when 'GET', 'HEAD' begin scrubbed_path = site.scrubbed_path(path) rescue ArgumentError return [400, {}, ['Invalid path']] end if scrubbed_path.empty? || site.is_directory?(scrubbed_path) return [403, {}, ['Cannot download a directory']] end return [404, {}, ['']] unless site.file_exists?(scrubbed_path) site_file = site.site_files_dataset.where(path: scrubbed_path).first file_path = site.files_path(scrubbed_path) begin file_stat = File.stat(file_path) rescue Errno::ENOENT return [404, {}, ['']] end content_length = site_file&.size || file_stat.size last_modified = if site_file&.updated_at site_file.updated_at elsif site_file&.created_at site_file.created_at else file_stat.mtime end headers = { 'Content-Type' => Rack::Mime.mime_type(File.extname(scrubbed_path), 'application/octet-stream'), 'Content-Length' => content_length.to_s, 'Last-Modified' => last_modified.httpdate } if site_file&.sha1_hash headers['ETag'] = %("#{site_file.sha1_hash}") end if request_method == 'HEAD' return [200, headers, []] end file_io = File.open(file_path, 'rb') return [200, headers, file_io] when 'PROPFIND' begin scrubbed_path = site.scrubbed_path(path) rescue ArgumentError return [400, {}, ['Invalid path']] end target_is_root = scrubbed_path.empty? site_file = unless target_is_root site.site_files_dataset.where(path: scrubbed_path).first end if !target_is_root && site_file.nil? return [404, {}, ['']] end if site_file && !site_file.is_directory && !site.file_exists?(scrubbed_path) return [404, {}, ['']] end depth_header = env['HTTP_DEPTH'] depth = depth_header == '0' ? 0 : 1 responses = [] build_response_info = lambda do |relative_path, file_info| is_directory = file_info[:is_directory] href_path_segments = relative_path.split('/').reject(&:empty?).map { |segment| Rack::Utils.escape_path(segment) } href = '/webdav' href += '/' unless href_path_segments.empty? href += href_path_segments.join('/') href += '/' if is_directory && !href.end_with?('/') display_name = if relative_path.empty? '/' else relative_path.split('/').last end updated_at = file_info[:updated_at] || file_info[:created_at] <<~XML #{Rack::Utils.escape_html(href)} #{Rack::Utils.escape_html(display_name)} #{is_directory ? '' : ''} #{file_info[:created_at] ? "#{file_info[:created_at].utc.iso8601}" : ''} #{updated_at ? "#{updated_at.httpdate}" : ''} #{is_directory ? '' : "#{file_info[:size]}"} #{file_info[:content_type] ? "#{Rack::Utils.escape_html(file_info[:content_type])}" : ''} #{file_info[:etag] ? "#{Rack::Utils.escape_html(file_info[:etag])}" : ''} HTTP/1.1 200 OK XML end add_response_for = lambda do |relative_path, file_info| responses << build_response_info.call(relative_path, file_info) end target_info = if target_is_root { is_directory: true, size: 0, created_at: site.created_at, updated_at: site.site_updated_at || site.updated_at, content_type: nil, etag: nil } else { is_directory: site_file.is_directory, size: site_file.is_directory ? 0 : site_file.size.to_i, created_at: site_file.created_at, updated_at: site_file.updated_at, content_type: site_file.is_directory ? nil : Rack::Mime.mime_type(File.extname(scrubbed_path), 'application/octet-stream'), etag: site_file.sha1_hash ? %("#{site_file.sha1_hash}") : nil } end add_response_for.call(scrubbed_path, target_info) if depth > 0 && (target_is_root || target_info[:is_directory]) site.file_list(scrubbed_path).each do |entry| child_info = { is_directory: entry[:is_directory], size: entry[:is_directory] ? 0 : entry[:size].to_i, created_at: entry[:created_at], updated_at: entry[:updated_at], content_type: entry[:is_directory] ? nil : Rack::Mime.mime_type(File.extname(entry[:path]), 'application/octet-stream'), etag: entry[:sha1_hash] ? %("#{entry[:sha1_hash]}") : nil } add_response_for.call(entry[:path], child_info) end end xml = <<~XML #{responses.join} XML return [207, {'Content-Type' => 'application/xml; charset=utf-8', 'DAV' => '1'}, [xml]] when 'PUT' tmpfile = Tempfile.new('davfile', encoding: 'binary') tmpfile.write(env['rack.input'].read) tmpfile.close result = site.store_files([{ filename: path, tempfile: tmpfile }]) if result.is_a?(Hash) && result[:error] # Map error types to appropriate HTTP status codes status_code = case result[:error_type] when 'too_large', 'file_too_large', 'too_many_files' 507 # Insufficient Storage when 'directory_exists' 409 # Conflict when 'invalid_file_type' 415 # Unsupported Media Type else 400 # Bad Request end return [status_code, {}, [result[:message]]] end return [201, {}, ['']] when 'MKCOL' return [400, {}, ['Invalid path']] if site.invalid_path?(path) return [400, {}, ['Path too long']] if SiteFile.path_too_long?(path) return [409, {}, ['Already exists']] if site.file_exists?(path) site.create_directory(path) return [201, {}, ['']] when 'MOVE' destination = env['HTTP_DESTINATION'][/\/webdav(.+)$/i, 1] return [400, {}, ['Bad Request']] unless destination begin destination = Rack::Utils.unescape_path(destination) rescue ArgumentError return [400, {}, ['Invalid destination path']] end # Remove leading and trailing slashes if present path.sub!(/^\//, '') path.sub!(/\/$/, '') site_file = site.site_files.find { |s| s.path == path } return [404, {}, ['']] unless site_file return [400, {}, ['Invalid destination path']] if site.invalid_path?(destination) return [400, {}, ['Destination path too long']] if SiteFile.path_too_long?(destination) return [400, {}, ['Destination filename too long']] if SiteFile.name_too_long?(destination) return [403, {}, ['Cannot rename to index.html at root']] if destination == '/index.html' && path != '/index.html' res = site_file.rename(destination) if res.first == true return [201, {}, ['']] else return [400, {}, [res.last]] end when 'DELETE' return [403, {}, ['Cannot delete index.html']] if path == '/index.html' || path == 'index.html' return [403, {}, ['Cannot delete root directory']] if site.files_path(path) == site.files_path return [404, {}, ['File not found']] unless site.file_exists?(path) site.delete_file(path) return [201, {}, ['']] else return [501, {}, ['Not Implemented']] end } end map '/sidekiq' do use Rack::Auth::Basic, "Protected Area" do |username, password| raise 'missing sidekiq auth' unless $config['sidekiq_user'] && $config['sidekiq_pass'] username == $config['sidekiq_user'] && password == $config['sidekiq_pass'] end use Rack::Session::Cookie, key: 'sidekiq.session', secret: Base64.strict_decode64($config['session_secret']) use Rack::Protection::AuthenticityToken run Sidekiq::Web end