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 }