diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 69a889c..ea54d75 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -1,3 +1,7 @@ +Paths may be defined as fixed strings or regular expressions (starting with `^`). + +Fixed string paths will match with and without a trailing slash. + # config.yaml ```yaml @@ -8,12 +12,12 @@ key: /home/twins/data/keyfile.key # Paths to serve serve: - - dir: /sites + path: /sites root: /home/twins/data/sites - - regexp: ^/(help|info)$ + path: ^/(help|info)$ root: /home/twins/data/help - - dir: / + path: / root: /home/twins/data/home ``` \ No newline at end of file diff --git a/config.go b/config.go index 052063f..94f8b2b 100644 --- a/config.go +++ b/config.go @@ -10,17 +10,17 @@ import ( ) type serveConfig struct { - Dir string - Regexp string - Root string + Path string + Root string r *regexp.Regexp } type serverConfig struct { - Cert string - Key string - Serve []*serveConfig + Cert string + Key string + Address string + Serve []*serveConfig } var config = &serverConfig{} @@ -45,11 +45,14 @@ func readconfig(configPath string) error { } for _, serve := range config.Serve { - if serve.Dir != "" && serve.Dir[len(serve.Dir)-1] == '/' { - serve.Dir = serve.Dir[:len(serve.Dir)-1] + if serve.Path == "" { + continue } - if serve.Regexp != "" { - serve.r = regexp.MustCompile(serve.Regexp) + + if serve.Path[0] == '^' { + serve.r = regexp.MustCompile(serve.Path) + } else if serve.Path[len(serve.Path)-1] == '/' { + serve.Path = serve.Path[:len(serve.Path)-1] } } diff --git a/main.go b/main.go index 5e3a3e3..89ffdb9 100644 --- a/main.go +++ b/main.go @@ -1,165 +1,14 @@ package main import ( - "bufio" - "bytes" - "crypto/tls" "flag" - "fmt" - "io" "log" - "net" - "net/url" "os" "path" - "strings" - - "github.com/h2non/filetype" - "github.com/makeworld-the-better-one/go-gemini" ) -func writeHeader(c net.Conn, code int, meta string) { - fmt.Fprintf(c, "%d %s\r\n", code, meta) -} - -func respond(c net.Conn, code int) { - var meta string - switch code { - case gemini.StatusTemporaryFailure: - meta = "Temporary failure" - case gemini.StatusBadRequest: - meta = "Bad request" - case gemini.StatusNotFound: - meta = "Not found" - } - writeHeader(c, code, meta) -} - -func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) { - if atEOF && len(data) == 0 { - return 0, nil, nil - } - if i := bytes.IndexByte(data, '\r'); i >= 0 { - // We have a full newline-terminated line. - return i + 1, data[0:i], nil - } - // If we're at EOF, we have a final, non-terminated line. Return it. - if atEOF { - return len(data), data, nil - } - // Request more data. - return 0, nil, nil -} - -func handleConn(c net.Conn) { - defer c.Close() - - var requestData string - scanner := bufio.NewScanner(c) - scanner.Split(scanCRLF) - if scanner.Scan() { - requestData = scanner.Text() - } - if err := scanner.Err(); err != nil { - respond(c, gemini.StatusBadRequest) - return - } - - request, err := url.Parse(requestData) - if err != nil || request.Scheme != "gemini" || request.Host == "" { - respond(c, gemini.StatusBadRequest) - return - } - if request.Path == "" { - request.Path = "/" - } - pathBytes := []byte(request.Path) - strippedPath := request.Path - if strippedPath[0] == '/' { - strippedPath = strippedPath[1:] - } - - for _, serve := range config.Serve { - var realPath string - if serve.Dir != "" && strings.HasPrefix(request.Path, serve.Dir) { - realPath = path.Join(serve.Root, request.Path[len(serve.Dir):]) - } else if serve.r != nil && serve.r.Match(pathBytes) { - realPath = path.Join(serve.Root, strippedPath) - } else { - continue - } - - fi, err := os.Stat(realPath) - if os.IsNotExist(err) { - respond(c, gemini.StatusNotFound) - return - } else if err != nil { - respond(c, gemini.StatusTemporaryFailure) - return - } - - if mode := fi.Mode(); mode.IsDir() { - _, err := os.Stat(path.Join(realPath, "index.gemini")) - if err == nil { - realPath = path.Join(realPath, "index.gemini") - } else { - realPath = path.Join(realPath, "index.gmi") - } - } - - fi, err = os.Stat(realPath) - if os.IsNotExist(err) { - respond(c, gemini.StatusNotFound) - return - } else if err != nil { - respond(c, gemini.StatusTemporaryFailure) - return - } - - file, _ := os.Open(realPath) - defer file.Close() - - buf := make([]byte, 261) - n, _ := file.Read(buf) - - mimeType := "text/gemini; charset=utf-8" - if !strings.HasSuffix(realPath, ".gmi") && !strings.HasSuffix(realPath, ".gemini") { - if strings.HasSuffix(realPath, ".html") && strings.HasSuffix(realPath, ".htm") { - mimeType = "text/html; charset=utf-8" - } else if strings.HasSuffix(realPath, ".txt") && strings.HasSuffix(realPath, ".text") { - mimeType = "text/plain; charset=utf-8" - } else { - kind, _ := filetype.Match(buf[:n]) - if kind != filetype.Unknown { - mimeType = kind.MIME.Value - } - } - } - - writeHeader(c, gemini.StatusSuccess, mimeType) - c.Write(buf[:n]) - io.Copy(c, file) - return - } - - respond(c, gemini.StatusNotFound) -} - -func handleListener(l net.Listener) { - for { - conn, err := l.Accept() - if err != nil { - log.Fatal(err) - } - - go handleConn(conn) - } -} - func main() { configFile := flag.String("config", "", "path to configuration file") - certFile := flag.String("cert", "", "path to certificate file") - keyFile := flag.String("key", "", "path to private key file") flag.Parse() if *configFile == "" { @@ -170,34 +19,17 @@ func main() { } err := readconfig(*configFile) - if err != nil && *certFile == "" { + if err != nil { log.Fatalf("failed to read configuration file at %s: %v\nSee CONFIGURATION.md for information on configuring twins", *configFile, err) } - if *certFile != "" { - config.Cert = *certFile - } - if *keyFile != "" { - config.Key = *keyFile + if config.Address == "" { + log.Fatal("listen address must be specified") } if config.Cert == "" || config.Key == "" { log.Fatal("certificate file and private key must be specified (gemini requires TLS for all connections)") } - cert, err := tls.LoadX509KeyPair(config.Cert, config.Key) - if err != nil { - log.Fatalf("failed to load certificate: %s", err) - } - - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - } - - listener, err := tls.Listen("tcp", "localhost:8888", tlsConfig) - if err != nil { - log.Fatalf("failed to listen on %s: %s", "localhost:8888", err) - } - - handleListener(listener) + listen(config.Address, config.Cert, config.Key) } diff --git a/server.go b/server.go new file mode 100644 index 0000000..6df72f8 --- /dev/null +++ b/server.go @@ -0,0 +1,183 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/tls" + "fmt" + "io" + "log" + "net" + "net/url" + "os" + "path" + "strings" + + "github.com/h2non/filetype" + "github.com/makeworld-the-better-one/go-gemini" +) + +func writeHeader(c net.Conn, code int, meta string) { + fmt.Fprintf(c, "%d %s\r\n", code, meta) +} + +func writeStatus(c net.Conn, code int) { + var meta string + switch code { + case gemini.StatusTemporaryFailure: + meta = "Temporary failure" + case gemini.StatusBadRequest: + meta = "Bad request" + case gemini.StatusNotFound: + meta = "Not found" + } + writeHeader(c, code, meta) +} + +func serveFile(c net.Conn, filePath string) { + fi, err := os.Stat(filePath) + if os.IsNotExist(err) { + writeStatus(c, gemini.StatusNotFound) + return + } else if err != nil { + writeStatus(c, gemini.StatusTemporaryFailure) + return + } + + if mode := fi.Mode(); mode.IsDir() { + _, err := os.Stat(path.Join(filePath, "index.gemini")) + if err == nil { + filePath = path.Join(filePath, "index.gemini") + } else { + filePath = path.Join(filePath, "index.gmi") + } + } + + fi, err = os.Stat(filePath) + if os.IsNotExist(err) { + writeStatus(c, gemini.StatusNotFound) + return + } else if err != nil { + writeStatus(c, gemini.StatusTemporaryFailure) + return + } + + // Open file + file, _ := os.Open(filePath) + defer file.Close() + + // Read file header + buf := make([]byte, 261) + n, _ := file.Read(buf) + + // Write header + var mimeType string + if strings.HasSuffix(filePath, ".html") && strings.HasSuffix(filePath, ".htm") { + mimeType = "text/html; charset=utf-8" + } else if strings.HasSuffix(filePath, ".txt") && strings.HasSuffix(filePath, ".text") { + mimeType = "text/plain; charset=utf-8" + } else if !strings.HasSuffix(filePath, ".gmi") && !strings.HasSuffix(filePath, ".gemini") { + kind, _ := filetype.Match(buf[:n]) + if kind != filetype.Unknown { + mimeType = kind.MIME.Value + } + } + if mimeType == "" { + mimeType = "text/gemini; charset=utf-8" + } + writeHeader(c, gemini.StatusSuccess, mimeType) + + // Write body + c.Write(buf[:n]) + io.Copy(c, file) +} + +func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexByte(data, '\r'); i >= 0 { + // We have a full newline-terminated line. + return i + 1, data[0:i], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} + +func handleConn(c net.Conn) { + defer c.Close() + + var requestData string + scanner := bufio.NewScanner(c) + scanner.Split(scanCRLF) + if scanner.Scan() { + requestData = scanner.Text() + } + if err := scanner.Err(); err != nil { + writeStatus(c, gemini.StatusBadRequest) + return + } + + request, err := url.Parse(requestData) + if err != nil || request.Scheme != "gemini" || request.Host == "" { + writeStatus(c, gemini.StatusBadRequest) + return + } + if request.Path == "" { + request.Path = "/" + } + pathBytes := []byte(request.Path) + strippedPath := request.Path + if strippedPath[0] == '/' { + strippedPath = strippedPath[1:] + } + + var realPath string + for _, serve := range config.Serve { + if serve.r != nil && serve.r.Match(pathBytes) { + realPath = path.Join(serve.Root, strippedPath) + } else if serve.r == nil && strings.HasPrefix(request.Path, serve.Path) { + realPath = path.Join(serve.Root, request.Path[len(serve.Path):]) + } else { + continue + } + + serveFile(c, realPath) + return + } + + writeStatus(c, gemini.StatusNotFound) +} + +func handleListener(l net.Listener) { + for { + conn, err := l.Accept() + if err != nil { + log.Fatal(err) + } + + go handleConn(conn) + } +} + +func listen(address, certFile, keyFile string) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + log.Fatalf("failed to load certificate: %s", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + listener, err := tls.Listen("tcp", address, tlsConfig) + if err != nil { + log.Fatalf("failed to listen on %s: %s", address, err) + } + + handleListener(listener) +}