~duncan-bayne/halp

0be70af646ee7962fbd547aacf81979f53c79fe1 — Duncan Bayne 2 months ago adfb686
Migrate Web serving to static generation
22 files changed, 117 insertions(+), 434 deletions(-)

D dev-config/cert.csr
D dev-config/cert.pem
M dev-config/halp-config.pl
D dev-config/key.pem
D dev-config/privkey.pem
D freebsd/halp
D freebsd/halp-config.pl
D freebsd/install.sh
M halp.pl
M lib/Halp/AtomFeed.pm
M lib/Halp/ContentUtils.pm
M lib/Halp/GeminiServer.pm
M lib/Halp/WebServer.pm
D run-dev-server.sh
D run-tests.sh
R freebsd/install-prereqs.sh => scripts/freebsd-dependencies.sh
R linux/install-prereqs.sh => scripts/ubuntu-dependencies.sh
M t/Halp/web_server_test.t
A t/fixtures/www/miscellaneous/.description.html
A t/fixtures/www/projects/halp/.description.html
D t/fixtures/www/projects/halp/index.html
A t/fixtures/www/projects/unicomp-overhaul/.description.html
D dev-config/cert.csr => dev-config/cert.csr +0 -16
@@ 1,16 0,0 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICijCCAXICAQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUx
ITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBALqNOmYxADWe8oZKP1Oal0iVQRz/00U5jdL0RkUc
s9ddjE6+N3WIJY+Xeklq9rDo4wad09yIKYb2vHuURoIbNmlbrJTn3MTiqp14uRnl
cMRTvSiH7JD1Ttuw/Vgbpc8enC3/6p4aopRuvvT17+49SCOiTYQXCkbryPfhZ8rz
IwqTQngYZc+atC3lbE2ZPhexJ9iwwxU/ns7y9JqIiQmEmsDZww9VqNXjF3ggR8nd
gmqtpl+9izbeV/xuOglaxYf/I8jNVLdatFEYbYJlsVssaG+ohyNfK3va6EPnTh1g
bP0YZtPJkxCJLr4zPBMyvQ1p8edC3RXRHVmnnpkMUlhEy/UCAwEAAaAAMA0GCSqG
SIb3DQEBCwUAA4IBAQBQMLGkWnjivgqhclFvdtcu4BGVzw3IF00IRKKlhjh1rGTz
8FDW3HHtfu+eEl6zRYAyIcj1B+Tqx0QddSW1zGox8rWNE8NZqa/kb3W2pJQ//waC
QegoQsoUdvnFWwdP+oN1e7gBrx4bSq8B565B5rmxt4GOxiCaGctZxzFgAzPzHM6/
hIpGXVxTNkQH8+6SZ8TrDrbZeK7MknqdGM6cexItAjAhRoDIDqR9/TUQM/CZtxeg
FGIFS3fObg8Pd8Y6kEaqVuwcifo8/m8CVLnXxyp6sRXkGBCKhvM8xah+tM97Balp
1iIH12W/7fZgYzrOCBoz1M2oSlnuZy3ufs1uInKf
-----END CERTIFICATE REQUEST-----

D dev-config/cert.pem => dev-config/cert.pem +0 -47
@@ 1,47 0,0 @@
-----BEGIN CERTIFICATE-----
MIIDETCCAfkCFGwVbEh0nfw6IrCete4jZ3pqxoD5MA0GCSqGSIb3DQEBCwUAMEUx
CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl
cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwNjEzMDQxMTM2WhcNNDkxMDI5MDQx
MTM2WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE
CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAuo06ZjEANZ7yhko/U5qXSJVBHP/TRTmN0vRGRRyz112MTr43
dYglj5d6SWr2sOjjBp3T3Igphva8e5RGghs2aVuslOfcxOKqnXi5GeVwxFO9KIfs
kPVO27D9WBulzx6cLf/qnhqilG6+9PXv7j1II6JNhBcKRuvI9+FnyvMjCpNCeBhl
z5q0LeVsTZk+F7En2LDDFT+ezvL0moiJCYSawNnDD1Wo1eMXeCBHyd2Caq2mX72L
Nt5X/G46CVrFh/8jyM1Ut1q0URhtgmWxWyxob6iHI18re9roQ+dOHWBs/Rhm08mT
EIkuvjM8EzK9DWnx50LdFdEdWaeemQxSWETL9QIDAQABMA0GCSqGSIb3DQEBCwUA
A4IBAQBl4EQEBtKq1nrPGA3BDRoyAVr8BZuf11gTwHgFuw8tt5dTlL4mr0qlvicp
i/Ij3BpT8L+vvKRHTsHQRUFucYRl6E3XrSSVAnVgA5psgnjMTxLqk6PSqSYFJ5Bv
nZ0j0TmDKwKWV8pvtwlkUSH5WAfxYvwwPzaj4FZk1MYvpufEORKjwLFm1HL/+HeY
+CsDmyuEdz9s/De1IwmmwBlg4dKzgQXbbrnklsj0fX6syelHKBFt76H3AFTd6gJ8
9SvUWLpPcfYgdVUSLLcYkjeyHTXE+kV2oU6mA8HvaV1PCX7jPoojN861nF6uFTAk
d+jeDB2onO3Zz6Mtz1ULzkr7kfXX
-----END CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6jTpmMQA1nvKG
Sj9TmpdIlUEc/9NFOY3S9EZFHLPXXYxOvjd1iCWPl3pJavaw6OMGndPciCmG9rx7
lEaCGzZpW6yU59zE4qqdeLkZ5XDEU70oh+yQ9U7bsP1YG6XPHpwt/+qeGqKUbr70
9e/uPUgjok2EFwpG68j34WfK8yMKk0J4GGXPmrQt5WxNmT4XsSfYsMMVP57O8vSa
iIkJhJrA2cMPVajV4xd4IEfJ3YJqraZfvYs23lf8bjoJWsWH/yPIzVS3WrRRGG2C
ZbFbLGhvqIcjXyt72uhD504dYGz9GGbTyZMQiS6+MzwTMr0NafHnQt0V0R1Zp56Z
DFJYRMv1AgMBAAECggEABHcoeHVAUMhE/GdvFCB9chCWjivmgf/yb+QGgZWCDYvw
9ZqSh8byRTlmRTchABOXCZvCDEObIN6rnNasCGW/2+5El4zkSTc2x82xupe2JxDt
FHkKdd7VXCdkrRT+V1KfgO9hDZdMIHr1KbZwX5bKcQXrzpdhmNiAh0R3QFGpG1Ho
EcIkoBH1orZfRy4sLTIHNv5CobHgvGUMXAdyXPg94u5KgzhQNb2upn2iiPnC+w5n
UKS+lt95Da+Rcbtf3C4ztxD9mswoY5AW9ywZolxP1CZSu50wNq6i0Ywx2NTWcDC/
tmDv9ih6fXq7bKNGNCAxibsxIVrafgpoT18B/wNbeQKBgQDHIySxCRoI6LtAPWlD
Mh45Mr0JibAOplqItae3wST9KIj6MbB3Wz6c5EhP1Hl63eA3XPBopJYV+uuhI9E6
IdiYsf8Y/4td4pYGFgOMqeYuMDxJ8EfFEH1CST10p3KTpn++Kls8nTEoEHeQnshv
Pps3q1Ce9Ikb7Es35a+PGbnX2QKBgQDv0hT5e7VN27QmBEhmYIr90jh84ztJx8dr
MBks7Q2YwLdbZ/mSkQ67QMaLTHGKVWDk8mQq6mSqF+21XVinqYlpOYfplnoVKVy+
cOau1j67FyGugKi0peSapgIHT+BXjxw0IBWhddedRDyvWgcxjU38n3P1V0IS/LJN
hjrh1qg/fQKBgCupL+VE6pljdZK7JDYgafH+huXtc1Vy8cyhj3lBdruuxIGenWM9
9Tdu8c/W9R+UQBHDZc57r5B4KGt3L+t/+j5YYGb5uHPINz50WyjfI2GjH8TSUtoJ
KH19E2VdRUjMK5vlK4XZrSpsmdpyhd2mK2AERrPjR6CAqWlDCb/lUxLZAoGBAK8s
1lNSEKIZjKKEWFonxP8YklhvJvyCqGDcVldhpJ/ijUyAS2XK/Wa8LwrLQNhZ+xfh
ElfitLsmFWV4FO0LQqsQ8f0nBG/2sZ8OGwK0zkec4uZzZkfmMXDhN/QdXXK3v1M3
6HTy/hcLJAS2DzEb1U5lLq+UGFiEKr3EAbi7MlSFAoGBAL5HpJNSKnKizM3oTBmX
3TnaY2B1X/AZT2iNRDZsnWIff1FmRNbMXzyKtusKm3u0qYl31TYNAcjpk7I2voMF
+BkI7TRtNKiXrNFR/vDERjqONu1cKYpgYiTG+F2apH62h8K72yM+n30rW21SN0qW
0WwINkd2tlFNUiokBy2Q2DDm
-----END PRIVATE KEY-----

M dev-config/halp-config.pl => dev-config/halp-config.pl +6 -7
@@ 7,16 7,15 @@
    domain => 'example.com',

    gemini => {
	cert_path => './dev-config/cert.pem',
	gemini_path => '/usr/home/duncan/code/halp/gemini',
	host => '0.0.0.0',
	key_path => './dev-config/key.pem',
	port => 1965
	input_path => '/home/duncan/code/halp/t/fixtures/www',
	output_path => '/home/duncan/code/halp/tmp/gemini'
    },

    web => {
	port => 8080,
	# web_path => '/usr/home/duncan/code/halp/t/fixtures/www'
	web_path => '/usr/home/duncan/code/duncan.bayne.id.au/www/'
	host => '0.0.0.0',
	# input_path => '/home/duncan/code/halp/t/fixtures/www',
	input_path => '/home/duncan/code/duncan.bayne.id.au/www',
	output_path => '/home/duncan/code/halp/tmp/www'
    }
}

D dev-config/key.pem => dev-config/key.pem +0 -28
@@ 1,28 0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6jTpmMQA1nvKG
Sj9TmpdIlUEc/9NFOY3S9EZFHLPXXYxOvjd1iCWPl3pJavaw6OMGndPciCmG9rx7
lEaCGzZpW6yU59zE4qqdeLkZ5XDEU70oh+yQ9U7bsP1YG6XPHpwt/+qeGqKUbr70
9e/uPUgjok2EFwpG68j34WfK8yMKk0J4GGXPmrQt5WxNmT4XsSfYsMMVP57O8vSa
iIkJhJrA2cMPVajV4xd4IEfJ3YJqraZfvYs23lf8bjoJWsWH/yPIzVS3WrRRGG2C
ZbFbLGhvqIcjXyt72uhD504dYGz9GGbTyZMQiS6+MzwTMr0NafHnQt0V0R1Zp56Z
DFJYRMv1AgMBAAECggEABHcoeHVAUMhE/GdvFCB9chCWjivmgf/yb+QGgZWCDYvw
9ZqSh8byRTlmRTchABOXCZvCDEObIN6rnNasCGW/2+5El4zkSTc2x82xupe2JxDt
FHkKdd7VXCdkrRT+V1KfgO9hDZdMIHr1KbZwX5bKcQXrzpdhmNiAh0R3QFGpG1Ho
EcIkoBH1orZfRy4sLTIHNv5CobHgvGUMXAdyXPg94u5KgzhQNb2upn2iiPnC+w5n
UKS+lt95Da+Rcbtf3C4ztxD9mswoY5AW9ywZolxP1CZSu50wNq6i0Ywx2NTWcDC/
tmDv9ih6fXq7bKNGNCAxibsxIVrafgpoT18B/wNbeQKBgQDHIySxCRoI6LtAPWlD
Mh45Mr0JibAOplqItae3wST9KIj6MbB3Wz6c5EhP1Hl63eA3XPBopJYV+uuhI9E6
IdiYsf8Y/4td4pYGFgOMqeYuMDxJ8EfFEH1CST10p3KTpn++Kls8nTEoEHeQnshv
Pps3q1Ce9Ikb7Es35a+PGbnX2QKBgQDv0hT5e7VN27QmBEhmYIr90jh84ztJx8dr
MBks7Q2YwLdbZ/mSkQ67QMaLTHGKVWDk8mQq6mSqF+21XVinqYlpOYfplnoVKVy+
cOau1j67FyGugKi0peSapgIHT+BXjxw0IBWhddedRDyvWgcxjU38n3P1V0IS/LJN
hjrh1qg/fQKBgCupL+VE6pljdZK7JDYgafH+huXtc1Vy8cyhj3lBdruuxIGenWM9
9Tdu8c/W9R+UQBHDZc57r5B4KGt3L+t/+j5YYGb5uHPINz50WyjfI2GjH8TSUtoJ
KH19E2VdRUjMK5vlK4XZrSpsmdpyhd2mK2AERrPjR6CAqWlDCb/lUxLZAoGBAK8s
1lNSEKIZjKKEWFonxP8YklhvJvyCqGDcVldhpJ/ijUyAS2XK/Wa8LwrLQNhZ+xfh
ElfitLsmFWV4FO0LQqsQ8f0nBG/2sZ8OGwK0zkec4uZzZkfmMXDhN/QdXXK3v1M3
6HTy/hcLJAS2DzEb1U5lLq+UGFiEKr3EAbi7MlSFAoGBAL5HpJNSKnKizM3oTBmX
3TnaY2B1X/AZT2iNRDZsnWIff1FmRNbMXzyKtusKm3u0qYl31TYNAcjpk7I2voMF
+BkI7TRtNKiXrNFR/vDERjqONu1cKYpgYiTG+F2apH62h8K72yM+n30rW21SN0qW
0WwINkd2tlFNUiokBy2Q2DDm
-----END PRIVATE KEY-----

D dev-config/privkey.pem => dev-config/privkey.pem +0 -30
@@ 1,30 0,0 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIaIAFTOTmQJQCAggA
MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECBiSF+aTb8WyBIIEyMujrmnvc9lp
uGe440LEQph/jNQwvL5OSggAr5V6Skhw5hMh+aPBX9/4swttSMI7ObtOKYS1sjU7
5XKLjGQKJnDj4PbIRV9Mo8BY1RvUfd2phNhAZ48aCcm87goMDFQVu6mTKNbxOpwm
6YNlO2vwFFQNV4+JxXtnNkuBdWrBxc8fnTFJW9NxZC9624Yp2j9HsZASiZ1M4NVr
2/q/J9tPhj12p0WgJHujeu2l7eYdRAxEh8BQ0UjiBmdXZH3eQ+BWrscJOY8Z5pVs
KzXsK5veVfuQeziLNwXqs/1FeADrmnA5kZV0LFj6pjV1W+mYIKu1RHadZBD6ECbB
r7J4vKIQywhRwdWpGF1e7pi5d+c2/V7KZrs5abav5YPLw8nu8Wf79EblKwSnNekA
tG8HuQ8WhBotp8G3W7MbilYJopBncnBQzspctQqwxh8hlNq3JQg2rsEwkGfJeelf
fcmrLqCjQrncP+qL5K6lbMVct4IEVWrbiol1LEwE2ZMFDFYHVz5HcgSISg6IP/KE
mXRu/hHfsLdVeGwx8Hf02B/Qzt5WnsWt+R6BLWTbzzjcRbM5GLFuPD1HWJbHz3+x
+H8m9oCqsyDasCfnYMuHkPDlNxmSElaKYmRdaGBlxBUvYek88xCgT0kcncNfG5+v
uzmbI/Om5H+izGhpcpz3hBHSkENaOAZRFOy96T19xUo7YITy1esDiKhLFrhBUMrs
YGbvJBUTx+XKY8n26uDnzBj7aOzDrH72PNjcX04GqKxzl5PdJnYTavyJ8iw3rB+y
fQHVSU4UpVyRja78CGUhu4CvXGeKuTRHOxJHagpa0thKVGpeCefqTtb2PYAbjGCh
qyiJT+iH2AkwZsD6A8K7KlivWkRGd5aFUsZc7fdj3gaFIfeyoJ6bC4AJQQAbzyUD
d/Ms4LyDkHUXToVT7iScvERQyeBrPJE5qg1IUDZSQQcCHMIL8qocn8i6ze1M/kMZ
+l2wboMleXYeoxiZ7iykjd49VmPw9jcZE8oZJRj0mE3W/cJrfHQ18Vn3+bCWLJuE
s5lZ3XtClzG+XLUoGVuWohagBddN6BnO78xRlLcvQ6S6AHjEbxCriPJJZrUwwt3P
ROrP1H7fjSjUd83nNiwL3B8wLGrs2DexHnuEacHSldxsaxLRiooQo4PmWDLEzxHu
G9yYFLKjzeLBRFvEEii1NoAGifuNlCiiJZ/OUl/ht1kUkKk1cebeG2OQQx4+ydj1
JS7gTBhLU5C6E1P8VlyIkkEcgJ5v5abpmUwkNRvGVDVhOENd+9X/ht1S70AiYxKn
c7bMYI3eGshpoEZW2s3GpFktQZjbqdetzNDTHku0l7RIplD3jYSTl7aGMuK2LEho
jSgUoMenLduQMCONXqQ8LISBn3VoRjqJZlZ6NklmjMCXlsTKxiSIB/O0//Jjm7Yh
vx5dv71aqT29/Z6rnPOYOEtafg3wP9UNttr9AC4mkA5i7/wyFI5qcv3EpaZbvXA1
N4l2OtNWzfmDO2erlBjbDJn8O5J2mfl9ohMZRNno5rlvGqxxIUK65nPHYaNsxk1K
5YFLa3SOvG6w0nJZD6grfFAOb2gPQVkbUPTziG6fN6SOk1zq/mfIPKKcX/TVuAKp
NwvUfv5MAj9fpse89jTozg==
-----END ENCRYPTED PRIVATE KEY-----

D freebsd/halp => freebsd/halp +0 -49
@@ 1,49 0,0 @@
#!/bin/sh
#
# $FreeBSD$
#

# PROVIDE: halp
# REQUIRE: networking
# KEYWORD: shutdown

. /etc/rc.subr

name="halp"
desc="Perl scripts to run my personal Gemlog and my professional Website"
rcvar="halp_enable"
command="/usr/local/bin/perl"
command_args="halp.pl --config-filename /usr/local/etc/halp/halp-config.pl --web"
halp_user="root"
pidfile="/var/run/${name}.pid"
required_files="/usr/local/etc/halp/halp-config.pl"
logfile="/var/log/halp/halp.log"

start_cmd="halp_start"
stop_cmd="halp_stop"
status_cmd="halp_status"

halp_start() {
	cd /usr/local/share/halp
	/usr/sbin/daemon -P ${pidfile} -o $logfile -r -f -u $halp_user $command $command_args
}

halp_stop() {
	if [ -e "${pidfile}" ]; then
		kill -s TERM `cat ${pidfile}`
	else
		echo "${name} is not running"
	fi

}

halp_status() {
	if [ -e "${pidfile}" ]; then
		echo "${name} is running as pid `cat ${pidfile}`"
	else
		echo "${name} is not running"
	fi
}

load_rc_config $name
run_rc_command "$1"

D freebsd/halp-config.pl => freebsd/halp-config.pl +0 -16
@@ 1,16 0,0 @@
{
    author => {
	email => 'duncan@bayne.id.au',
	name => 'Duncan Bayne'
    },

    domain => 'duncan.bayne.id.au',

    gemini => {
    },

    web => {
	port => 8080,
	web_path => '/home/freebsd/duncan.bayne.id.au/www/'
    }
}

D freebsd/install.sh => freebsd/install.sh +0 -16
@@ 1,16 0,0 @@
#!/usr/bin/env bash

set -euxo pipefail

shellcheck "$0"

mkdir -p /usr/local/etc/halp
mkdir -p /var/log/halp

git clone https://git.sr.ht/~duncan-bayne/halp /usr/local/share/halp

ln -s /usr/local/share/halp/freebsd/halp-config.pl /usr/local/etc/halp/halp-config.pl
ln -s /usr/local/share/halp/freebsd/halp /etc/rc.d/halp

sysrc -f /etc/rc.conf halp_enable="YES"
service halp start

M halp.pl => halp.pl +12 -9
@@ 15,8 15,12 @@ my $web = 0;

GetOptions("config-filename=s" => \$config_filename, "gemini" => \$gemini, "web" => \$web);

if (!$config_filename) {
    die "You must specify a config filename with --config-filename.";
}

if (!($web || $gemini)) {
    die "You must specify whether to serve --gemini or --web.";
    die "You must specify whether to generate --gemini or --web.";
}

if ($web && $gemini) {


@@ 30,22 34,21 @@ if ($web) {
    my $web_server = Halp::WebServer->new(
	author => $halp_config{author},
	domain => $halp_config{domain},
	port => $halp_config{web}{port},
	web_path => $halp_config{web}{web_path}
	host => $halp_config{gemini}{host},
	input_path => $halp_config{web}{input_path},
	output_path => $halp_config{web}{output_path}
	);
    $web_server->run();
    $web_server->generate();
}

if ($gemini) {
    my $gemini_server = Halp::GeminiServer->new(
	author => $halp_config{author},
	cert_path => $halp_config{gemini}{cert_path},
	domain => $halp_config{domain},
	gemini_path => $halp_config{gemini}{gemini_path},
	host => $halp_config{gemini}{host},
	key_path => $halp_config{gemini}{key_path},
	port => $halp_config{gemini}{port}
	input_path => $halp_config{web}{input_path},
	output_path => $halp_config{web}{output_path}
	);

    $gemini_server->run();
    $gemini_server->generate();
}

M lib/Halp/AtomFeed.pm => lib/Halp/AtomFeed.pm +5 -5
@@ 2,8 2,8 @@ package Halp::AtomFeed;

use File::Basename;
use File::Slurp;
use File::Spec::Functions 'rel2abs';
use File::Spec;
use File::Spec::Functions 'rel2abs';
use Halp::ContentUtils;
use Time::Piece;
use XML::Atom::SimpleFeed;


@@ 12,18 12,18 @@ use Exporter 'import';
our @EXPORT = qw(feed_for);

sub feed_for {
    my ($request_path, $local_path, $web_path, $domain, $author) = @_;
    my ($local_path, $web_path, $domain, $author) = @_;

    my $feed_directory = dirname($local_path);
    my $updated_at = `cd $feed_directory; git log --reverse --pretty='format:%ad' --date=iso8601-strict -- | head -1`;

    my $feed = XML::Atom::SimpleFeed->new(
	title   => directory_title($feed_directory),
	link    => "https://$domain$feed_path",
	link    => "https://$domain/$feed_path",
	updated => $updated_at
	);

    my @files = directory_listing(dirname($request_path), $feed_directory, 1);
    my @files = directory_listing($local_path, 1);

    foreach my $file (@files) {
	my $full_path = $file->{filename};


@@ 34,7 34,7 @@ sub feed_for {
	    $updated_at = `cd $feed_directory; git log --reverse --pretty='format:%ad' --date=iso8601-strict -- $relative_path | head -1`;
	    chomp($updated_at);

	    my $url = "https://$domain$file->{href}";
	    my $url = "https://$domain/$file->{href}";
	    $feed->add_entry(
		title     => file_title($full_path),
		link      => $url,

M lib/Halp/ContentUtils.pm => lib/Halp/ContentUtils.pm +4 -3
@@ 15,14 15,15 @@ use Exporter 'import';
our @EXPORT = qw(directory_listing directory_title file_title);

sub directory_listing {
    my ($request_path, $local_path, $descend) = @_;
    my ($local_path, $descend) = @_;
    my @files;
    my $href;
    opendir(my $dh, $local_path);
    while (my $file = readdir $dh) {
	next if $file =~ /^\./;

	$href = File::Spec->catfile($request_path, $file);
	$href = File::Spec->abs2rel($file, $self->{input_path});

	my $title = $file;
	my $full_path = File::Spec->catfile($local_path, $file);



@@ 33,7 34,7 @@ sub directory_listing {
	if (-d $full_path) {
	    $title = directory_title($full_path);
	    if ($descend) {
		push @files, directory_listing(File::Spec->catfile($request_path, $file), $full_path, 1);
		push @files, directory_listing($full_path, 1);
	    }
	}


M lib/Halp/GeminiServer.pm => lib/Halp/GeminiServer.pm +3 -0
@@ 77,4 77,7 @@ sub run {
    }
}

sub generate {
}

1;

M lib/Halp/WebServer.pm => lib/Halp/WebServer.pm +80 -132
@@ 1,6 1,11 @@
package Halp::WebServer;

use Cwd;
use Data::Dump qw(dump);
use File::Basename;
use File::Copy;
use File::Copy::Recursive "dircopy";
use File::Glob;
use File::Slurp;
use File::Spec;
use Halp::AtomFeed;


@@ 9,8 14,8 @@ use Halp::MimeTypes;
use HTTP::Server::Simple::CGI;
use Text::Template;
use Time::Piece;
use XML::Atom::SimpleFeed;
use utf8;
use XML::Atom::SimpleFeed;

use base qw(HTTP::Server::Simple::CGI);



@@ 18,20 23,19 @@ sub new {
    my ($class, %args) = @_;
    my $self = $class->SUPER::new();

    die "web_path $args{web_path} must be absolute" unless File::Spec->file_name_is_absolute($args{web_path});
    die "input_path $args{input_path} must be absolute" unless File::Spec->file_name_is_absolute($args{input_path});
    die "output_path $args{output_path} must be absolute" unless File::Spec->file_name_is_absolute($args{output_path});

    $self->{author} = $args{author};
    $self->{domain} = $args{domain};
    $self->{web_path} = $args{web_path};
    $self->{port} = $args{port};
    $self->{template_path} = "./www/templates";
    $self->{default_site_path} = "./www/site";
    $self->{host} = $args{host};
    $self->{input_path} = $args{input_path};
    $self->{output_path} = $args{output_path};

    return $self;
}
    $self->{template_path} = File::Spec->catfile(cwd(), "www/templates");
    $self->{default_site_path} = File::Spec->catfile(cwd(), "www/site");

sub print_banner {
    # Don't print any banner, so everything we send to STDERR and STDOUT is logging.
    return $self;
}

sub generate_menu {


@@ 41,7 45,7 @@ sub generate_menu {
    my $template = Text::Template->new(SOURCE => $template_pathname);

    my @items = ({ href => '/', title => 'Home' });
    my @listing = directory_listing("/", $self->{web_path}, 0);
    my @listing = directory_listing($self->{input_path}, 0);
    push(@items, @listing);

    my $result = $template->fill_in(HASH => { 'items' => \@items, 'current' => \@items[0] } );


@@ 49,16 53,16 @@ sub generate_menu {
}

sub wrap_page_template {
    my ($self, $title, $content, $show_atom_link, $request_path, $local_path) = @_;
    my ($self, $title, $content, $show_atom_link, $source_filename) = @_;

    my $menu = $self->generate_menu($request_path);
    my $menu = $self->generate_menu($source_filename);
    my $template_pathname = File::Spec->catfile($self->{template_path}, 'page.html.tpl');
    my $template = Text::Template->new(SOURCE => $template_pathname);
    my $atom_url = File::Spec->catfile($request_path, "feed.xml");
    my $atom_url = File::Spec->catfile(dirname(File::Spec->abs2rel($source_filename, $self->{input_path})), "feed.xml");

    my $footer = '';
    if (-f $local_path) {
	my $footer_pathname = File::Spec->catfile(dirname($local_path), '.footer.html');
    if (-f $source_filename) {
	my $footer_pathname = File::Spec->catfile(dirname($source_filename), '.footer.html');
	if (-f $footer_pathname) {
	    $footer = read_file($footer_pathname);
	}


@@ 70,7 74,8 @@ sub wrap_page_template {
    }

    my @stylesheets = [];
    if (-f File::Spec->catfile($self->{web_path}, ".static/styles/custom.css")) {
    my $custom_css_pathname = File::Spec->catfile($self->{input_path}, ".static/styles/custom.css");
    if (-f $custom_css_pathname) {
	@stylesheets = ['halp.css', 'custom.css'];
    } else {
	@stylesheets = ['halp.css'];


@@ 81,141 86,84 @@ sub wrap_page_template {
    return $result;
}

sub ok_200 {
    my ($self, $content_type, $content) = @_;

    my $length = bytes::length($content);

    print "HTTP/1.0 200 OK\r\n";
    print "Content-Type: $content_type;\r\n";
    print "Content-Length: " . $length . "\r\n";
    print "\r\n";
    print $content;

    return {'status' => 200, 'bytes' => $length};
}

sub method_not_allowed_405 {
    my ($self) = @_;

    my $template_pathname = File::Spec->catfile($self->{template_path}, '405.html.tpl');
    my $template = Text::Template->new(SOURCE => $template_pathname);

    my $result = $template->fill_in(HASH => {'path' => $request_path});
    $result = $self->wrap_page_template('Method not allowed', $result, 0, $request_path);

    my $length = length($result);
sub generate_file {
    my ($self, $source_file) = @_;

    print "HTTP/1.0 405 Method Not Allowed\r\n";
    print "Content-Type: text/html; charset=UTF-8\r\n";
    print "Content-Length: " . $length . "\r\n";
    print "\r\n";
    print $result;
    my $relative_file = File::Spec->abs2rel($source_file, $self->{input_path});
    my $output_file = File::Spec->catfile($self->{output_path}, $relative_file);

    return {'status' => 405, 'bytes' => $length};
}

sub not_found_404 {
    my ($self, $request_path) = @_;

    my $template_pathname = File::Spec->catfile($self->{template_path}, '404.html.tpl');
    my $template = Text::Template->new(SOURCE => $template_pathname);

    my $result = $template->fill_in(HASH => {'path' => $request_path});
    $result = $self->wrap_page_template('Page not found', $result, 0, $request_path);

    my $length = length($result);

    print "HTTP/1.0 404 Not Found\r\n";
    print "Content-Type: text/html; charset=UTF-8\r\n";
    print "Content-Length: " . $length . "\r\n";
    print "\r\n";
    print $result;

    return {'status' => 404, 'bytes' => $length};
}

sub handle_file {
    my ($self, $request_path, $local_path) = @_;
    my ($extension) = $local_path =~ /\.([^.]*)$/;
    my $mime_type = mime_types->{$extension};
    my $content = read_file($local_path, binmode => ':bytes');
    my ($extension) = $source_file =~ /\.([^.]*)$/;
    my $content = read_file($source_file, binmode => ':bytes');

    if ($extension eq "html") {
	$content = $self->wrap_page_template(file_title($local_path), $content, 0, $request_path, $local_path);
	$content = $self->wrap_page_template(file_title($source_file), $content, 0, $source_file);
    }

    return {content_type => $mime_type, content => $content};
    open my $content_file, '>', $output_file or die "Can't open '$output_file'";
    binmode $content_file;
    print $content_file $content;
    close $content_file;
}

sub handle_atom_feed {
    my ($self, $request_path, $local_path) = @_;
sub generate_directory {
    my ($self, $source_path) = @_;

    my $atom_feed = feed_for($request_path, $local_path, $self->{web_path}, $self->{domain}, $self->{author});
    return {content_type => 'application/xml+atom', content => $atom_feed};
}
    my $relative_path = File::Spec->abs2rel($source_path, $self->{input_path});
    my $output_path = File::Spec->catfile($self->{output_path}, $relative_path);

sub handle_directory {
    my ($self, $request_path, $local_path) = @_;
    if (!-e $output_path) {
	mkdir($output_path) or die "Unable to create directory '$output_path' - $!";
    }

    my $title = directory_title($local_path);
    my $title = directory_title($source_path);

    my $directory_description = read_file(File::Spec->catfile($local_path, '.description.html'));
    $directory_description =~ s/\s+$//;
    my $directory_description = "";
    my $description_pathname = File::Spec->catfile($source_path, '.description.html');
    if (-f $description_pathname) {
	$directory_description = read_file($description_pathname);
	$directory_description =~ s/\s+$//;
    }

    my $template_pathname = File::Spec->catfile($self->{template_path}, 'directory_listing.html.tpl');
    my $template = Text::Template->new(SOURCE => $template_pathname);
    my @items = directory_listing($request_path, $local_path, 0);
    my $result = $template->fill_in(HASH => {'path' => $request_path,
    my @items = directory_listing($source_path, 0);
    my $result = $template->fill_in(HASH => {'path' => $source_path,
						 'directory_description' => $directory_description,
						 'items' => \@items});

    $result = $self->wrap_page_template($title, $result, true, $request_path, $local_path);
    return {content_type => 'text/html; charset=UTF-8', content => $result};
}

sub handle_request {
    my ($self, $cgi) = @_;

    my $request_path = $cgi->path_info();
    if ($request_path =~ /^\s+$/) {
	$request_path = "/";
    }

    my $default_local_path = File::Spec->catfile($self->{default_site_path}, $request_path);
    my $local_path = File::Spec->catfile($self->{web_path}, $request_path);
    my $local_filename = basename($local_path);
    my $method = $ENV{REQUEST_METHOD};
    my $content;
    my $response;

    if ($method ne 'GET') {
	$response = $self->method_not_allowed_405();
    } elsif ($local_filename =~ m/^\./) {
	$response = $self->not_found_404($request_path);
    } elsif ($local_filename eq 'feed.xml') {
	$content = $self->handle_atom_feed($request_path, $local_path);
	$response = $self->ok_200($content->{content_type}, $content->{content});
    } elsif (-f $local_path) {
	$content = $self->handle_file($request_path, $local_path);
	$response = $self->ok_200($content->{content_type}, $content->{content});
    } elsif (-f $default_local_path) {
	$content = $self->handle_file($request_path, $default_local_path);
	$response = $self->ok_200($content->{content_type}, $content->{content});
    } elsif (-d $local_path) {
	$content = $self->handle_directory($request_path, $local_path);
	$response = $self->ok_200($content->{content_type}, $content->{content});
    } else {
	$response = $self->not_found_404($request_path);
    $result = $self->wrap_page_template($title, $result, 1, $source_path);

    my $index_file = File::Spec->catfile($output_path, "index.html");
    open my $file, '>', $index_file or die "Can't open '$index_file'";
    print $file $result;
    close $file;

    my $atom_file = File::Spec->catfile($output_path, "feed.xml");
    my $atom_feed = feed_for($source_path, $self->{web_path}, $self->{domain}, $self->{author});
    open my $file, '>', $atom_file or die "Can't open '$index_file'";
    print $file $atom_feed;
    close $file;

    my @files = <"$source_path/*" "$source_path/.*">;
    foreach $file (@files) {
	my $relative_file = basename($file);

	if (-f $file && $relative_file =~ m/^\./) {
	    # Ignore dotfiles
	} elsif ($relative_file =~ m/^\.+$/) {
	    # Ignore . and ..
	} elsif (-d $file) {
	    $self->generate_directory($file);
	} elsif (-f $file) {
	    $self->generate_file($file);
	}
    }
}

    my $host = $cgi->remote_addr();
    my $ident = "-";
    my $authuser = "-";
    my $date = localtime->strftime('%d/%b/%Y:%H:%M:%S %z');
    my $request = "$method $request_path HTTP/1.0";
sub generate {
    my ($self) = @_;

    printf STDERR "%s %s %s [%s] \"%s\" %d %d\n", $host, $ident, $authuser, $date, $request, $response->{status}, $response->{bytes};
    dircopy($self->{default_site_path}, $self->{output_path}) or die $!;
    $self->generate_directory($self->{input_path});
}

1;

D run-dev-server.sh => run-dev-server.sh +0 -47
@@ 1,47 0,0 @@
#!/usr/bin/env bash

set -e
shellcheck "$0"

check_dep() {
    command_name="$1"
    if ! command -v "$command_name" >/dev/null 2>&1; then
	echo "$command_name not found"
	exit 1
    fi
}

cleanup() {
    kill_halp
    exit
}

kill_halp() {
    if [ -n "$halp_pid" ]; then
	kill -15 $halp_pid || true
	wait $halp_pid || true
    fi
    echo "halp is dead" >&2
}

utils=("find" "perl" "wait_on")
for util in "${utils[@]}"; do
    check_dep "$util"
done

halp_pid=""
trap cleanup EXIT

while true; do
    perl ./halp.pl --config-filename ./dev-config/halp-config.pl "$1" &
    halp_pid=$!
    if [ -z "$halp_pid" ]; then
	echo "failed to start halp" >&2
    else
	echo "long live halp" >&2
    fi

    # shellcheck disable=SC2046
    wait_on ./halp.pl $(find lib/Halp -type f -o -type d) || true
    kill_halp
done

D run-tests.sh => run-tests.sh +0 -24
@@ 1,24 0,0 @@
#!/usr/bin/env bash

set -e
shellcheck "$0"

check_dep() {
    command_name="$1"
    if ! command -v "$command_name" >/dev/null 2>&1; then
	echo "$command_name not found"
	exit 1
    fi
}

utils=("find" "perl" "wait_on")
for util in "${utils[@]}"; do
    check_dep "$util"
done

while true; do
    prove -rv t || echo "FAILED" 1>&2

    # shellcheck disable=SC2046
    wait_on ./halp.pl $(find lib/Halp -type f -o -type d) $(find t -type f -o -type d) $(find www -type f -o -type d) || true
done

R freebsd/install-prereqs.sh => scripts/freebsd-dependencies.sh +1 -0
@@ 10,6 10,7 @@ shellcheck "$0"

pkg install -y \
    p5-Clone \
    p5-File-Copy-Recursive \
    p5-File-Slurp \
    p5-HTTP-Server-Simple \
    p5-Mojolicious \

R linux/install-prereqs.sh => scripts/ubuntu-dependencies.sh +1 -0
@@ 5,6 5,7 @@ set -euxo pipefail
shellcheck "$0"

cpan Clone
cpan File::Copy::Recursive
cpan File::Slurp
cpan HTTP::Server::Simple
cpan Mojolicious

M t/Halp/web_server_test.t => t/Halp/web_server_test.t +2 -2
@@ 144,14 144,14 @@ sub run_root_tests {
}

sub run_static_file_tests {
   my $response = $ua->get('http://localhost:8088/.static/styles/halp.css');
   my $response = $ua->get('http://localhost:8088/static/styles/halp.css');
   my $css = Mojo::DOM->new($response->content);

   is($response->code, 200, 'Requesting a static CSS file returns 200.');
   is($response->content_type, 'text/css', 'Static CSS file is served with the correct content type.');
   like($response->content, qr/^body \{/, 'Static CSS file starts with body selector.');

   $response = $ua->get('http://localhost:8088/.static/images/avatar.jpg');
   $response = $ua->get('http://localhost:8088/static/images/avatar.jpg');
   is($response->code, 200, 'Requesting a static JPG file returns 200.');
   is($response->content_type, 'image/jpeg', 'Static JPG file is served with the correct content type.');
}

A t/fixtures/www/miscellaneous/.description.html => t/fixtures/www/miscellaneous/.description.html +1 -0
@@ 0,0 1,1 @@
A description

A t/fixtures/www/projects/halp/.description.html => t/fixtures/www/projects/halp/.description.html +1 -0
@@ 0,0 1,1 @@
Halp

D t/fixtures/www/projects/halp/index.html => t/fixtures/www/projects/halp/index.html +0 -3
@@ 1,3 0,0 @@
<p>
    This is the index file for the halp project.
</p>

A t/fixtures/www/projects/unicomp-overhaul/.description.html => t/fixtures/www/projects/unicomp-overhaul/.description.html +1 -0
@@ 0,0 1,1 @@
Unicomp Overhaul