cassini/main.go

238 lines
5.6 KiB
Go
Raw Permalink Normal View History

2021-12-18 08:25:16 +00:00
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"io"
"log"
"math/rand"
"net"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
)
var (
argSock string
argList string
argIndex string
listMod int64 = 0
)
func main() {
flag.StringVar(&argSock, "sock", "/var/run/cassini.sock", "socket to listen on")
flag.StringVar(&argList, "list", "ring.txt", "plaintext file containing a sorted list of capsules")
flag.StringVar(&argIndex, "index", "index.gmi", "gemtext file to show on /")
flag.Parse()
log.Println("socket:\t", argSock)
log.Println("list:\t", argList)
log.Println("index:\t", argIndex)
log.Println()
// load list
capsules := loadList()
// listen for connections
listener, err := net.Listen("unix", argSock)
if err != nil {
log.Fatal("unable to listen: ", err)
}
log.Println("listening for connections over unix socket:", argSock)
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGTERM)
go func() {
<-c
listener.Close()
os.Exit(0)
}()
for {
conn, err := listener.Accept()
if err != nil {
continue
}
log.Println("accepted connection")
go serveRing(conn, capsules)
}
}
// serveRing checks the URL, ensures it is valid and sanity checked,
// then parses it and serves the correct gemini page
func serveRing(conn io.ReadWriteCloser, capsules [][]string) {
defer conn.Close()
reader := bufio.NewReader(conn)
// check request size
lengthString, err := reader.ReadString(':')
if err != nil {
log.Println("connection closed: error reading request size")
response(conn, 50, "could not read request size")
return
}
length, _ := strconv.Atoi(strings.TrimSuffix(lengthString, ":"))
if length > 1024 {
log.Println("connection closed: incoming request too big")
response(conn, 50, "request too big")
return
}
// get SCGI headers
headers := make([]byte, length)
if _, err := reader.Read(headers); err != nil {
log.Println("connection closed: error reading headers")
response(conn, 50, "error reading headers")
return
}
parts := strings.Split(string(headers), "\x00")
var pathString string
for i := 0; i < len(parts)-1; i += 2 {
if parts[i] == "PATH_INFO" {
pathString = parts[i+1]
}
}
log.Println("incoming URL:", pathString)
var path []string
if len(pathString) > 0 {
path = strings.Split(pathString, "/")
} else {
path = []string{""}
}
// remove trailing slash
if path[len(path)-1] == "" {
path = path[:len(path)-1]
}
// update list if necessary
if isListUpdated() {
capsules = loadList()
}
if len(path) == 2 {
for i, capsule := range capsules {
if path[1] == capsule[0] {
var destination string
switch path[0] {
case "previous":
if i == 0 {
destination = capsules[len(capsules)-1][1]
} else {
destination = capsules[i-1][1]
}
case "next":
if i == len(capsules)-1 {
destination = capsules[0][1]
} else {
destination = capsules[i+1][1]
}
default:
log.Println("connection closed: not found")
response(conn, 51, "not found")
return
}
log.Println("redirecting to", destination)
response(conn, 30, destination)
return
}
}
} else if len(path) == 1 && path[0] == "random" {
// serve random page
destination := capsules[rand.Intn(len(capsules))][1]
log.Println("redirecting to", destination)
response(conn, 30, destination)
return
} else if len(path) == 0 {
// serve index page
log.Println("serving index page")
content, err := os.ReadFile(argIndex)
if err != nil {
log.Fatal("error while loading index file: ", err)
}
// if contains {{ list }}, replace it with the list
if bytes.Contains(content, []byte("{{ list }}")) {
log.Println("inserting ring member list")
var links string
for _, capsule := range capsules {
links += fmt.Sprintf("=> %s %s\n", capsule[1], capsule[0])
}
content = bytes.Replace(content, []byte("{{ list }}"), []byte(links), -1)
}
response(conn, 20, "text/gemini; lang=en; charset=utf-8")
_, err = conn.Write(content)
if err != nil {
log.Println("error while writing to the connection:", err)
}
return
}
log.Println("connection closed: not found")
response(conn, 51, "not found")
return
}
// response sends a gemini response header
func response(conn io.ReadWriteCloser, code int, message string) {
_, err := conn.Write([]byte(fmt.Sprintf("%d %s\r\n", code, message)))
if err != nil {
log.Println("error while writing to the connection:", err)
}
}
// loadList parses the plaintext file containing the list of members
// and their URLs, and returns a 2d string slice
func loadList() [][]string {
// load file
file, err := os.ReadFile(argList)
if err != nil {
log.Fatal("error while loading list: ", err)
}
// split file into 2d slice
var capsules [][]string
lines := strings.Split(string(file), "\n")
for _, line := range lines[:len(lines)-1] {
capsules = append(capsules, strings.Split(line, " "))
}
// store last modified time
metadata, err := os.Stat(argList)
if err != nil {
log.Fatal("error while getting list metadata: ", err)
}
listMod = metadata.ModTime().Unix()
log.Printf("loaded %d capsules:", len(capsules))
for _, capsule := range capsules {
log.Printf("\t%s:\t%s\n", capsule[0], capsule[1])
}
log.Println()
return capsules
}
// isListUpdated checks the last modified time of the list file,
// compares it against the last recorded modified time, and returns
// true if the file has been updated
func isListUpdated() bool {
metadata, err := os.Stat(argList)
if err != nil {
log.Fatal("error while getting list metadata: ", err)
}
return metadata.ModTime().Unix() > listMod
}