File Coverage

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 = "$code $text"
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             HTTP::Handy Demo
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            

Server info

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/
756             $val =~ s/>/>/g;
757             $key =~ s/&/&/g;
758             $key =~ s/
759             $params_html .= "
$key$val
760             }
761             $params_html ||= '
(no parameters)
762              
763             my $html = <<"HTML";
764            
765             Echo
766            
769            
770            

Echo: $method $path

771             $params_html
772            

Back

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/
787             $env_html .= "
$key$val
788             }
789             my $html = <<"HTML";
790            
791             Server Info
792            
796            
797            

PSGI Environment

798             $env_html
799            

Back

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__