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