238 lines
5.6 KiB
Go
238 lines
5.6 KiB
Go
|
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
|
||
|
}
|