| lib/HTTP/Handy.pm | |||
|---|---|---|---|
| Criterion | Covered | Total | % |
| statement | 148 | 295 | 50.1 |
| branch | 47 | 146 | 32.1 |
| condition | 24 | 72 | 33.3 |
| subroutine | 27 | 31 | 87.1 |
| pod | 10 | 10 | 100.0 |
| total | 256 | 554 | 46.2 |
| line | stmt | bran | cond | sub | pod | time | code |
|---|---|---|---|---|---|---|---|
| 1 | package HTTP::Handy; | ||||||
| 2 | ###################################################################### | ||||||
| 3 | # | ||||||
| 4 | # HTTP::Handy - A tiny HTTP/1.0 server for Perl 5.5.3+ | ||||||
| 5 | # | ||||||
| 6 | # https://metacpan.org/dist/HTTP-Handy | ||||||
| 7 | # | ||||||
| 8 | # Copyright (c) 2026 INABA Hitoshi |
||||||
| 9 | ###################################################################### | ||||||
| 10 | # | ||||||
| 11 | # Compatible : Perl 5.005_03 and later | ||||||
| 12 | # Platform : Windows and UNIX/Linux | ||||||
| 13 | # | ||||||
| 14 | ###################################################################### | ||||||
| 15 | |||||||
| 16 | 6 | 6 | 251063 | use 5.00503; # Universal Consensus 1998 for primetools | |||
| 6 | 27 | ||||||
| 17 | # Perl 5.005_03 compatibility for historical toolchains | ||||||
| 18 | # use 5.008001; # Lancaster Consensus 2013 for toolchains | ||||||
| 19 | |||||||
| 20 | 6 | 6 | 39 | use strict; | |||
| 6 | 16 | ||||||
| 6 | 485 | ||||||
| 21 | 6 | 50 | 33 | 6 | 204 | BEGIN { if ($] < 5.006 && !defined(&warnings::import)) { $INC{'warnings.pm'} = 'stub'; eval 'package warnings; sub import {}' } } | |
| 0 | 0 | ||||||
| 0 | 0 | ||||||
| 22 | 6 | 6 | 35 | use warnings; local $^W = 1; | |||
| 6 | 7 | ||||||
| 6 | 500 | ||||||
| 23 | 6 | 50 | 6 | 146 | BEGIN { pop @INC if $INC[-1] eq '.' } | ||
| 24 | |||||||
| 25 | 6 | 6 | 16762 | use IO::Socket; | |||
| 6 | 105213 | ||||||
| 6 | 31 | ||||||
| 26 | 6 | 6 | 2896 | use Carp qw(croak); | |||
| 6 | 14 | ||||||
| 6 | 359 | ||||||
| 27 | 6 | 6 | 29 | use vars qw($VERSION $ACCESS_LOG_FH $CURRENT_LOG_FILE); | |||
| 6 | 9 | ||||||
| 6 | 30252 | ||||||
| 28 | $VERSION = '1.03'; | ||||||
| 29 | $VERSION = $VERSION; | ||||||
| 30 | # $VERSION self-assignment suppresses "used only once" warning under strict. | ||||||
| 31 | |||||||
| 32 | ############################################################################### | ||||||
| 33 | # Status text map | ||||||
| 34 | ############################################################################### | ||||||
| 35 | my %STATUS_TEXT = ( | ||||||
| 36 | 200 => 'OK', | ||||||
| 37 | 201 => 'Created', | ||||||
| 38 | 204 => 'No Content', | ||||||
| 39 | 301 => 'Moved Permanently', | ||||||
| 40 | 302 => 'Found', | ||||||
| 41 | 304 => 'Not Modified', | ||||||
| 42 | 400 => 'Bad Request', | ||||||
| 43 | 403 => 'Forbidden', | ||||||
| 44 | 404 => 'Not Found', | ||||||
| 45 | 405 => 'Method Not Allowed', | ||||||
| 46 | 413 => 'Request Entity Too Large', | ||||||
| 47 | 500 => 'Internal Server Error', | ||||||
| 48 | ); | ||||||
| 49 | |||||||
| 50 | ############################################################################### | ||||||
| 51 | # MIME type map | ||||||
| 52 | ############################################################################### | ||||||
| 53 | my %MIME = ( | ||||||
| 54 | 'html' => 'text/html; charset=utf-8', | ||||||
| 55 | 'htm' => 'text/html; charset=utf-8', | ||||||
| 56 | 'txt' => 'text/plain; charset=utf-8', | ||||||
| 57 | 'text' => 'text/plain; charset=utf-8', | ||||||
| 58 | 'css' => 'text/css', | ||||||
| 59 | 'js' => 'application/javascript', | ||||||
| 60 | 'json' => 'application/json', | ||||||
| 61 | 'xml' => 'application/xml', | ||||||
| 62 | 'png' => 'image/png', | ||||||
| 63 | 'jpg' => 'image/jpeg', | ||||||
| 64 | 'jpeg' => 'image/jpeg', | ||||||
| 65 | 'gif' => 'image/gif', | ||||||
| 66 | 'ico' => 'image/x-icon', | ||||||
| 67 | 'svg' => 'image/svg+xml', | ||||||
| 68 | 'pdf' => 'application/pdf', | ||||||
| 69 | 'zip' => 'application/zip', | ||||||
| 70 | 'gz' => 'application/gzip', | ||||||
| 71 | 'ltsv' => 'text/plain; charset=utf-8', | ||||||
| 72 | 'csv' => 'text/csv; charset=utf-8', | ||||||
| 73 | 'tsv' => 'text/tab-separated-values; charset=utf-8', | ||||||
| 74 | ); | ||||||
| 75 | |||||||
| 76 | # Default max POST body size: 10MB | ||||||
| 77 | my $DEFAULT_MAX_POST_SIZE = 10 * 1024 * 1024; | ||||||
| 78 | |||||||
| 79 | # Access log variables (package variables for testability; declared via use vars above) | ||||||
| 80 | $ACCESS_LOG_FH = undef; | ||||||
| 81 | $CURRENT_LOG_FILE = ''; | ||||||
| 82 | |||||||
| 83 | ############################################################################### | ||||||
| 84 | # run - Start the server (blocking) | ||||||
| 85 | ############################################################################### | ||||||
| 86 | sub run { | ||||||
| 87 | 0 | 0 | 1 | 0 | my ($class, %args) = @_; | ||
| 88 | |||||||
| 89 | 0 | 0 | 0 | my $app = $args{app} or croak "HTTP::Handy->run: 'app' is required"; | |||
| 90 | 0 | 0 | 0 | my $host = defined $args{host} ? $args{host} : '0.0.0.0'; | |||
| 91 | 0 | 0 | 0 | my $port = defined $args{port} ? $args{port} : 8080; | |||
| 92 | 0 | 0 | 0 | my $log = defined $args{log} ? $args{log} : 1; | |||
| 93 | 0 | 0 | 0 | my $max_post_size = defined $args{max_post_size} ? $args{max_post_size} : $DEFAULT_MAX_POST_SIZE; | |||
| 94 | |||||||
| 95 | 0 | 0 | 0 | ref($app) eq 'CODE' or croak "HTTP::Handy->run: 'app' must be a code reference"; | |||
| 96 | 0 | 0 | 0 | $port =~ /^\d+$/ or croak "HTTP::Handy->run: 'port' must be a number"; | |||
| 97 | 0 | 0 | 0 | $max_post_size =~ /^\d+$/ or croak "HTTP::Handy->run: 'max_post_size' must be a number"; | |||
| 98 | |||||||
| 99 | 0 | 0 | my $server = IO::Socket::INET->new( | ||||
| 100 | LocalAddr => $host, | ||||||
| 101 | LocalPort => $port, | ||||||
| 102 | Proto => 'tcp', | ||||||
| 103 | Listen => 10, | ||||||
| 104 | ReuseAddr => 1, | ||||||
| 105 | ); | ||||||
| 106 | 0 | 0 | 0 | unless ($server) { | |||
| 107 | 0 | 0 | croak "HTTP::Handy: Cannot bind to $host:$port - $@"; | ||||
| 108 | } | ||||||
| 109 | |||||||
| 110 | # Create Apache-like directories | ||||||
| 111 | 0 | 0 | _init_directories(); | ||||
| 112 | |||||||
| 113 | 0 | 0 | 0 | _log_message("HTTP::Handy $HTTP::Handy::VERSION started on http://$host:$port/") if $log; | |||
| 114 | 0 | 0 | 0 | _log_message("Press Ctrl+C to stop.") if $log; | |||
| 115 | |||||||
| 116 | 0 | 0 | while (1) { | ||||
| 117 | 0 | 0 | my $client = $server->accept; | ||||
| 118 | 0 | 0 | 0 | unless ($client) { | |||
| 119 | 0 | 0 | 0 | _log_message("Accept failed: $!") if $log; | |||
| 120 | 0 | 0 | next; | ||||
| 121 | } | ||||||
| 122 | |||||||
| 123 | # Disable CRLF translation on Windows | ||||||
| 124 | 0 | 0 | binmode $client; | ||||
| 125 | |||||||
| 126 | 0 | 0 | eval { | ||||
| 127 | 0 | 0 | _handle_connection($client, $app, $log, $max_post_size, $port); | ||||
| 128 | }; | ||||||
| 129 | 0 | 0 | 0 | if ($@) { | |||
| 130 | 0 | 0 | 0 | _log_message("Error handling connection: $@") if $log; | |||
| 131 | } | ||||||
| 132 | |||||||
| 133 | 0 | 0 | close $client; | ||||
| 134 | } | ||||||
| 135 | } | ||||||
| 136 | |||||||
| 137 | ############################################################################### | ||||||
| 138 | # _handle_connection - Parse request and dispatch to app | ||||||
| 139 | ############################################################################### | ||||||
| 140 | sub _handle_connection { | ||||||
| 141 | 0 | 0 | 0 | my ($client, $app, $log, $max_post_size, $server_port) = @_; | |||
| 142 | |||||||
| 143 | # Read request line | ||||||
| 144 | 0 | 0 | my $request_line = _read_line($client); | ||||
| 145 | 0 | 0 | 0 | 0 | return unless defined $request_line && $request_line ne ''; | ||
| 146 | |||||||
| 147 | 0 | 0 | $request_line =~ s/\r?\n$//; | ||||
| 148 | |||||||
| 149 | 0 | 0 | my ($method, $request_uri, $http_version) = split /\s+/, $request_line, 3; | ||||
| 150 | |||||||
| 151 | # Only allow GET and POST | ||||||
| 152 | 0 | 0 | 0 | 0 | unless (defined $method && ($method eq 'GET' || $method eq 'POST')) { | ||
| 0 | |||||||
| 153 | 0 | 0 | _send_error($client, 405, 'Method Not Allowed'); | ||||
| 154 | 0 | 0 | return; | ||||
| 155 | } | ||||||
| 156 | |||||||
| 157 | # Parse URI into path and query | ||||||
| 158 | 0 | 0 | my ($path, $query_string) = ('/', ''); | ||||
| 159 | 0 | 0 | 0 | if (defined $request_uri) { | |||
| 160 | 0 | 0 | 0 | if ($request_uri =~ /^([^?]*)\?(.*)$/) { | |||
| 161 | 0 | 0 | $path = $1; | ||||
| 162 | 0 | 0 | $query_string = $2; | ||||
| 163 | } | ||||||
| 164 | else { | ||||||
| 165 | 0 | 0 | $path = $request_uri; | ||||
| 166 | } | ||||||
| 167 | } | ||||||
| 168 | 0 | 0 | 0 | 0 | $path = '/' unless defined $path && $path ne ''; | ||
| 169 | |||||||
| 170 | # Read headers | ||||||
| 171 | 0 | 0 | my %headers; | ||||
| 172 | 0 | 0 | while (1) { | ||||
| 173 | 0 | 0 | my $line = _read_line($client); | ||||
| 174 | 0 | 0 | 0 | last unless defined $line; | |||
| 175 | 0 | 0 | $line =~ s/\r?\n$//; | ||||
| 176 | 0 | 0 | 0 | last if $line eq ''; | |||
| 177 | |||||||
| 178 | 0 | 0 | 0 | if ($line =~ /^([^:]+):\s*(.*)$/) { | |||
| 179 | 0 | 0 | my ($name, $value) = ($1, $2); | ||||
| 180 | # Normalize: lowercase, then convert to HTTP_* style | ||||||
| 181 | 0 | 0 | $name = lc $name; | ||||
| 182 | 0 | 0 | $headers{$name} = $value; | ||||
| 183 | } | ||||||
| 184 | } | ||||||
| 185 | |||||||
| 186 | # Build $env | ||||||
| 187 | 0 | 0 | 0 | my $server_name = $headers{'host'} || 'localhost'; | |||
| 188 | 0 | 0 | $server_name =~ s/:\d+$//; # strip port from Host header | ||||
| 189 | |||||||
| 190 | # SERVER_PORT: prefer the port from Host header if present, | ||||||
| 191 | # otherwise use the actual bound port passed from run(). | ||||||
| 192 | 0 | 0 | 0 | 0 | my $env_port = ($headers{'host'} && $headers{'host'} =~ /:(\d+)$/) | ||
| 193 | ? int($1) | ||||||
| 194 | : $server_port; | ||||||
| 195 | |||||||
| 196 | 0 | 0 | 0 | my $content_length = $headers{'content-length'} || 0; | |||
| 197 | 0 | 0 | $content_length = int($content_length); | ||||
| 198 | |||||||
| 199 | 0 | 0 | 0 | if ($content_length > $max_post_size) { | |||
| 200 | 0 | 0 | _send_error($client, 413, 'Request Entity Too Large'); | ||||
| 201 | 0 | 0 | return; | ||||
| 202 | } | ||||||
| 203 | |||||||
| 204 | # Read POST body | ||||||
| 205 | 0 | 0 | my $post_body = ''; | ||||
| 206 | 0 | 0 | 0 | 0 | if ($method eq 'POST' && $content_length > 0) { | ||
| 207 | 0 | 0 | read($client, $post_body, $content_length); | ||||
| 208 | } | ||||||
| 209 | |||||||
| 210 | # Build psgi.input as an in-memory filehandle | ||||||
| 211 | # For 5.5.3 compatibility, use a temp file approach via a simple object | ||||||
| 212 | 0 | 0 | my $input = HTTP::Handy::Input->new($post_body); | ||||
| 213 | |||||||
| 214 | my %env = ( | ||||||
| 215 | 'REQUEST_METHOD' => $method, | ||||||
| 216 | 'PATH_INFO' => $path, | ||||||
| 217 | 'QUERY_STRING' => $query_string, | ||||||
| 218 | 'SERVER_NAME' => $server_name, | ||||||
| 219 | 'SERVER_PORT' => $env_port, | ||||||
| 220 | 0 | 0 | 0 | 'CONTENT_TYPE' => $headers{'content-type'} || '', | |||
| 221 | 'CONTENT_LENGTH' => $content_length, | ||||||
| 222 | 'psgi.input' => $input, | ||||||
| 223 | 'psgi.errors' => \*STDERR, | ||||||
| 224 | 'psgi.url_scheme' => 'http', | ||||||
| 225 | ); | ||||||
| 226 | |||||||
| 227 | # Add HTTP_* headers | ||||||
| 228 | 0 | 0 | for my $name (keys %headers) { | ||||
| 229 | 0 | 0 | my $key = 'HTTP_' . uc($name); | ||||
| 230 | 0 | 0 | $key =~ s/-/_/g; | ||||
| 231 | 0 | 0 | $env{$key} = $headers{$name}; | ||||
| 232 | } | ||||||
| 233 | |||||||
| 234 | # Dispatch to app | ||||||
| 235 | 0 | 0 | my $response; | ||||
| 236 | 0 | 0 | eval { | ||||
| 237 | 0 | 0 | $response = $app->(\%env); | ||||
| 238 | }; | ||||||
| 239 | 0 | 0 | 0 | if ($@) { | |||
| 240 | 0 | 0 | my $err = $@; | ||||
| 241 | 0 | 0 | _log_message("App error: $err"); | ||||
| 242 | 0 | 0 | _send_error($client, 500, 'Internal Server Error'); | ||||
| 243 | 0 | 0 | return; | ||||
| 244 | } | ||||||
| 245 | |||||||
| 246 | # Validate response | ||||||
| 247 | 0 | 0 | 0 | 0 | unless (ref($response) eq 'ARRAY' && scalar(@$response) == 3) { | ||
| 248 | 0 | 0 | _send_error($client, 500, 'Internal Server Error'); | ||||
| 249 | 0 | 0 | return; | ||||
| 250 | } | ||||||
| 251 | |||||||
| 252 | 0 | 0 | my ($status, $resp_headers, $body) = @$response; | ||||
| 253 | |||||||
| 254 | # Send response | ||||||
| 255 | 0 | 0 | 0 | my $status_text = $STATUS_TEXT{$status} || 'Unknown'; | |||
| 256 | 0 | 0 | my $response_str = "HTTP/1.0 $status $status_text\r\n"; | ||||
| 257 | 0 | 0 | $response_str .= "Connection: close\r\n"; | ||||
| 258 | |||||||
| 259 | # Process response headers (flat array: key, value, key, value, ...) | ||||||
| 260 | 0 | 0 | my @header_list; | ||||
| 261 | 0 | 0 | 0 | if (ref($resp_headers) eq 'ARRAY') { | |||
| 262 | 0 | 0 | my @h = @$resp_headers; | ||||
| 263 | 0 | 0 | while (@h) { | ||||
| 264 | 0 | 0 | 0 | my $k = shift(@h) || ''; | |||
| 265 | 0 | 0 | 0 | my $v = shift(@h) || ''; | |||
| 266 | 0 | 0 | push @header_list, "$k: $v"; | ||||
| 267 | } | ||||||
| 268 | } | ||||||
| 269 | 0 | 0 | 0 | $response_str .= join("\r\n", @header_list) . "\r\n" if @header_list; | |||
| 270 | 0 | 0 | $response_str .= "\r\n"; | ||||
| 271 | |||||||
| 272 | # Build body | ||||||
| 273 | 0 | 0 | my $body_str = ''; | ||||
| 274 | 0 | 0 | 0 | if (ref($body) eq 'ARRAY') { | |||
| 275 | 0 | 0 | 0 | $body_str = join('', @$body) if @$body; | |||
| 276 | } | ||||||
| 277 | |||||||
| 278 | 0 | 0 | my $body_length = length($body_str); | ||||
| 279 | 0 | 0 | $response_str .= $body_str; | ||||
| 280 | |||||||
| 281 | 0 | 0 | print $client $response_str; | ||||
| 282 | |||||||
| 283 | # Access log in LTSV format. | ||||||
| 284 | # Sanitize field values: LTSV forbids tab and newline characters in values. | ||||||
| 285 | 0 | 0 | 0 | if ($log) { | |||
| 286 | 0 | 0 | my $ts = _iso_time(); | ||||
| 287 | 0 | 0 | 0 | my $ua = $headers{'user-agent'} || ''; | |||
| 288 | 0 | 0 | 0 | my $referer = $headers{'referer'} || ''; | |||
| 289 | 0 | 0 | $ua =~ s/[\t\n\r]/ /g; | ||||
| 290 | 0 | 0 | $referer =~ s/[\t\n\r]/ /g; | ||||
| 291 | 0 | 0 | my $line = join("\t", | ||||
| 292 | "time:$ts", | ||||||
| 293 | "method:$method", | ||||||
| 294 | "path:$path", | ||||||
| 295 | "status:$status", | ||||||
| 296 | "size:$body_length", | ||||||
| 297 | "ua:$ua", | ||||||
| 298 | "referer:$referer", | ||||||
| 299 | ) . "\n"; | ||||||
| 300 | 0 | 0 | print STDERR $line; | ||||
| 301 | |||||||
| 302 | 0 | 0 | _open_access_log(); | ||||
| 303 | 0 | 0 | 0 | print $ACCESS_LOG_FH $line if $ACCESS_LOG_FH; | |||
| 304 | } | ||||||
| 305 | } | ||||||
| 306 | |||||||
| 307 | ############################################################################### | ||||||
| 308 | # _read_line - Read one line from socket (CR+LF or LF terminated) | ||||||
| 309 | ############################################################################### | ||||||
| 310 | sub _read_line { | ||||||
| 311 | 0 | 0 | 0 | my ($fh) = @_; | |||
| 312 | 0 | 0 | my $line = ''; | ||||
| 313 | 0 | 0 | my $char; | ||||
| 314 | 0 | 0 | while (read($fh, $char, 1)) { | ||||
| 315 | 0 | 0 | $line .= $char; | ||||
| 316 | 0 | 0 | 0 | last if $char eq "\n"; | |||
| 317 | # Safety limit: no header line should exceed 8KB | ||||||
| 318 | 0 | 0 | 0 | return undef if length($line) > 8192; | |||
| 319 | } | ||||||
| 320 | 0 | 0 | 0 | return $line eq '' ? undef : $line; | |||
| 321 | } | ||||||
| 322 | |||||||
| 323 | ############################################################################### | ||||||
| 324 | # _send_error - Send a simple HTTP error response | ||||||
| 325 | ############################################################################### | ||||||
| 326 | sub _send_error { | ||||||
| 327 | 0 | 0 | 0 | my ($client, $code, $message) = @_; | |||
| 328 | 0 | 0 | 0 | my $text = $STATUS_TEXT{$code} || $message; | |||
| 329 | 0 | 0 | my $body = " |
||||
| 330 | . "$code $text$message " |
||||||
| 331 | . " HTTP::Handy/$HTTP::Handy::VERSION"; |
||||||
| 332 | 0 | 0 | print $client "HTTP/1.0 $code $text\r\n"; | ||||
| 333 | 0 | 0 | print $client "Content-Type: text/html\r\n"; | ||||
| 334 | 0 | 0 | print $client "Content-Length: " . length($body) . "\r\n"; | ||||
| 335 | 0 | 0 | print $client "Connection: close\r\n"; | ||||
| 336 | 0 | 0 | print $client "\r\n"; | ||||
| 337 | 0 | 0 | print $client $body; | ||||
| 338 | } | ||||||
| 339 | |||||||
| 340 | ############################################################################### | ||||||
| 341 | # _log_message - Print timestamped log to STDERR | ||||||
| 342 | ############################################################################### | ||||||
| 343 | sub _log_message { | ||||||
| 344 | 2 | 2 | 580 | my ($msg) = @_; | |||
| 345 | |||||||
| 346 | 2 | 16 | my $ts = _iso_time(); | ||||
| 347 | 2 | 14 | print STDERR "[$ts] $msg\n"; | ||||
| 348 | |||||||
| 349 | 2 | 15 | my $fh; | ||||
| 350 | 2 | 50 | 8 | if ($] >= 5.006) { | |||
| 351 | # Avoid "Too many arguments for open at" error when running with Perl 5.005_03 | ||||||
| 352 | 2 | 50 | 190 | eval q{ open($fh, '>>', 'logs/error/error.log') } or return; | |||
| 353 | } | ||||||
| 354 | else { | ||||||
| 355 | 0 | 0 | $fh = \do { local *_ }; | ||||
| 0 | 0 | ||||||
| 356 | 0 | 0 | 0 | open($fh, ">> logs/error/error.log") or return; | |||
| 357 | } | ||||||
| 358 | 2 | 12 | binmode $fh; | ||||
| 359 | |||||||
| 360 | 2 | 20 | print $fh "[$ts] $msg\n"; | ||||
| 361 | 2 | 98 | close $fh; | ||||
| 362 | } | ||||||
| 363 | |||||||
| 364 | ############################################################################### | ||||||
| 365 | # serve_static - Serve files from a document root | ||||||
| 366 | ############################################################################### | ||||||
| 367 | sub serve_static { | ||||||
| 368 | 20 | 20 | 1 | 212321 | my ($class, $env, $docroot, %opts) = @_; | ||
| 369 | |||||||
| 370 | 20 | 100 | 108 | $docroot ||= '.'; | |||
| 371 | # Remove trailing slash | ||||||
| 372 | 20 | 98 | $docroot =~ s{[/\\]$}{}; | ||||
| 373 | |||||||
| 374 | 20 | 50 | 74 | my $path = $env->{PATH_INFO} || '/'; | |||
| 375 | |||||||
| 376 | # Prevent path traversal via ".." | ||||||
| 377 | 20 | 100 | 94 | if ($path =~ /\.\./) { | |||
| 378 | 3 | 21 | return [403, ['Content-Type', 'text/plain'], ['Forbidden']]; | ||||
| 379 | } | ||||||
| 380 | |||||||
| 381 | # Normalize separators on Windows | ||||||
| 382 | 17 | 35 | $path =~ s{\\}{/}g; | ||||
| 383 | |||||||
| 384 | # Strip leading slashes to prevent absolute path injection | ||||||
| 385 | 17 | 163 | $path =~ s{^/+}{/}; | ||||
| 386 | |||||||
| 387 | 17 | 35 | my $file = $docroot . $path; | ||||
| 388 | |||||||
| 389 | # Directory: try index.html | ||||||
| 390 | 17 | 100 | 361 | if (-d $file) { | |||
| 391 | 2 | 13 | $file =~ s{/?$}{/index.html}; | ||||
| 392 | } | ||||||
| 393 | |||||||
| 394 | 17 | 100 | 139 | unless (-f $file) { | |||
| 395 | 2 | 16 | return [404, ['Content-Type', 'text/plain'], ['Not Found']]; | ||||
| 396 | } | ||||||
| 397 | |||||||
| 398 | # Determine MIME type from extension | ||||||
| 399 | 15 | 53 | my $ext = ''; | ||||
| 400 | 15 | 50 | 91 | if ($file =~ /\.([^.]+)$/) { | |||
| 401 | 15 | 64 | $ext = lc $1; | ||||
| 402 | } | ||||||
| 403 | 15 | 100 | 115 | my $mime = $MIME{$ext} || 'application/octet-stream'; | |||
| 404 | |||||||
| 405 | # Read file | ||||||
| 406 | 15 | 21 | my $fh; | ||||
| 407 | 15 | 50 | 42 | if ($] >= 5.006) { | |||
| 408 | # Avoid "Too many arguments for open at" error when running with Perl 5.005_03 | ||||||
| 409 | 15 | 50 | 1440 | unless (eval q{ open($fh, '<', $file) }) { | |||
| 410 | 0 | 0 | return [403, ['Content-Type', 'text/plain'], ['Forbidden']]; | ||||
| 411 | } | ||||||
| 412 | } | ||||||
| 413 | else { | ||||||
| 414 | 0 | 0 | $fh = \do { local *_ }; | ||||
| 0 | 0 | ||||||
| 415 | 0 | 0 | 0 | unless (open($fh, "<$file")) { | |||
| 416 | 0 | 0 | return [403, ['Content-Type', 'text/plain'], ['Forbidden']]; | ||||
| 417 | } | ||||||
| 418 | } | ||||||
| 419 | 15 | 113 | binmode $fh; | ||||
| 420 | 15 | 68 | local $/; | ||||
| 421 | 15 | 405 | my $content = <$fh>; | ||||
| 422 | 15 | 172 | close $fh; | ||||
| 423 | |||||||
| 424 | # Cache-Control header | ||||||
| 425 | 15 | 26 | my @cache_headers; | ||||
| 426 | 15 | 100 | 47 | if (exists $opts{cache_max_age}) { | |||
| 427 | 3 | 13 | my $age = int($opts{cache_max_age}); | ||||
| 428 | 3 | 100 | 12 | if ($age > 0) { | |||
| 429 | 2 | 12 | push @cache_headers, 'Cache-Control', "public, max-age=$age"; | ||||
| 430 | } | ||||||
| 431 | else { | ||||||
| 432 | 1 | 8 | push @cache_headers, 'Cache-Control', 'no-cache'; | ||||
| 433 | } | ||||||
| 434 | } | ||||||
| 435 | else { | ||||||
| 436 | # Default: no-cache (safe for development use) | ||||||
| 437 | 12 | 28 | push @cache_headers, 'Cache-Control', 'no-cache'; | ||||
| 438 | } | ||||||
| 439 | |||||||
| 440 | 15 | 210 | return [200, | ||||
| 441 | ['Content-Type', $mime, | ||||||
| 442 | 'Content-Length', length($content), | ||||||
| 443 | @cache_headers], | ||||||
| 444 | [$content]]; | ||||||
| 445 | } | ||||||
| 446 | |||||||
| 447 | ############################################################################### | ||||||
| 448 | # url_decode - Decode percent-encoded string | ||||||
| 449 | ############################################################################### | ||||||
| 450 | sub url_decode { | ||||||
| 451 | 29 | 29 | 1 | 124967 | my ($class, $str) = @_; | ||
| 452 | 29 | 100 | 37 | return '' unless defined $str; | |||
| 453 | 28 | 26 | $str =~ s/\+/ /g; | ||||
| 454 | 28 | 37 | $str =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg; | ||||
| 10 | 19 | ||||||
| 455 | 28 | 66 | return $str; | ||||
| 456 | } | ||||||
| 457 | |||||||
| 458 | ############################################################################### | ||||||
| 459 | # parse_query - Parse query string into hash | ||||||
| 460 | ############################################################################### | ||||||
| 461 | sub parse_query { | ||||||
| 462 | 8 | 8 | 1 | 77 | my ($class, $query) = @_; | ||
| 463 | 8 | 100 | 100 | 23 | return () unless defined $query && $query ne ''; | ||
| 464 | 6 | 6 | my %params; | ||||
| 465 | 6 | 9 | for my $pair (split /&/, $query) { | ||||
| 466 | 10 | 15 | my ($key, $val) = split /=/, $pair, 2; | ||||
| 467 | 10 | 50 | 14 | next unless defined $key; | |||
| 468 | 10 | 12 | $key = $class->url_decode($key); | ||||
| 469 | 10 | 100 | 15 | $val = defined $val ? $class->url_decode($val) : ''; | |||
| 470 | 10 | 100 | 12 | if (exists $params{$key}) { | |||
| 471 | 2 | 100 | 9 | if (ref $params{$key} eq 'ARRAY') { | |||
| 472 | 1 | 2 | push @{$params{$key}}, $val; | ||||
| 1 | 3 | ||||||
| 473 | } | ||||||
| 474 | else { | ||||||
| 475 | 1 | 3 | $params{$key} = [$params{$key}, $val]; | ||||
| 476 | } | ||||||
| 477 | } | ||||||
| 478 | else { | ||||||
| 479 | 8 | 13 | $params{$key} = $val; | ||||
| 480 | } | ||||||
| 481 | } | ||||||
| 482 | 6 | 17 | return %params; | ||||
| 483 | } | ||||||
| 484 | |||||||
| 485 | ############################################################################### | ||||||
| 486 | # mime_type - Return MIME type for a file extension | ||||||
| 487 | ############################################################################### | ||||||
| 488 | sub mime_type { | ||||||
| 489 | 11 | 11 | 1 | 20 | my ($class, $ext) = @_; | ||
| 490 | 11 | 16 | $ext = lc $ext; | ||||
| 491 | 11 | 17 | $ext =~ s/^\.//; | ||||
| 492 | 11 | 100 | 59 | return $MIME{$ext} || 'application/octet-stream'; | |||
| 493 | } | ||||||
| 494 | |||||||
| 495 | ############################################################################### | ||||||
| 496 | # is_htmx - Return true if the request was made by htmx | ||||||
| 497 | ############################################################################### | ||||||
| 498 | sub is_htmx { | ||||||
| 499 | 5 | 5 | 1 | 10 | my ($class, $env) = @_; | ||
| 500 | 5 | 100 | 100 | 15 | return (defined $env->{HTTP_HX_REQUEST} && $env->{HTTP_HX_REQUEST} eq 'true') ? 1 : 0; | ||
| 501 | } | ||||||
| 502 | |||||||
| 503 | ############################################################################### | ||||||
| 504 | # response_redirect - Build a redirect response | ||||||
| 505 | ############################################################################### | ||||||
| 506 | sub response_redirect { | ||||||
| 507 | 2 | 2 | 1 | 40 | my ($class, $location, $code) = @_; | ||
| 508 | 2 | 100 | 5 | $code ||= 302; | |||
| 509 | 2 | 7 | return [$code, | ||||
| 510 | ['Location', $location, | ||||||
| 511 | 'Content-Type', 'text/plain'], | ||||||
| 512 | ["Redirect to $location"]]; | ||||||
| 513 | } | ||||||
| 514 | |||||||
| 515 | ############################################################################### | ||||||
| 516 | # response_json - Build a JSON response (no JSON encoding, caller provides) | ||||||
| 517 | ############################################################################### | ||||||
| 518 | sub response_json { | ||||||
| 519 | 1 | 1 | 1 | 26 | my ($class, $json_str, $code) = @_; | ||
| 520 | 1 | 50 | 7 | $code ||= 200; | |||
| 521 | 1 | 3 | return [$code, | ||||
| 522 | ['Content-Type', 'application/json', | ||||||
| 523 | 'Content-Length', length($json_str)], | ||||||
| 524 | [$json_str]]; | ||||||
| 525 | } | ||||||
| 526 | |||||||
| 527 | ############################################################################### | ||||||
| 528 | # response_html - Build an HTML response | ||||||
| 529 | ############################################################################### | ||||||
| 530 | sub response_html { | ||||||
| 531 | 3 | 3 | 1 | 52 | my ($class, $html, $code) = @_; | ||
| 532 | 3 | 100 | 9 | $code ||= 200; | |||
| 533 | 3 | 10 | return [$code, | ||||
| 534 | ['Content-Type', 'text/html; charset=utf-8', | ||||||
| 535 | 'Content-Length', length($html)], | ||||||
| 536 | [$html]]; | ||||||
| 537 | } | ||||||
| 538 | |||||||
| 539 | ############################################################################### | ||||||
| 540 | # response_text - Build a plain text response | ||||||
| 541 | ############################################################################### | ||||||
| 542 | sub response_text { | ||||||
| 543 | 1 | 1 | 1 | 7 | my ($class, $text, $code) = @_; | ||
| 544 | 1 | 50 | 6 | $code ||= 200; | |||
| 545 | 1 | 3 | return [$code, | ||||
| 546 | ['Content-Type', 'text/plain; charset=utf-8', | ||||||
| 547 | 'Content-Length', length($text)], | ||||||
| 548 | [$text]]; | ||||||
| 549 | } | ||||||
| 550 | |||||||
| 551 | ############################################################################### | ||||||
| 552 | # _init_directories - Create Apache-like directories | ||||||
| 553 | ############################################################################### | ||||||
| 554 | sub _init_directories { | ||||||
| 555 | 3 | 3 | 6431 | for my $dir (qw( | |||
| 556 | logs | ||||||
| 557 | logs/access | ||||||
| 558 | logs/error | ||||||
| 559 | run | ||||||
| 560 | htdocs | ||||||
| 561 | conf | ||||||
| 562 | )) { | ||||||
| 563 | 18 | 100 | 903 | mkdir($dir, 0777) unless -d $dir; | |||
| 564 | } | ||||||
| 565 | } | ||||||
| 566 | |||||||
| 567 | ############################################################################### | ||||||
| 568 | # _open_access_log - Opens the access log file (YYYYMMDDHHm0.log.ltsv format) | ||||||
| 569 | ############################################################################### | ||||||
| 570 | sub _open_access_log { | ||||||
| 571 | 2 | 2 | 164 | my ($year, $month, $day, $hour, $min) = (localtime)[5, 4, 3, 2, 1]; | |||
| 572 | 2 | 25 | my $current_log_filename = sprintf("logs/access/%04d%02d%02d%02d%02d.log.ltsv", | ||||
| 573 | 1900 + $year, | ||||||
| 574 | $month + 1, | ||||||
| 575 | $day, | ||||||
| 576 | $hour, | ||||||
| 577 | int($min / 10) * 10, | ||||||
| 578 | ); | ||||||
| 579 | |||||||
| 580 | 2 | 100 | 66 | 32 | return if defined $ACCESS_LOG_FH && $current_log_filename eq $CURRENT_LOG_FILE; | ||
| 581 | |||||||
| 582 | 1 | 50 | 15 | if ($ACCESS_LOG_FH) { | |||
| 583 | 0 | 0 | close $ACCESS_LOG_FH; | ||||
| 584 | } | ||||||
| 585 | |||||||
| 586 | 1 | 4 | my $fh; | ||||
| 587 | 1 | 50 | 10 | if ($] >= 5.006) { | |||
| 588 | # Avoid "Too many arguments for open at" error when running with Perl 5.005_03 | ||||||
| 589 | 1 | 50 | 224 | eval q{ open($fh, '>>', $current_log_filename) } or do { | |||
| 590 | 0 | 0 | warn "Cannot open access log: $current_log_filename: $!"; | ||||
| 591 | 0 | 0 | return; | ||||
| 592 | }; | ||||||
| 593 | } | ||||||
| 594 | else { | ||||||
| 595 | 0 | 0 | $fh = \do { local *_ }; | ||||
| 0 | 0 | ||||||
| 596 | 0 | 0 | 0 | open($fh, ">> $current_log_filename") or do { | |||
| 597 | 0 | 0 | warn "Cannot open access log: $current_log_filename: $!"; | ||||
| 598 | 0 | 0 | return; | ||||
| 599 | }; | ||||||
| 600 | } | ||||||
| 601 | 1 | 9 | binmode $fh; | ||||
| 602 | |||||||
| 603 | 1 | 19 | select((select($fh), $| = 1)[0]); # autoflush | ||||
| 604 | |||||||
| 605 | 1 | 21 | $ACCESS_LOG_FH = $fh; | ||||
| 606 | 1 | 12 | $CURRENT_LOG_FILE = $current_log_filename; | ||||
| 607 | } | ||||||
| 608 | |||||||
| 609 | ############################################################################### | ||||||
| 610 | # _iso_time - Returns localtime as ISO style (YYYY-MM-DDTHH:mm:SS) | ||||||
| 611 | ############################################################################### | ||||||
| 612 | sub _iso_time { | ||||||
| 613 | 7 | 7 | 189866 | my ($year, $month, $day, $hour, $min, $sec) = (localtime)[5, 4, 3, 2, 1, 0]; | |||
| 614 | 7 | 58 | return sprintf("%04d-%02d-%02dT%02d:%02d:%02d", | ||||
| 615 | 1900 + $year, | ||||||
| 616 | $month + 1, | ||||||
| 617 | $day, | ||||||
| 618 | $hour, | ||||||
| 619 | $min, | ||||||
| 620 | $sec, | ||||||
| 621 | ); | ||||||
| 622 | } | ||||||
| 623 | |||||||
| 624 | # ---------------------------------------------------------------- | ||||||
| 625 | # HTTP::Handy::Input - Minimal in-memory filehandle for psgi.input | ||||||
| 626 | # Compatible with Perl 5.5.3 (no open with scalar ref) | ||||||
| 627 | # ---------------------------------------------------------------- | ||||||
| 628 | package HTTP::Handy::Input; | ||||||
| 629 | |||||||
| 630 | sub new { | ||||||
| 631 | 2 | 2 | 40 | my ($class, $data) = @_; | |||
| 632 | 2 | 50 | 4 | $data = '' unless defined $data; | |||
| 633 | 2 | 7 | return bless { data => $data, pos => 0 }, $class; | ||||
| 634 | } | ||||||
| 635 | |||||||
| 636 | sub read { | ||||||
| 637 | # Note: $_[1] is the caller's buffer variable -- modified in place. | ||||||
| 638 | # We do NOT include it in the my() list because: | ||||||
| 639 | # (a) we must write back via $_[1], not a copy, and | ||||||
| 640 | # (b) "my ($self, undef, $length)" requires Perl 5.10+. | ||||||
| 641 | 2 | 2 | 8 | my $self = $_[0]; | |||
| 642 | 2 | 7 | my $length = $_[2]; | ||||
| 643 | 2 | 50 | 6 | my $offset = $_[3] || 0; | |||
| 644 | 2 | 11 | my $remaining = length($self->{data}) - $self->{pos}; | ||||
| 645 | 2 | 100 | 5 | $length = $remaining if $length > $remaining; | |||
| 646 | 2 | 100 | 4 | return 0 if $length <= 0; | |||
| 647 | 1 | 2 | my $chunk = substr($self->{data}, $self->{pos}, $length); | ||||
| 648 | 1 | 2 | $self->{pos} += $length; | ||||
| 649 | # Write into $_[1] at $offset (like POSIX read) | ||||||
| 650 | 1 | 2 | substr($_[1], $offset) = $chunk; | ||||
| 651 | 1 | 2 | return $length; | ||||
| 652 | } | ||||||
| 653 | |||||||
| 654 | sub seek { | ||||||
| 655 | 2 | 2 | 7 | my ($self, $pos, $whence) = @_; | |||
| 656 | 2 | 50 | 10 | $whence ||= 0; | |||
| 657 | 2 | 50 | 3 | if ($whence == 0) { | |||
| 0 | |||||||
| 0 | |||||||
| 658 | 2 | 3 | $self->{pos} = $pos; | ||||
| 659 | } | ||||||
| 660 | elsif ($whence == 1) { | ||||||
| 661 | 0 | 0 | $self->{pos} += $pos; | ||||
| 662 | } | ||||||
| 663 | elsif ($whence == 2) { | ||||||
| 664 | 0 | 0 | $self->{pos} = length($self->{data}) + $pos; | ||||
| 665 | } | ||||||
| 666 | 2 | 50 | 3 | $self->{pos} = 0 if $self->{pos} < 0; | |||
| 667 | 2 | 4 | return 1; | ||||
| 668 | } | ||||||
| 669 | |||||||
| 670 | sub tell { | ||||||
| 671 | 2 | 2 | 12 | my ($self) = @_; | |||
| 672 | 2 | 5 | return $self->{pos}; | ||||
| 673 | } | ||||||
| 674 | |||||||
| 675 | sub getline { | ||||||
| 676 | 7 | 7 | 20 | my ($self) = @_; | |||
| 677 | 7 | 100 | 15 | return undef if $self->{pos} >= length($self->{data}); | |||
| 678 | 4 | 6 | my $nl = index($self->{data}, "\n", $self->{pos}); | ||||
| 679 | 4 | 3 | my $line; | ||||
| 680 | 4 | 50 | 6 | if ($nl < 0) { | |||
| 681 | 0 | 0 | $line = substr($self->{data}, $self->{pos}); | ||||
| 682 | 0 | 0 | $self->{pos} = length($self->{data}); | ||||
| 683 | } | ||||||
| 684 | else { | ||||||
| 685 | 4 | 6 | $line = substr($self->{data}, $self->{pos}, $nl - $self->{pos} + 1); | ||||
| 686 | 4 | 8 | $self->{pos} = $nl + 1; | ||||
| 687 | } | ||||||
| 688 | 4 | 8 | return $line; | ||||
| 689 | } | ||||||
| 690 | |||||||
| 691 | sub getlines { | ||||||
| 692 | 1 | 1 | 3 | my ($self) = @_; | |||
| 693 | 1 | 3 | my @lines; | ||||
| 694 | 1 | 2 | while (defined(my $line = $self->getline)) { | ||||
| 695 | 2 | 3 | push @lines, $line; | ||||
| 696 | } | ||||||
| 697 | 1 | 3 | return @lines; | ||||
| 698 | } | ||||||
| 699 | |||||||
| 700 | ############################################################################### | ||||||
| 701 | # Back to main package -- demo/self-test when run directly | ||||||
| 702 | ############################################################################### | ||||||
| 703 | package HTTP::Handy; | ||||||
| 704 | |||||||
| 705 | # Run as script: perl lib/HTTP/Handy.pm [port] | ||||||
| 706 | unless (caller) { | ||||||
| 707 | my $port = $ARGV[0] || 8080; | ||||||
| 708 | |||||||
| 709 | my $demo_app = sub { | ||||||
| 710 | my $env = shift; | ||||||
| 711 | my $method = $env->{REQUEST_METHOD}; | ||||||
| 712 | my $path = $env->{PATH_INFO}; | ||||||
| 713 | my $query = $env->{QUERY_STRING}; | ||||||
| 714 | |||||||
| 715 | # Route: GET / | ||||||
| 716 | if ($method eq 'GET' && $path eq '/') { | ||||||
| 717 | my $html = <<'HTML'; | ||||||
| 718 | |||||||
| 719 | |||||||
| 720 | |
||||||
| 721 | |||||||
| 729 | |||||||
| 730 | |||||||
| 731 | HTTP::Handy Demo |
||||||
| 732 | A tiny HTTP/1.0 server running on Perl 5.5.3+. |
||||||
| 733 | GET with query string |
||||||
| 734 | |||||||
| 735 | |||||||
| 736 | |||||||
| 737 | |||||||
| 738 | POST form |
||||||
| 739 | |||||||
| 740 | |||||||
| 741 | |||||||
| 742 | |||||||
| 743 | |||||||
| 744 | |||||||
| 745 | |||||||
| 746 | |||||||
| 747 | HTML | ||||||
| 748 | return HTTP::Handy->response_html($html); | ||||||
| 749 | } | ||||||
| 750 | |||||||
| 751 | # Route: GET or POST /echo | ||||||
| 752 | if ($path eq '/echo') { | ||||||
| 753 | my %params; | ||||||
| 754 | if ($method eq 'GET') { | ||||||
| 755 | %params = HTTP::Handy->parse_query($query); | ||||||
| 756 | } | ||||||
| 757 | elsif ($method eq 'POST') { | ||||||
| 758 | my $body = ''; | ||||||
| 759 | $env->{'psgi.input'}->read($body, $env->{CONTENT_LENGTH} || 0); | ||||||
| 760 | %params = HTTP::Handy->parse_query($body); | ||||||
| 761 | } | ||||||
| 762 | |||||||
| 763 | my $params_html = ''; | ||||||
| 764 | for my $key (sort keys %params) { | ||||||
| 765 | my $val = $params{$key}; | ||||||
| 766 | $val = ref($val) eq 'ARRAY' ? join(', ', @$val) : $val; | ||||||
| 767 | # simple HTML escape | ||||||
| 768 | $val =~ s/&/&/g; | ||||||
| 769 | $val =~ s/</g; | ||||||
| 770 | $val =~ s/>/>/g; | ||||||
| 771 | $key =~ s/&/&/g; | ||||||
| 772 | $key =~ s/</g; | ||||||
| 773 | $params_html .= " | ||||||
| $key | $val | ||||||
| 774 | } | ||||||
| 775 | $params_html ||= ' | ||||||
| (no parameters) | |||||||
| 776 | |||||||
| 777 | my $html = <<"HTML"; | ||||||
| 778 | |||||||
| 779 | |
||||||
| 780 | |||||||
| 783 | |||||||
| 784 | Echo: $method $path |
||||||
| 785 | |
||||||
| 786 | |||||||
| 787 | |||||||
| 788 | HTML | ||||||
| 789 | return HTTP::Handy->response_html($html); | ||||||
| 790 | } | ||||||
| 791 | |||||||
| 792 | # Route: GET /info | ||||||
| 793 | if ($method eq 'GET' && $path eq '/info') { | ||||||
| 794 | my $env_html = ''; | ||||||
| 795 | for my $key (sort keys %$env) { | ||||||
| 796 | next if $key eq 'psgi.input' || $key eq 'psgi.errors'; | ||||||
| 797 | my $val = $env->{$key}; | ||||||
| 798 | $val = '' unless defined $val; | ||||||
| 799 | $val =~ s/&/&/g; | ||||||
| 800 | $val =~ s/</g; | ||||||
| 801 | $env_html .= " | ||||||
$key | $val | ||||||
| 802 | } | ||||||
| 803 | my $html = <<"HTML"; | ||||||
| 804 | |||||||
| 805 | |
||||||
| 806 | |||||||
| 810 | |||||||
| 811 | PSGI Environment |
||||||
| 812 | |
||||||
| 813 | |||||||
| 814 | |||||||
| 815 | HTML | ||||||
| 816 | return HTTP::Handy->response_html($html); | ||||||
| 817 | } | ||||||
| 818 | |||||||
| 819 | # 404 fallback | ||||||
| 820 | return [404, | ||||||
| 821 | ['Content-Type', 'text/html'], | ||||||
| 822 | ["404 Not Found$path Home"]]; |
||||||
| 823 | }; | ||||||
| 824 | |||||||
| 825 | HTTP::Handy->run(app => $demo_app, port => $port); | ||||||
| 826 | } | ||||||
| 827 | |||||||
| 828 | 1; | ||||||
| 829 | |||||||
| 830 | __END__ | ||||||