#!/usr/local/bin/perl
# listen v.200503171012-perl
# A standalone HTTP Wiki server.
# Released without warranty and without restriction, except that the author(s) should
# receive appropriate credit for any derivative products.
#
# Doesn't use a database, isn't multithreaded, isn't documented, is written in perl.
#
# Revision history:
# 200503122337 Created 2005.03.11-03.12 by rjmccall@gmail.com
#
# 200503132359 Incorporated patch from jcreed@andrew.cmu.edu.
# Added actions, cleaned up APIs, allow for a configuration file.
#
# 200503171012 Incorporated patch from jcreed.
# Support hosting binaries, sidebars.
#
# 200503190156 Sidebar jiggering, again based on research by jcreed.
# The configuration file is a series of key/value pairs, one per line (though
# values may be arbitrarily complex. Here are the currently-supported commands:
# Directory path
# Provides the root database directory.
# Alias alias_path true_path
# States that the resource 'alias_path' can actually be found at 'true_path'.
# Edit.editbox.dimensions COLS x ROWS
# Provides explicit dimensions for the edit box.
# Title title string here
# Titles the wiki.
# Edit.links
# Fetch.links
# List.links
# Provide a list of the automatic links to make available on each of these pages.
# Supportered link generators are:
# edit - edit the current resource
# list - list the contents of the directory containing the current resource
# main - jump to the root page
# StyleSheet file
# Use the given stylesheet file instead of the default inclusion. The file must
# be given in remote form (as in, a file that the browser could reasonably fetch).
# LinksAtTop true|false
# LinksAtBottom true|false
# States whether link sections should be created at the top/bottom of the page.
# Configuration files are processed at the moment they are encountered on the command
# line; they override earlier arguments and may be themselves overriden by later
# arguments.
use Fcntl ':flock';
use Socket;
use strict;
# default configuration details
my $listen_port = 80; # TCP port on which to listen
my $database_directory = "."; # root directory of the data repository
my $server_title = "listen"; # title of the server, used in the HTML title
my $main_page_link = "Main page"; # text for the link to the main page
my $list_link = "List other pages here";# text for the link to the directory list
my $edit_link = "Edit this page"; # text for the link to the editor
my $edit_box_width = undef; # width of the edit box, in columns
my $edit_box_height = undef; # height of the edit box, in lines (if these aren't both defined, uses a proportional scheme)
my $directory_page = "Main"; # page to display when fetching a directory
my $style_file = undef; # style file to reference; if not defined, directly insert $default_style
my $html_links_on_top = 0; # display standard links on the top of the page
my $html_links_on_bottom = 1; # display standard links on the bottom of the page
my $html_insert_page_title = 1; # display the page title at the top of the page
my $default_style = ""; # the default style text; initialized in init()
# content_fetch_filter: (resource -> string) option
my $content_fetch_filter = \&content_fetch_filter;
# init: unit -> unit
sub init
{
set_handler( "get", \&handle_get );
set_handler( "head", \&handle_get );
set_handler( "post", \&handle_post );
my $submit_action = create_action( "submit" );
set_content_handler( $submit_action, \&handle_content_submit );
set_resource_path_processor( $submit_action, \&get_true_path_to_resource );
my $edit_action = create_action( "edit" );
set_content_handler( $edit_action, \&handle_content_edit );
set_link_generator_names( $edit_action, "main", "list" );
set_resource_path_processor( $edit_action, \&get_true_path_to_resource );
my $list_action = create_action( "list" );
set_content_handler( $list_action, \&handle_content_list );
set_link_generator_names( $list_action, "main", "list" );
set_resource_path_processor( $list_action, \&get_true_path );
my $fetch_action = create_action( "fetch" );
set_content_handler( $fetch_action, \&handle_content_fetch );
set_link_generator_names( $fetch_action, "edit", "main", "list" );
set_resource_path_processor( $fetch_action, \&get_true_path_to_resource );
set_default_action( $fetch_action );
$default_style = << 'EOF';
EOF
my $text_html_type = create_content_type( "text/html" );
set_content_type_editor( $text_html_type, \&entry_editor );
set_content_type_exporter( $text_html_type, \&entry_exporter );
set_content_type_importer( $text_html_type, \&entry_importer );
create_binary_content_type( "text/css", "css" );
create_binary_content_type( "image/jpeg", "jpg", "jpeg" );
create_binary_content_type( "image/png", "png" );
set_default_content_type( $text_html_type );
}
my $jitter_script = << 'EOF';
EOF
# create_html_response: http_request * string -> string
# Takes a resource path and a body and produces an HTML message
sub create_html_response
{
my ($http_request, $body) = @_;
my $semantics = $http_request->{request_semantics};
my $action = $semantics->{action};
my $resource_path = $semantics->{resource_path};
my $style;
if (defined $style_file)
{
$style = '';
}
else
{
$style = $default_style;
}
my $buffer = "
";
my $links;
$buffer .= $server_title . ': ' if $server_title;
$buffer .= $resource_path . "" . $style . "";
my $link_generators = get_link_generators( $action );
if ($link_generators)
{
my $cnt = 0;
$links .= '
";
set_response_content_mime_type( $http_request, "text/html" );
}
send_response( $http_request, $http_error_code, $http_error_message, $buffer);
}
# send_redirect: http_request * string -> unit
sub send_redirect
{
my ($http_request, $target) = @_;
my $host = $http_request->{request_headers}->{Host};
if ($host)
{
set_response_header( $http_request, "Location", "http://$host$target" );
send_response( $http_request, 303, "Redirect", undef );
}
else
{
my $buffer = "Redirect to " . $target .
"Click here";
send_html_okay_response( $http_request, $buffer );
}
}
# handle_get: http_request -> unit
sub handle_get
{
handle_content_request @_;
}
# handle_post: http_request -> unit
sub handle_post
{
handle_content_request @_;
}
{
my %request_handlers;
# handle_request: http_request -> unit
sub handle_request
{
my ($http_request) = @_;
my $type = $http_request->{type}; # already lc
my $handler = $request_handlers{$type};
if ($handler)
{
$handler->( $http_request );
}
else
{
send_error( $http_request, 501, 'Not supported', 'The request type "' . $type . '" is unknown or unsupported' );
}
}
# set_handler: string * (http_request -> unit) -> unit
sub set_handler
{
my ($http_request_type, $handler) = @_;
$request_handlers{lc $http_request_type} = $handler;
}
}
# classify_relative_resource: string * string -> ('present' | 'absent' | 'nonlocal')
sub classify_relative_resource
{
my ($resource_path, $relative_path) = @_;
my $path;
if ($relative_path =~ /^\//)
{
$path = $relative_path;
}
elsif ($relative_path =~ /^[a-zA-Z]:/)
{
# non-local path
return 'nonlocal';
}
else
{
$resource_path =~ s/[^\/]+$//;
$path = $resource_path . $relative_path;
}
if (exists_resource( $path ))
{
return 'present';
}
else
{
return 'absent';
}
}
# exists_resource: string -> boolean
sub exists_resource
{
my ($resource_path) = @_;
my $local_path = create_local_path( $resource_path );
return -e $local_path;
}
my @sidebar_colors = ('#c16c72', '#b86cc1', '#6c9ac1', '#6cc19a', '#7cc16c', '#c0c16c', '#c1826c');
my $current_sidebar_color = 0;
# make_paler_color: color -> color
sub make_paler_color
{
my ($color) = @_;
if ($color =~ /^#([[:xdigit:]]{2})([[:xdigit:]]{2})([[:xdigit:]]{2})$/)
{
my ($r, $g, $b) = (hex $1, hex $2, hex $3);
$r = ($r + 0xFF) / 2;
$g = ($g + 0xFF) / 2;
$b = ($b + 0xFF) / 2;
$color = sprintf( "#%x%x%x", $r, $g, $b );
return $color;
}
else
{
return $color;
}
}
# interpret_link: string * string -> string
# (resource_path, text)
sub interpret_link
{
my ($resource_path, $text) = @_;
return '[' if $text eq "lb";
return ']' if $text eq "rb";
if ($text =~ /^aside:\s*(.*)\s*$/i)
{
my $text = $1;
$text = '' unless $text;
my $sidebar_color = $sidebar_colors[$current_sidebar_color++];
my $pale_color = make_paler_color( $sidebar_color );
$current_sidebar_color %= @sidebar_colors;
return '' . $text . '';
}
elsif ($text =~ /^:aside$/i)
{
return '';
}
elsif ($text =~ /^([^\|]*)(?:\|([^\|]*))?$/)
{
my $linktext = $1;
my $href = defined $2 ? $2 : $linktext;
$href = encode_urlencoding( $href );
my $class = classify_relative_resource( $resource_path, $href );
return ' ' . $linktext . '';
}
else
{
return $text;
}
}
# content_fetch_filter: resource -> string
sub content_fetch_filter
{
$current_sidebar_color = 0;
my ($resource) = @_;
my $path = $resource->{path};
my $text = $resource->{text};
$text =~ s/\[\[(.*?)\]\]/interpret_link($path, $1)/eg;
$text =~ s/\r//g;
$text =~ s/\n\n/\n
\n/g;
$text =~ s/---/—/g;
$text =~ s/--/–/g;
my ($node) = ${parse_resource_path($path)}[-1];
if ($node eq "Main")
{
return '
' . $text . '
';
}
# elsif ($node =~ /\*(.*)/)
# {
# return '
*' . $1 . ' ' . $text . '
';
# }
# else
{
return '
' . $text . '
';
}
}
# local_path_encode: string -> string
sub local_path_encode
{
my ($rp) = @_;
$rp =~ tr!%!_!;
$rp;
}
# local_path_decode: string -> string
sub local_path_decode
{
my ($value) = @_;
$value =~ tr!_!%!;
return $value;
}
# process_configuration_line: string -> (string option)
sub process_configuration_line
{
my ($line) = @_;
if ($line =~ /^\s*([a-zA-Z0-9\._]+):?\s*([^\s].*)?$/)
{
my $command = lc $1;
my $params = $2;
if ($command eq "alias")
{
if ($params !~ /^([^\s]*)\s+([^\s]*)\s*$/)
{
return "expected 'Alias alias_path true_path'";
}
my $alias_path = $1;
my $true_path = $2;
add_path_alias( $alias_path, $true_path );
}
elsif ($command eq "edit.editbox.dimensions")
{
if ($params !~ /^([0-9]+)\s*x\s*([0-9]+)\s*$/)
{
return "expected 'Edit.editbox.dimensions columns x rows'";
}
$edit_box_width = $1;
$edit_box_height = $2;
}
elsif ($command eq "title")
{
if ($params !~ /^(.*[^\s])\s*$/)
{
return "expected 'Title title_string'";
}
$server_title = $1;
}
elsif ($command =~ /^([a-z]+).links$/)
{
my $action_name = $1;
my $action = get_action( $action_name );
my $flags = 0;
$params = lc $params;
$params =~ s/\s+$//;
my @names = split /(?:,\s*|\s+)/, $params;
my @link_generators;
if (not @names)
{
return "no link generators specified after $command";
}
while (my $name = shift @names)
{
if ($name eq "none")
{
if (@link_generators or @names)
{
return "'none' specified with other link generators";
}
}
else
{
my $generator = $link_generators{$name};
if (not $generator)
{
return "unknown link generator '$name'";
}
push @link_generators, $generator;
}
}
set_link_generators( $action, \@link_generators );
}
elsif ($command eq 'directory')
{
$params =~ s/\s+$//;
if (not $params)
{
return "no path specified after Directory";
}
$database_directory = $params;
}
elsif ($command eq 'stylesheet')
{
$params =~ s/\s+$//;
if (not $params)
{
return "no style sheet file specified after StyleSheet";
}
$style_file = $params;
}
elsif ($command eq 'linksattop')
{
$params =~ s/\s+$//;
if ($params =~ /^true$/i)
{
$html_links_on_top = 1;
}
elsif ($params =~ /^false$/i)
{
$html_links_on_top = 0;
}
else
{
return "expected 'true' or 'false' after LinksAtTop";
}
}
elsif ($command eq 'linksatbottom')
{
$params =~ s/\s+$//;
if ($params =~ /^true$/i)
{
$html_links_on_bottom = 1;
}
elsif ($params =~ /^false$/i)
{
$html_links_on_bottom = 0;
}
else
{
return "expected 'true' or 'false' after LinksAtBottom";
}
}
else
{
return "unknown configuration command: $command\n";
}
}
elsif (not /^\s+$/)
{
return "unknown configuration line: $line\n";
}
return undef;
}
# main program begins here
init();
while ($_ = shift)
{
if (/^-h$/)
{
print "listen\n";
print " A standalone HTTP wiki server; just run this program in the background\n";
print " and point a web browser at localhost. This is version 200503190156-perl.\n";
print " -h Prints this help message and exits.\n";
print " -p port Specifies which port to listen to (default: 80)\n";
print " -d directory Set the data root (default: the working directory)\n";
print " -t title Sets the server title (default: 'listen')\n";
print " -r page_name Sets the page displayed when a folder is fetched (default: 'Main')\n";
print " -c config_file Use the given file for configuration (parsed in-place)\n";
print " -cl config_line Process the given line as if it were from a config file\n";
print " Send bugs, requests, and comments to rjmccall\@gmail.com\n";
exit(0);
}
elsif (/^-p$/)
{
$listen_port = shift;
if ($listen_port <= 0)
{
die "Invalid port specification: $listen_port\n";
}
}
elsif (/^-d$/)
{
$database_directory = shift;
if (not $database_directory)
{
die "A directory path is required after -d\n";
}
}
elsif (/^-t$/)
{
$server_title = shift;
if (not defined $server_title)
{
die "A title is required after -t\n";
}
}
elsif (/^-r$/)
{
$directory_page = shift;
if (not defined $directory_page)
{
die "A page name is required after -r\n";
}
}
elsif (/^-cl$/)
{
my $config_line = shift;
if (not defined $config_line)
{
die "Configuration line required after -cl\n";
}
my $error = process_configuration_line( $config_line );
if ($error)
{
die "Error on command line after -cl: $error\n";
}
}
elsif (/^-c$/)
{
my $config_file = shift;
if (not defined $config_file)
{
die "Configuration file required after -c\n";
}
if (not open CONFIG, '<', $config_file)
{
die "Couldn't open configuration file '$config_file': $!\n";
}
my $lineno = 0;
while ()
{
$lineno++;
s/#.*$//;
my $error = process_configuration_line( $_ );
if ($error)
{
die "Error in '$config_file' at line $lineno: $error\n";
}
}
}
else
{
die 'Unknown parameter: ', $_, "\n";
}
}
my $tcp_proto = getprotobyname( 'tcp' );
socket Socket, PF_INET, SOCK_STREAM, $tcp_proto or die "socket: $!\n";;
setsockopt Socket, SOL_SOCKET, SO_REUSEADDR, pack( "l", 1 ) or die "setsockopt: $!";
bind Socket, sockaddr_in( $listen_port, INADDR_ANY ) or die "bind: $!\n";
listen Socket, SOMAXCONN or die "listen: $!\n";
while (1)
{
if (not accept Conn, Socket)
{
warn "accept: $!\n";
next;
}
my $http_request = {};
$http_request->{response_headers} = {};
$http_request->{socket} = *Conn;
{
my $line = recv_line $http_request;
if ($line =~ /^([a-zA-Z]+)\s+([^\s]+)\s+([^\s]+)/)
{
$http_request->{type} = lc $1;
$http_request->{uri} = $2;
$http_request->{version} = $3;
}
else
{
send_error( $http_request, 400, 'Bad request', "Your HTTP client sent a request that couldn't be parsed:
$line
" );
close Conn;
next;
}
}
my %http_request_headers;
while (1)
{
my $line = recv_line $http_request;
$line =~ s/\s+$//;
last if not $line;
$line =~ /^([^:]+):\s*(.*)$/;
$http_request_headers{$1} = $2;
}
$http_request->{request_headers} = \%http_request_headers;
handle_request $http_request;
close Conn;
}