diff --git a/lib/falcon/command/static.rb b/lib/falcon/command/static.rb new file mode 100644 index 0000000..f4ba3d8 --- /dev/null +++ b/lib/falcon/command/static.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require_relative "../server" +require_relative "../endpoint" +require_relative "../configuration" +require_relative "../service/server" +require_relative "../middleware/static" + +require "async/container" +require "samovar" + +module Falcon + module Command + # Implements the `falcon static` command. Designed for serving static files. + # + # Manages a static file server for the current directory. + class Static < Samovar::Command + self.description = "Serve static files from the current directory." + + # The command line options. + # @attribute [Samovar::Options] + options do + option "-b/--bind
", "Bind to the given hostname/address.", default: "http://localhost:3000" + + option "-p/--port ", "Override the specified port.", type: Integer + option "-h/--hostname ", "Specify the hostname which would be used for certificates, etc." + option "-t/--timeout ", "Specify the maximum time to wait for non-blocking operations.", type: Float, default: nil + + option "-r/--root ", "Root directory to serve static files from.", default: Dir.pwd + option "-i/--index ", "Index file to serve for directories.", default: "index.html" + option "--[no]-directory-listing", "Enable/disable directory listings.", default: true + + option "--cache", "Enable the response cache." + + option "--forked | --threaded | --hybrid", "Select a specific parallelism model.", key: :container, default: :forked + + option "-n/--count ", "Number of instances to start.", default: 1, type: Integer + + option "--forks ", "Number of forks (hybrid only).", type: Integer + option "--threads ", "Number of threads (hybrid only).", type: Integer + + option "--[no]-restart", "Enable/disable automatic restart.", default: true + option "--graceful-stop ", "Duration to wait for graceful stop.", type: Float, default: 1.0 + + option "--health-check-timeout ", "Duration to wait for health check.", type: Float, default: 30.0 + end + + def container_options + @options.slice(:count, :forks, :threads, :restart, :health_check_timeout) + end + + def endpoint_options + @options.slice(:hostname, :port, :timeout) + end + + def name + @options[:hostname] || @options[:bind] + end + + def root_directory + File.expand_path(@options[:root]) + end + + # Create a middleware stack for serving static files + def middleware_app + # Create a 404 fallback + not_found_app = lambda do |request| + Protocol::HTTP::Response[404, {'content-type' => 'text/plain'}, ['Not Found']] + end + + # Create the static middleware + Middleware::Static.new( + not_found_app, + root: root_directory, + index: @options[:index], + directory_listing: @options[:directory_listing] + ) + end + + def environment + static_middleware = middleware_app + verbose_mode = self.parent&.verbose? + cache_enabled = @options[:cache] + + Async::Service::Environment.new(Falcon::Environment::Server).with( + root: root_directory, + + verbose: verbose_mode, + cache: cache_enabled, + + container_options: self.container_options, + endpoint_options: self.endpoint_options, + + url: @options[:bind], + + name: self.name, + + endpoint: ->{Endpoint.parse(url, **endpoint_options)}, + + # Use our custom static middleware directly + middleware: ->{ + ::Protocol::HTTP::Middleware.build do + if verbose_mode + use Falcon::Middleware::Verbose + end + + if cache_enabled + use Async::HTTP::Cache::General + end + + use ::Protocol::HTTP::ContentEncoding + + run static_middleware + end + } + ) + end + + def configuration + Configuration.new.tap do |configuration| + configuration.add(self.environment) + end + end + + # The container class to use. + def container_class + case @options[:container] + when :threaded + return Async::Container::Threaded + when :forked + return Async::Container::Forked + when :hybrid + return Async::Container::Hybrid + end + end + + # The endpoint to bind to. + def endpoint + Endpoint.parse(@options[:bind], **endpoint_options) + end + + # Prepare the environment and run the controller. + def call + Console.logger.info(self) do |buffer| + buffer.puts "Falcon Static v#{VERSION} taking flight! Using #{self.container_class} #{self.container_options}." + buffer.puts "- Running on #{RUBY_DESCRIPTION}" + buffer.puts "- Serving files from: #{root_directory}" + buffer.puts "- Index file: #{@options[:index]}" + buffer.puts "- Binding to: #{self.endpoint}" + buffer.puts "- To terminate: Ctrl-C or kill #{Process.pid}" + buffer.puts "- To reload configuration: kill -HUP #{Process.pid}" + end + + Async::Service::Controller.run(self.configuration, container_class: self.container_class, graceful_stop: @options[:graceful_stop]) + end + end + end +end diff --git a/lib/falcon/command/top.rb b/lib/falcon/command/top.rb index c7eb0d5..9acafc1 100644 --- a/lib/falcon/command/top.rb +++ b/lib/falcon/command/top.rb @@ -8,6 +8,7 @@ require_relative "virtual" require_relative "proxy" require_relative "redirect" +require_relative "static" require_relative "../version" @@ -37,6 +38,7 @@ class Top < Samovar::Command "virtual" => Virtual, "proxy" => Proxy, "redirect" => Redirect, + "static" => Static, }, default: "serve" # Whether verbose logging is enabled. diff --git a/lib/falcon/middleware/static.rb b/lib/falcon/middleware/static.rb new file mode 100644 index 0000000..4ae7eb9 --- /dev/null +++ b/lib/falcon/middleware/static.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http/middleware" +require "protocol/http/body/file" +require "mime/types" +require "cgi" + +module Falcon + module Middleware + # A HTTP middleware for serving static files and directory listings. + class Static < Protocol::HTTP::Middleware + # Default MIME types for common file extensions + MIME_TYPES = { + '.html' => 'text/html; charset=utf-8', + '.htm' => 'text/html; charset=utf-8', + '.css' => 'text/css', + '.js' => 'application/javascript', + '.json' => 'application/json', + '.xml' => 'application/xml', + '.txt' => 'text/plain', + '.md' => 'text/markdown', + '.png' => 'image/png', + '.jpg' => 'image/jpeg', + '.jpeg' => 'image/jpeg', + '.gif' => 'image/gif', + '.svg' => 'image/svg+xml', + '.ico' => 'image/x-icon', + '.pdf' => 'application/pdf', + '.zip' => 'application/zip', + }.freeze + + # Initialize the static file middleware. + # @parameter app [Protocol::HTTP::Middleware] The middleware to wrap. + # @parameter root [String] The root directory to serve files from. + # @parameter index [String] The default index file for directories. + # @parameter directory_listing [Boolean] Whether to show directory listings. + def initialize(app, root: Dir.pwd, index: 'index.html', directory_listing: true) + super(app) + + @root = File.expand_path(root) + @index = index + @directory_listing = directory_listing + end + + # The root directory being served. + # @attribute [String] + attr :root + + # The default index file. + # @attribute [String] + attr :index + + # Whether directory listings are enabled. + # @attribute [Boolean] + attr :directory_listing + + # Get the MIME type for a file extension. + # @parameter extension [String] The file extension (including the dot). + # @returns [String] The MIME type. + def mime_type_for_extension(extension) + MIME_TYPES[extension.downcase] || 'application/octet-stream' + end + + # Resolve the file system path for a request path. + # @parameter request_path [String] The HTTP request path. + # @returns [String, nil] The file system path, or nil if invalid. + def resolve_path(request_path) + # Normalize the path and prevent directory traversal + path = File.join(@root, request_path) + real_path = File.realpath(path) rescue nil + + # Ensure the resolved path is within the root directory + return nil unless real_path&.start_with?(@root) + + real_path + end + + # Generate an HTML directory listing. + # @parameter directory_path [String] The directory path. + # @parameter request_path [String] The HTTP request path. + # @returns [String] HTML content for the directory listing. + def generate_directory_listing(directory_path, request_path) + entries = Dir.entries(directory_path).sort + + # Remove current directory entry, but keep parent unless at root + entries.reject! { |entry| entry == '.' } + entries.reject! { |entry| entry == '..' } if request_path == '/' + + html = <<~HTML + + + + + + Directory listing for #{CGI.escapeHTML(request_path)} + + + +
+
+

📁 Directory Listing

+
#{CGI.escapeHTML(request_path)}
+
+
+ HTML + + entries.each do |entry| + entry_path = File.join(directory_path, entry) + relative_path = File.join(request_path, entry) + relative_path = relative_path[1..-1] if relative_path.start_with?('//') + + if File.directory?(entry_path) + icon = entry == '..' ? '⬆️' : '📁' + size = '-' + href = entry == '..' ? File.dirname(request_path) : relative_path + href += '/' unless href.end_with?('/') + else + icon = get_file_icon(entry) + size = format_file_size(File.size(entry_path)) + href = relative_path + end + + html += <<~HTML + + #{icon} + #{CGI.escapeHTML(entry)} + #{size} + + HTML + end + + html += <<~HTML +
+ +
+ + + HTML + + html + end + + # Get an appropriate icon for a file based on its extension. + # @parameter filename [String] The filename. + # @returns [String] An emoji icon. + def get_file_icon(filename) + ext = File.extname(filename).downcase + case ext + when '.html', '.htm' then '🌐' + when '.css' then '🎨' + when '.js' then '⚡' + when '.json' then '📋' + when '.xml' then '📄' + when '.txt', '.md' then '📝' + when '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico' then '🖼️' + when '.pdf' then '📕' + when '.zip', '.tar', '.gz' then '📦' + when '.rb' then '💎' + when '.py' then '🐍' + when '.java' then '☕' + when '.cpp', '.c', '.h' then '⚙️' + else '📄' + end + end + + # Format file size in human-readable format. + # @parameter size [Integer] Size in bytes. + # @returns [String] Formatted size string. + def format_file_size(size) + if size < 1024 + "#{size}B" + elsif size < 1024 * 1024 + "#{(size / 1024.0).round(1)}KB" + elsif size < 1024 * 1024 * 1024 + "#{(size / (1024.0 * 1024)).round(1)}MB" + else + "#{(size / (1024.0 * 1024 * 1024)).round(1)}GB" + end + end + + # Handle the HTTP request for static files. + # @parameter request [Protocol::HTTP::Request] + # @returns [Protocol::HTTP::Response] + def call(request) + # Only handle GET and HEAD requests + return super unless request.method == 'GET' || request.method == 'HEAD' + + file_path = resolve_path(request.path) + return super unless file_path + + if File.exist?(file_path) + if File.directory?(file_path) + # Try to serve index file first + index_path = File.join(file_path, @index) + if File.file?(index_path) + return serve_file(index_path, request.method == 'HEAD') + elsif @directory_listing + return serve_directory_listing(file_path, request.path) + else + return super + end + elsif File.file?(file_path) + return serve_file(file_path, request.method == 'HEAD') + end + end + + # File not found, pass to next middleware + super + end + + private + + # Serve a static file. + # @parameter file_path [String] The file system path. + # @parameter head_only [Boolean] Whether this is a HEAD request. + # @returns [Protocol::HTTP::Response] + def serve_file(file_path, head_only = false) + stat = File.stat(file_path) + extension = File.extname(file_path) + content_type = mime_type_for_extension(extension) + + headers = [ + ['content-type', content_type], + ['content-length', stat.size.to_s], + ['last-modified', stat.mtime.httpdate] + ] + + if head_only + body = [] + else + body = Protocol::HTTP::Body::File.open(file_path) + end + + return Protocol::HTTP::Response[200, headers, body] + end + + # Serve a directory listing. + # @parameter directory_path [String] The directory path. + # @parameter request_path [String] The HTTP request path. + # @returns [Protocol::HTTP::Response] + def serve_directory_listing(directory_path, request_path) + html = generate_directory_listing(directory_path, request_path) + + headers = [ + ['content-type', 'text/html; charset=utf-8'], + ['content-length', html.bytesize.to_s] + ] + + return Protocol::HTTP::Response[200, headers, [html]] + end + end + end +end diff --git a/lib/falcon/server.rb b/lib/falcon/server.rb index 588242f..192d242 100644 --- a/lib/falcon/server.rb +++ b/lib/falcon/server.rb @@ -59,11 +59,11 @@ def accept(...) @connection_count -= 1 end - def call(...) + def call(*args, **kwargs) @request_count += 1 @active_count += 1 - super + super(*args, **kwargs) ensure @active_count -= 1 end