Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
**/.#*
|
||||||
|
**/*~
|
||||||
|
/.idea
|
||||||
|
/run.sh
|
||||||
15
LICENSE
Normal file
15
LICENSE
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright (c) Antanst 2025
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
|
copyright notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
47
Makefile
Normal file
47
Makefile
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
SHELL := /bin/env oksh
|
||||||
|
export PATH := $(PATH)
|
||||||
|
|
||||||
|
all: fmt lintfix tidy test clean build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f ./gemserve
|
||||||
|
|
||||||
|
debug:
|
||||||
|
@echo "PATH: $(PATH)"
|
||||||
|
@echo "GOPATH: $(shell go env GOPATH)"
|
||||||
|
@which go
|
||||||
|
@which gofumpt
|
||||||
|
@which gci
|
||||||
|
@which golangci-lint
|
||||||
|
|
||||||
|
# Test
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
gofumpt -l -w .
|
||||||
|
gci write .
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
lint: fmt
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Run linter and fix
|
||||||
|
lintfix: fmt
|
||||||
|
golangci-lint run --fix
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o ./gemserve ./main.go
|
||||||
|
|
||||||
|
show-updates:
|
||||||
|
go list -m -u all
|
||||||
|
|
||||||
|
update:
|
||||||
|
go get -u all
|
||||||
|
|
||||||
|
update-patch:
|
||||||
|
go get -u=patch all
|
||||||
33
README.md
Normal file
33
README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
```
|
||||||
|
__ _ ___ _ __ ___ ___ ___ _ ____ _____
|
||||||
|
/ _` |/ _ | '_ ` _ \/ __|/ _ | '__\ \ / / _ \
|
||||||
|
| (_| | __| | | | | \__ | __| | \ V | __/
|
||||||
|
\__, |\___|_| |_| |_|___/\___|_| \_/ \___|
|
||||||
|
|___/
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemserve is a simple Gemini server written in Go.
|
||||||
|
|
||||||
|
Run tests and build:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
make test #run tests only
|
||||||
|
make #run tests and build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
LOG_LEVEL=info \
|
||||||
|
PANIC_ON_UNEXPECTED_ERROR=true \
|
||||||
|
RESPONSE_TIMEOUT=10 \ #seconds
|
||||||
|
ROOT_PATH=./srv \
|
||||||
|
DIR_INDEXING_ENABLED=false \
|
||||||
|
./gemserve 0.0.0.0:1965
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll need TLS keys, you can use `certs/generate.sh`
|
||||||
|
for quick generation.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
- [ ] Fix slowloris (proper response timeouts)
|
||||||
2
certs/.gitignore
vendored
Normal file
2
certs/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ca*
|
||||||
|
server*
|
||||||
20
certs/generate.sh
Executable file
20
certs/generate.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Generate private key for CA
|
||||||
|
openssl genrsa -out ca.key 4096
|
||||||
|
|
||||||
|
# Generate CA certificate
|
||||||
|
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt \
|
||||||
|
-subj "/C=US/ST=State/L=City/O=Organization/CN=My CA"
|
||||||
|
|
||||||
|
# Generate private key for server
|
||||||
|
openssl genrsa -out server.key 2048
|
||||||
|
|
||||||
|
# Generate Certificate Signing Request (CSR) for server
|
||||||
|
openssl req -new -key server.key -out server.csr \
|
||||||
|
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"
|
||||||
|
|
||||||
|
# Generate server certificate signed by our CA
|
||||||
|
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
|
||||||
|
-CAcreateserial -out server.crt -days 3650 -sha256
|
||||||
242
common/gemini_url.go
Normal file
242
common/gemini_url.go
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gemserve/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type URL struct {
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Descr string `json:"descr,omitempty"`
|
||||||
|
Full string `json:"full,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *URL) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
// Clear the fields in the current GeminiUrl object (not the pointer itself)
|
||||||
|
*u = URL{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return errors.NewFatalError(fmt.Errorf("database scan error: expected string, got %T", value))
|
||||||
|
}
|
||||||
|
parsedURL, err := ParseURL(b, "", false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*u = *parsedURL
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u URL) String() string {
|
||||||
|
return u.Full
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u URL) StringNoDefaultPort() string {
|
||||||
|
if u.Port == 1965 {
|
||||||
|
return fmt.Sprintf("%s://%s%s", u.Protocol, u.Hostname, u.Path)
|
||||||
|
}
|
||||||
|
return u.Full
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u URL) Value() (driver.Value, error) {
|
||||||
|
if u.Full == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return u.Full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseURL(input string, descr string, normalize bool) (*URL, error) {
|
||||||
|
var u *url.URL
|
||||||
|
var err error
|
||||||
|
if normalize {
|
||||||
|
u, err = NormalizeURL(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
u, err = url.Parse(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewError(fmt.Errorf("error parsing URL: %w: %s", err, input))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Scheme != "gemini" {
|
||||||
|
return nil, errors.NewError(fmt.Errorf("error parsing URL: not a gemini URL: %s", input))
|
||||||
|
}
|
||||||
|
protocol := u.Scheme
|
||||||
|
hostname := u.Hostname()
|
||||||
|
strPort := u.Port()
|
||||||
|
urlPath := u.EscapedPath()
|
||||||
|
if strPort == "" {
|
||||||
|
strPort = "1965"
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(strPort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewError(fmt.Errorf("error parsing URL: %w: %s", err, input))
|
||||||
|
}
|
||||||
|
full := fmt.Sprintf("%s://%s:%d%s", protocol, hostname, port, urlPath)
|
||||||
|
// full field should also contain query params and url fragments
|
||||||
|
if u.RawQuery != "" {
|
||||||
|
full += "?" + u.RawQuery
|
||||||
|
}
|
||||||
|
if u.Fragment != "" {
|
||||||
|
full += "#" + u.Fragment
|
||||||
|
}
|
||||||
|
return &URL{Protocol: protocol, Hostname: hostname, Port: port, Path: urlPath, Descr: descr, Full: full}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveAbsoluteURL converts a (possibly) relative
|
||||||
|
// URL to an absolute one. Used primarily to calculate
|
||||||
|
// the full redirection URL target from a response header.
|
||||||
|
func DeriveAbsoluteURL(currentURL URL, input string) (*URL, error) {
|
||||||
|
// If target URL is absolute, return just it
|
||||||
|
if strings.Contains(input, "://") {
|
||||||
|
return ParseURL(input, "", true)
|
||||||
|
}
|
||||||
|
// input is a relative path. Clean it and construct absolute.
|
||||||
|
var newPath string
|
||||||
|
// Handle weird cases found in the wild
|
||||||
|
if strings.HasPrefix(input, "/") {
|
||||||
|
newPath = path.Clean(input)
|
||||||
|
} else if input == "./" || input == "." {
|
||||||
|
newPath = path.Join(currentURL.Path, "/")
|
||||||
|
} else {
|
||||||
|
newPath = path.Join(currentURL.Path, "/", path.Clean(input))
|
||||||
|
}
|
||||||
|
strURL := fmt.Sprintf("%s://%s:%d%s", currentURL.Protocol, currentURL.Hostname, currentURL.Port, newPath)
|
||||||
|
return ParseURL(strURL, "", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeURL takes a URL string and returns a normalized version
|
||||||
|
// Normalized meaning:
|
||||||
|
// - Path normalization (removing redundant slashes, . and .. segments)
|
||||||
|
// - Proper escaping of special characters
|
||||||
|
// - Lowercase scheme and host
|
||||||
|
// - Removal of default ports
|
||||||
|
// - Empty path becomes "/"
|
||||||
|
func NormalizeURL(rawURL string) (*url.URL, error) {
|
||||||
|
// Parse the URL
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewError(fmt.Errorf("error normalizing URL: %w: %s", err, rawURL))
|
||||||
|
}
|
||||||
|
if u.Scheme == "" {
|
||||||
|
return nil, errors.NewError(fmt.Errorf("error normalizing URL: No scheme: %s", rawURL))
|
||||||
|
}
|
||||||
|
if u.Host == "" {
|
||||||
|
return nil, errors.NewError(fmt.Errorf("error normalizing URL: No host: %s", rawURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert scheme to lowercase
|
||||||
|
u.Scheme = strings.ToLower(u.Scheme)
|
||||||
|
|
||||||
|
// Convert hostname to lowercase
|
||||||
|
if u.Host != "" {
|
||||||
|
u.Host = strings.ToLower(u.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove default ports
|
||||||
|
if u.Port() != "" {
|
||||||
|
switch {
|
||||||
|
case u.Scheme == "http" && u.Port() == "80":
|
||||||
|
u.Host = u.Hostname()
|
||||||
|
case u.Scheme == "https" && u.Port() == "443":
|
||||||
|
u.Host = u.Hostname()
|
||||||
|
case u.Scheme == "gemini" && u.Port() == "1965":
|
||||||
|
u.Host = u.Hostname()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle path normalization while preserving trailing slash
|
||||||
|
if u.Path != "" {
|
||||||
|
// Check if there was a trailing slash before cleaning
|
||||||
|
hadTrailingSlash := strings.HasSuffix(u.Path, "/")
|
||||||
|
|
||||||
|
u.Path = path.Clean(u.EscapedPath())
|
||||||
|
// If path was "/", path.Clean() will return "."
|
||||||
|
if u.Path == "." {
|
||||||
|
u.Path = "/"
|
||||||
|
} else if hadTrailingSlash && u.Path != "/" {
|
||||||
|
// Restore trailing slash if it existed and path isn't just "/"
|
||||||
|
u.Path += "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Properly escape the path
|
||||||
|
// First split on '/' to avoid escaping them
|
||||||
|
parts := strings.Split(u.Path, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
parts[i] = url.PathEscape(part)
|
||||||
|
}
|
||||||
|
u.Path = strings.Join(parts, "/")
|
||||||
|
|
||||||
|
// Remove trailing fragment if empty
|
||||||
|
if u.Fragment == "" {
|
||||||
|
u.Fragment = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing query if empty
|
||||||
|
if u.RawQuery == "" {
|
||||||
|
u.RawQuery = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EscapeURL(input string) string {
|
||||||
|
// Only escape if not already escaped
|
||||||
|
if strings.Contains(input, "%") && !strings.Contains(input, "% ") {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
// Split URL into parts (protocol, host, p)
|
||||||
|
parts := strings.SplitN(input, "://", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := parts[0]
|
||||||
|
remainder := parts[1]
|
||||||
|
|
||||||
|
// If URL ends with just a slash, return as is
|
||||||
|
if strings.HasSuffix(remainder, "/") && !strings.Contains(remainder[:len(remainder)-1], "/") {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split host and p
|
||||||
|
parts = strings.SplitN(remainder, "/", 2)
|
||||||
|
host := parts[0]
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return protocol + "://" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape the path portion
|
||||||
|
escapedPath := url.PathEscape(parts[1])
|
||||||
|
|
||||||
|
// Reconstruct the URL
|
||||||
|
return protocol + "://" + host + "/" + escapedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePath trims trailing slash and handles empty path
|
||||||
|
func TrimTrailingPathSlash(path string) string {
|
||||||
|
// Handle empty path (e.g., "http://example.com" -> treat as root)
|
||||||
|
if path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim trailing slash while preserving root slash
|
||||||
|
path = strings.TrimSuffix(path, "/")
|
||||||
|
if path == "" { // This happens if path was just "/"
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
384
common/gemini_url_test.go
Normal file
384
common/gemini_url_test.go
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://caolan.uk/cgi-bin/weather.py/wxfcs/3162"
|
||||||
|
parsed, err := ParseURL(input, "", true)
|
||||||
|
value, _ := parsed.Value()
|
||||||
|
if err != nil || !(value == "gemini://caolan.uk:1965/cgi-bin/weather.py/wxfcs/3162") {
|
||||||
|
t.Errorf("fail: %s", parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAbsoluteURL_abs_url_input(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
currentURL := URL{
|
||||||
|
Protocol: "gemini",
|
||||||
|
Hostname: "smol.gr",
|
||||||
|
Port: 1965,
|
||||||
|
Path: "/a/b",
|
||||||
|
Descr: "Nothing",
|
||||||
|
Full: "gemini://smol.gr:1965/a/b",
|
||||||
|
}
|
||||||
|
input := "gemini://a.b/c"
|
||||||
|
output, err := DeriveAbsoluteURL(currentURL, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("fail: %v", err)
|
||||||
|
}
|
||||||
|
expected := &URL{
|
||||||
|
Protocol: "gemini",
|
||||||
|
Hostname: "a.b",
|
||||||
|
Port: 1965,
|
||||||
|
Path: "/c",
|
||||||
|
Descr: "",
|
||||||
|
Full: "gemini://a.b:1965/c",
|
||||||
|
}
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAbsoluteURL_abs_path_input(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
currentURL := URL{
|
||||||
|
Protocol: "gemini",
|
||||||
|
Hostname: "smol.gr",
|
||||||
|
Port: 1965,
|
||||||
|
Path: "/a/b",
|
||||||
|
Descr: "Nothing",
|
||||||
|
Full: "gemini://smol.gr:1965/a/b",
|
||||||
|
}
|
||||||
|
input := "/c"
|
||||||
|
output, err := DeriveAbsoluteURL(currentURL, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("fail: %v", err)
|
||||||
|
}
|
||||||
|
expected := &URL{
|
||||||
|
Protocol: "gemini",
|
||||||
|
Hostname: "smol.gr",
|
||||||
|
Port: 1965,
|
||||||
|
Path: "/c",
|
||||||
|
Descr: "",
|
||||||
|
Full: "gemini://smol.gr:1965/c",
|
||||||
|
}
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveAbsoluteURL_rel_path_input(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
currentURL := URL{
|
||||||
|
Protocol: "gemini",
|
||||||
|
Hostname: "smol.gr",
|
||||||
|
Port: 1965,
|
||||||
|
Path: "/a/b",
|
||||||
|
Descr: "Nothing",
|
||||||
|
Full: "gemini://smol.gr:1965/a/b",
|
||||||
|
}
|
||||||
|
input := "c/d"
|
||||||
|
output, err := DeriveAbsoluteURL(currentURL, input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("fail: %v", err)
|
||||||
|
}
|
||||||
|
expected := &URL{
|
||||||
|
Protocol: "gemini",
|
||||||
|
Hostname: "smol.gr",
|
||||||
|
Port: 1965,
|
||||||
|
Path: "/a/b/c/d",
|
||||||
|
Descr: "",
|
||||||
|
Full: "gemini://smol.gr:1965/a/b/c/d",
|
||||||
|
}
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeURLSlash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net/retro-computing/magazines/"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := input
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeURLNoSlash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net/retro-computing/magazines"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := input
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMultiSlash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net/retro-computing/////////a///magazines"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := "gemini://uscoffings.net/retro-computing/a/magazines"
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTrailingSlash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net/"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := "gemini://uscoffings.net/"
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeNoTrailingSlash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := "gemini://uscoffings.net"
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeTrailingSlashPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net/a/"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := "gemini://uscoffings.net/a/"
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeNoTrailingSlashPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net/a"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := "gemini://uscoffings.net/a"
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net/retro-computing/./././////a///magazines"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := "gemini://uscoffings.net/retro-computing/a/magazines"
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePort(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://uscoffings.net:1965/a"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := "gemini://uscoffings.net/a"
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeURL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := "gemini://chat.gemini.lehmann.cx:11965/"
|
||||||
|
normalized, _ := NormalizeURL(input)
|
||||||
|
output := normalized.String()
|
||||||
|
expected := "gemini://chat.gemini.lehmann.cx:11965/"
|
||||||
|
pass := reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
input = "gemini://chat.gemini.lehmann.cx:11965/index?a=1&b=c"
|
||||||
|
normalized, _ = NormalizeURL(input)
|
||||||
|
output = normalized.String()
|
||||||
|
expected = "gemini://chat.gemini.lehmann.cx:11965/index?a=1&b=c"
|
||||||
|
pass = reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
input = "gemini://chat.gemini.lehmann.cx:11965/index#1"
|
||||||
|
normalized, _ = NormalizeURL(input)
|
||||||
|
output = normalized.String()
|
||||||
|
expected = "gemini://chat.gemini.lehmann.cx:11965/index#1"
|
||||||
|
pass = reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
input = "gemini://gemi.dev/cgi-bin/xkcd.cgi?1494"
|
||||||
|
normalized, _ = NormalizeURL(input)
|
||||||
|
output = normalized.String()
|
||||||
|
expected = "gemini://gemi.dev/cgi-bin/xkcd.cgi?1494"
|
||||||
|
pass = reflect.DeepEqual(output, expected)
|
||||||
|
if !pass {
|
||||||
|
t.Errorf("fail: %#v != %#v", output, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string // URL string to parse
|
||||||
|
expected string // Expected normalized path
|
||||||
|
}{
|
||||||
|
// Basic cases
|
||||||
|
{
|
||||||
|
name: "empty_path",
|
||||||
|
input: "http://example.com",
|
||||||
|
expected: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root_path",
|
||||||
|
input: "http://example.com/",
|
||||||
|
expected: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single_trailing_slash",
|
||||||
|
input: "http://example.com/test/",
|
||||||
|
expected: "/test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_trailing_slash",
|
||||||
|
input: "http://example.com/test",
|
||||||
|
expected: "/test",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edge cases with slashes
|
||||||
|
{
|
||||||
|
name: "multiple_trailing_slashes",
|
||||||
|
input: "http://example.com/test//",
|
||||||
|
expected: "/test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_consecutive_slashes",
|
||||||
|
input: "http://example.com//test//",
|
||||||
|
expected: "//test/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only_slashes",
|
||||||
|
input: "http://example.com////",
|
||||||
|
expected: "///",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single_slash",
|
||||||
|
input: "/",
|
||||||
|
expected: "/",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Encoded characters
|
||||||
|
{
|
||||||
|
name: "encoded_spaces",
|
||||||
|
input: "http://example.com/foo%20bar/",
|
||||||
|
expected: "/foo%20bar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encoded_special_chars",
|
||||||
|
input: "http://example.com/foo%2Fbar/",
|
||||||
|
expected: "/foo%2Fbar",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Query parameters and fragments
|
||||||
|
{
|
||||||
|
name: "with_query_parameters",
|
||||||
|
input: "http://example.com/path?query=param",
|
||||||
|
expected: "/path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with_fragment",
|
||||||
|
input: "http://example.com/path#fragment",
|
||||||
|
expected: "/path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with_both_query_and_fragment",
|
||||||
|
input: "http://example.com/path?query=param#fragment",
|
||||||
|
expected: "/path",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Relative URLs
|
||||||
|
{
|
||||||
|
name: "relative_path",
|
||||||
|
input: "/just/a/path/",
|
||||||
|
expected: "/just/a/path",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unicode paths
|
||||||
|
{
|
||||||
|
name: "unicode_characters",
|
||||||
|
input: "http://example.com/über/path/",
|
||||||
|
expected: "/%C3%BCber/path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unicode_encoded",
|
||||||
|
input: "http://example.com/%C3%BCber/path/",
|
||||||
|
expected: "/%C3%BCber/path",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Weird but valid cases
|
||||||
|
{
|
||||||
|
name: "dot_in_path",
|
||||||
|
input: "http://example.com/./path/",
|
||||||
|
expected: "/./path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double_dot_in_path",
|
||||||
|
input: "http://example.com/../path/",
|
||||||
|
expected: "/../path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed_case",
|
||||||
|
input: "http://example.com/PaTh/",
|
||||||
|
expected: "/PaTh",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
u, err := url.Parse(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse URL %q: %v", tt.input, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := TrimTrailingPathSlash(u.EscapedPath())
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("Input: %s\nExpected: %q\nGot: %q",
|
||||||
|
u.Path, tt.expected, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
126
config/config.go
Normal file
126
config/config.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Environment variable names.
|
||||||
|
const (
|
||||||
|
EnvLogLevel = "LOG_LEVEL"
|
||||||
|
EnvResponseTimeout = "RESPONSE_TIMEOUT"
|
||||||
|
EnvPanicOnUnexpectedError = "PANIC_ON_UNEXPECTED_ERROR"
|
||||||
|
EnvRootPath = "ROOT_PATH"
|
||||||
|
EnvDirIndexingEnabled = "DIR_INDEXING_ENABLED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the application configuration loaded from environment variables.
|
||||||
|
type Config struct {
|
||||||
|
LogLevel zerolog.Level // Logging level (debug, info, warn, error)
|
||||||
|
ResponseTimeout int // Timeout for responses in seconds
|
||||||
|
PanicOnUnexpectedError bool // Panic on unexpected errors when visiting a URL
|
||||||
|
RootPath string // Path to serve files from
|
||||||
|
DirIndexingEnabled bool // Allow client to browse directories or not
|
||||||
|
}
|
||||||
|
|
||||||
|
var CONFIG Config //nolint:gochecknoglobals
|
||||||
|
|
||||||
|
// parsePositiveInt parses and validates positive integer values.
|
||||||
|
func parsePositiveInt(param, value string) (int, error) {
|
||||||
|
val, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, ValidationError{
|
||||||
|
Param: param,
|
||||||
|
Value: value,
|
||||||
|
Reason: "must be a valid integer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val <= 0 {
|
||||||
|
return 0, ValidationError{
|
||||||
|
Param: param,
|
||||||
|
Value: value,
|
||||||
|
Reason: "must be positive",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(param, value string) (bool, error) {
|
||||||
|
val, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, ValidationError{
|
||||||
|
Param: param,
|
||||||
|
Value: value,
|
||||||
|
Reason: "cannot be converted to boolean",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig loads and validates configuration from environment variables
|
||||||
|
func GetConfig() *Config {
|
||||||
|
config := &Config{}
|
||||||
|
|
||||||
|
// Map of environment variables to their parsing functions
|
||||||
|
parsers := map[string]func(string) error{
|
||||||
|
EnvLogLevel: func(v string) error {
|
||||||
|
level, err := zerolog.ParseLevel(v)
|
||||||
|
if err != nil {
|
||||||
|
return ValidationError{
|
||||||
|
Param: EnvLogLevel,
|
||||||
|
Value: v,
|
||||||
|
Reason: "must be one of: debug, info, warn, error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.LogLevel = level
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
EnvResponseTimeout: func(v string) error {
|
||||||
|
val, err := parsePositiveInt(EnvResponseTimeout, v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config.ResponseTimeout = val
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
EnvPanicOnUnexpectedError: func(v string) error {
|
||||||
|
val, err := parseBool(EnvPanicOnUnexpectedError, v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config.PanicOnUnexpectedError = val
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
EnvRootPath: func(v string) error {
|
||||||
|
config.RootPath = v
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
EnvDirIndexingEnabled: func(v string) error {
|
||||||
|
val, err := parseBool(EnvDirIndexingEnabled, v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
config.DirIndexingEnabled = val
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each environment variable
|
||||||
|
for envVar, parser := range parsers {
|
||||||
|
value, ok := os.LookupEnv(envVar)
|
||||||
|
if !ok {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "Missing required environment variable: %s\n", envVar)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := parser(value); err != nil {
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
14
config/errors.go
Normal file
14
config/errors.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ValidationError represents a config validation error
|
||||||
|
type ValidationError struct {
|
||||||
|
Param string
|
||||||
|
Value string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("invalid value '%s' for %s: %s", e.Value, e.Param, e.Reason)
|
||||||
|
}
|
||||||
114
errors/errors.go
Normal file
114
errors/errors.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fatal interface {
|
||||||
|
Fatal() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsFatal(err error) bool {
|
||||||
|
te, ok := errors.Unwrap(err).(fatal)
|
||||||
|
return ok && te.Fatal()
|
||||||
|
}
|
||||||
|
|
||||||
|
func As(err error, target any) bool {
|
||||||
|
return errors.As(err, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Is(err, target error) bool {
|
||||||
|
return errors.Is(err, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unwrap(err error) error {
|
||||||
|
return errors.Unwrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Err error
|
||||||
|
Stack string
|
||||||
|
fatal bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("%v\n", e.Err))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) ErrorWithStack() string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("%v\n", e.Err))
|
||||||
|
sb.WriteString(fmt.Sprintf("Stack Trace:\n%s", e.Stack))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Fatal() bool {
|
||||||
|
return e.fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's already of our own
|
||||||
|
// Error type, so we don't add stack twice.
|
||||||
|
var asError *Error
|
||||||
|
if errors.As(err, &asError) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the stack trace
|
||||||
|
var stack strings.Builder
|
||||||
|
buf := make([]uintptr, 50)
|
||||||
|
n := runtime.Callers(2, buf)
|
||||||
|
frames := runtime.CallersFrames(buf[:n])
|
||||||
|
|
||||||
|
// Format the stack trace
|
||||||
|
for {
|
||||||
|
frame, more := frames.Next()
|
||||||
|
// Skip runtime and standard library frames
|
||||||
|
if !strings.Contains(frame.File, "runtime/") {
|
||||||
|
stack.WriteString(fmt.Sprintf("\t%s:%d - %s\n", frame.File, frame.Line, frame.Function))
|
||||||
|
}
|
||||||
|
if !more {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Error{
|
||||||
|
Err: err,
|
||||||
|
Stack: stack.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFatalError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's already of our own
|
||||||
|
// Error type.
|
||||||
|
var asError *Error
|
||||||
|
if errors.As(err, &asError) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err2 := NewError(err)
|
||||||
|
err2.(*Error).fatal = true
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
|
||||||
|
var ConnectionError error = fmt.Errorf("connection error")
|
||||||
|
|
||||||
|
func NewConnectionError(err error) error {
|
||||||
|
return fmt.Errorf("%w: %w", ConnectionError, err)
|
||||||
|
}
|
||||||
71
errors/errors_test.go
Normal file
71
errors/errors_test.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CustomError) Error() string { return e.Err.Error() }
|
||||||
|
|
||||||
|
func IsCustomError(err error) bool {
|
||||||
|
var asError *CustomError
|
||||||
|
return errors.As(err, &asError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapping(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
originalErr := errors.New("original error")
|
||||||
|
err1 := NewError(originalErr)
|
||||||
|
if !errors.Is(err1, originalErr) {
|
||||||
|
t.Errorf("original error is not wrapped")
|
||||||
|
}
|
||||||
|
if !Is(err1, originalErr) {
|
||||||
|
t.Errorf("original error is not wrapped")
|
||||||
|
}
|
||||||
|
unwrappedErr := errors.Unwrap(err1)
|
||||||
|
if !errors.Is(unwrappedErr, originalErr) {
|
||||||
|
t.Errorf("original error is not wrapped")
|
||||||
|
}
|
||||||
|
if !Is(unwrappedErr, originalErr) {
|
||||||
|
t.Errorf("original error is not wrapped")
|
||||||
|
}
|
||||||
|
unwrappedErr = Unwrap(err1)
|
||||||
|
if !errors.Is(unwrappedErr, originalErr) {
|
||||||
|
t.Errorf("original error is not wrapped")
|
||||||
|
}
|
||||||
|
if !Is(unwrappedErr, originalErr) {
|
||||||
|
t.Errorf("original error is not wrapped")
|
||||||
|
}
|
||||||
|
wrappedErr := fmt.Errorf("wrapped: %w", originalErr)
|
||||||
|
if !errors.Is(wrappedErr, originalErr) {
|
||||||
|
t.Errorf("original error is not wrapped")
|
||||||
|
}
|
||||||
|
if !Is(wrappedErr, originalErr) {
|
||||||
|
t.Errorf("original error is not wrapped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
originalErr := &CustomError{errors.New("err1")}
|
||||||
|
if !IsCustomError(originalErr) {
|
||||||
|
t.Errorf("TestNewError fail #1")
|
||||||
|
}
|
||||||
|
err1 := NewError(originalErr)
|
||||||
|
if !IsCustomError(err1) {
|
||||||
|
t.Errorf("TestNewError fail #2")
|
||||||
|
}
|
||||||
|
wrappedErr1 := fmt.Errorf("wrapped %w", err1)
|
||||||
|
if !IsCustomError(wrappedErr1) {
|
||||||
|
t.Errorf("TestNewError fail #3")
|
||||||
|
}
|
||||||
|
unwrappedErr1 := Unwrap(wrappedErr1)
|
||||||
|
if !IsCustomError(unwrappedErr1) {
|
||||||
|
t.Errorf("TestNewError fail #4")
|
||||||
|
}
|
||||||
|
}
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module gemserve
|
||||||
|
|
||||||
|
go 1.23.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8
|
||||||
|
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||||
|
github.com/rs/zerolog v1.33.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
)
|
||||||
30
go.sum
Normal file
30
go.sum
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||||
|
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
|
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||||
|
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
23
logging/logging.go
Normal file
23
logging/logging.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
zlog "github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LogDebug(format string, args ...interface{}) {
|
||||||
|
zlog.Debug().Msg(fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogInfo(format string, args ...interface{}) {
|
||||||
|
zlog.Info().Msg(fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogWarn(format string, args ...interface{}) {
|
||||||
|
zlog.Warn().Msg(fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogError(format string, args ...interface{}) {
|
||||||
|
zlog.Error().Err(fmt.Errorf(format, args...)).Msg("")
|
||||||
|
}
|
||||||
189
main.go
Normal file
189
main.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gemserve/config"
|
||||||
|
"gemserve/errors"
|
||||||
|
"gemserve/logging"
|
||||||
|
"gemserve/server"
|
||||||
|
"gemserve/uid"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
zlog "github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
config.CONFIG = *config.GetConfig()
|
||||||
|
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||||
|
zerolog.SetGlobalLevel(config.CONFIG.LogLevel)
|
||||||
|
zlog.Logger = zlog.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "[2006-01-02 15:04:05]"})
|
||||||
|
err := runApp()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("%v\n", err)
|
||||||
|
logging.LogError("%v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runApp() error {
|
||||||
|
logging.LogInfo("Starting up. Press Ctrl+C to exit")
|
||||||
|
|
||||||
|
var listenHost string
|
||||||
|
if len(os.Args) != 2 {
|
||||||
|
listenHost = "0.0.0.0:1965"
|
||||||
|
} else {
|
||||||
|
listenHost = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
signals := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
serverErrors := make(chan error)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := startServer(listenHost)
|
||||||
|
if err != nil {
|
||||||
|
serverErrors <- errors.NewFatalError(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-signals:
|
||||||
|
logging.LogWarn("Received SIGINT or SIGTERM signal, exiting")
|
||||||
|
return nil
|
||||||
|
case serverError := <-serverErrors:
|
||||||
|
return errors.NewFatalError(serverError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServer(listenHost string) (err error) {
|
||||||
|
cert, err := tls.LoadX509KeyPair("certs/server.crt", "certs/server.key")
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFatalError(fmt.Errorf("failed to load certificate: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := tls.Listen("tcp", listenHost, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewFatalError(fmt.Errorf("failed to create listener: %w", err))
|
||||||
|
}
|
||||||
|
defer func(listener net.Listener) {
|
||||||
|
// If we've got an error closing the
|
||||||
|
// listener, make sure we don't override
|
||||||
|
// the original error (if not nil)
|
||||||
|
errClose := listener.Close()
|
||||||
|
if errClose != nil && err == nil {
|
||||||
|
err = errors.NewFatalError(err)
|
||||||
|
}
|
||||||
|
}(listener)
|
||||||
|
|
||||||
|
logging.LogInfo("Server listening on %s", listenHost)
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
logging.LogInfo("Failed to accept connection: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := handleConnection(conn.(*tls.Conn))
|
||||||
|
if err != nil {
|
||||||
|
var asErr *errors.Error
|
||||||
|
if errors.As(err, &asErr) {
|
||||||
|
logging.LogError("Unexpected error: %v %v", err, err.(*errors.Error).ErrorWithStack())
|
||||||
|
} else {
|
||||||
|
logging.LogError("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if config.CONFIG.PanicOnUnexpectedError {
|
||||||
|
panic("Encountered unexpected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeConnection(conn *tls.Conn) error {
|
||||||
|
err := conn.CloseWrite()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewConnectionError(fmt.Errorf("failed to close TLS connection: %w", err))
|
||||||
|
}
|
||||||
|
err = conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
return errors.NewConnectionError(fmt.Errorf("failed to close connection: %w", err))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConnection(conn *tls.Conn) (err error) {
|
||||||
|
remoteAddr := conn.RemoteAddr().String()
|
||||||
|
connId := uid.UID()
|
||||||
|
start := time.Now()
|
||||||
|
var outputBytes []byte
|
||||||
|
|
||||||
|
defer func(conn *tls.Conn) {
|
||||||
|
// Three possible cases here:
|
||||||
|
// - We don't have an error
|
||||||
|
// - We have a ConnectionError, which we don't propagate up
|
||||||
|
// - We have an unexpected error.
|
||||||
|
end := time.Now()
|
||||||
|
tookMs := end.Sub(start).Milliseconds()
|
||||||
|
var responseHeader string
|
||||||
|
if err != nil {
|
||||||
|
_, _ = conn.Write([]byte("50 server error"))
|
||||||
|
responseHeader = "50 server error"
|
||||||
|
// We don't propagate connection errors up.
|
||||||
|
if errors.Is(err, errors.ConnectionError) {
|
||||||
|
logging.LogInfo("%s %s %v", connId, remoteAddr, err)
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if i := bytes.Index(outputBytes, []byte{'\r'}); i >= 0 {
|
||||||
|
responseHeader = string(outputBytes[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logging.LogInfo("%s %s response %s (%dms)", connId, remoteAddr, responseHeader, tookMs)
|
||||||
|
_ = closeConnection(conn)
|
||||||
|
}(conn)
|
||||||
|
|
||||||
|
// Gemini is supposed to have a 1kb limit
|
||||||
|
// on input requests.
|
||||||
|
buffer := make([]byte, 1024)
|
||||||
|
|
||||||
|
n, err := conn.Read(buffer)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return errors.NewConnectionError(fmt.Errorf("failed to read connection data: %w", err))
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return errors.NewConnectionError(fmt.Errorf("client did not send data"))
|
||||||
|
}
|
||||||
|
|
||||||
|
dataBytes := buffer[:n]
|
||||||
|
dataString := string(dataBytes)
|
||||||
|
|
||||||
|
logging.LogInfo("%s %s request %s (%d bytes)", connId, remoteAddr, strings.TrimSpace(dataString), len(dataBytes))
|
||||||
|
outputBytes, err = server.GenerateResponse(conn, connId, dataString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = conn.Write(outputBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
124
server/server.go
Normal file
124
server/server.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gemserve/common"
|
||||||
|
"gemserve/config"
|
||||||
|
"gemserve/errors"
|
||||||
|
"gemserve/logging"
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerConfig interface {
|
||||||
|
DirIndexingEnabled() bool
|
||||||
|
RootPath() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, error) {
|
||||||
|
trimmedInput := strings.TrimSpace(input)
|
||||||
|
// url will have a cleaned and normalized path after this
|
||||||
|
url, err := common.ParseURL(trimmedInput, "", true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewConnectionError(fmt.Errorf("failed to parse URL: %w", err))
|
||||||
|
}
|
||||||
|
logging.LogDebug("%s %s normalized URL path: %s", connId, conn.RemoteAddr(), url.Path)
|
||||||
|
serverRootPath := config.CONFIG.RootPath
|
||||||
|
localPath, err := calculateLocalPath(url.Path, serverRootPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewConnectionError(err)
|
||||||
|
}
|
||||||
|
logging.LogDebug("%s %s request file path: %s", connId, conn.RemoteAddr(), localPath)
|
||||||
|
|
||||||
|
// Get file/directory information
|
||||||
|
info, err := os.Stat(localPath)
|
||||||
|
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
|
||||||
|
return []byte("51 not found\r\n"), nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle directory.
|
||||||
|
if info.IsDir() {
|
||||||
|
return generateResponseDir(conn, connId, url, localPath)
|
||||||
|
}
|
||||||
|
return generateResponseFile(conn, connId, url, localPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateResponseFile(conn *tls.Conn, connId string, url *common.URL, localPath string) ([]byte, error) {
|
||||||
|
data, err := os.ReadFile(localPath)
|
||||||
|
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
|
||||||
|
return []byte("51 not found\r\n"), nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to read file: %w", connId, conn.RemoteAddr(), err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeType string
|
||||||
|
if path.Ext(localPath) == ".gmi" {
|
||||||
|
mimeType = "text/gemini"
|
||||||
|
} else {
|
||||||
|
mimeType = mimetype.Detect(data).String()
|
||||||
|
}
|
||||||
|
headerBytes := []byte(fmt.Sprintf("20 %s\r\n", mimeType))
|
||||||
|
response := append(headerBytes, data...)
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPath string) (output []byte, err error) {
|
||||||
|
entries, err := os.ReadDir(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to read directory: %w", connId, conn.RemoteAddr(), err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.CONFIG.DirIndexingEnabled {
|
||||||
|
var contents []string
|
||||||
|
contents = append(contents, "Directory index:\n\n")
|
||||||
|
contents = append(contents, "=> ../\n")
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
contents = append(contents, fmt.Sprintf("=> %s/\n", entry.Name()))
|
||||||
|
} else {
|
||||||
|
contents = append(contents, fmt.Sprintf("=> %s\n", entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data := []byte(strings.Join(contents, ""))
|
||||||
|
headerBytes := []byte("20 text/gemini;\r\n")
|
||||||
|
response := append(headerBytes, data...)
|
||||||
|
return response, nil
|
||||||
|
} else {
|
||||||
|
filePath := path.Join(localPath, "index.gmi")
|
||||||
|
return generateResponseFile(conn, connId, url, filePath)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateLocalPath(input string, basePath string) (string, error) {
|
||||||
|
// Check for invalid characters early
|
||||||
|
if strings.ContainsAny(input, "\\") {
|
||||||
|
return "", errors.NewError(fmt.Errorf("invalid characters in path: %s", input))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If IsLocal(path) returns true, then Join(base, path)
|
||||||
|
// will always produce a path contained within base and
|
||||||
|
// Clean(path) will always produce an unrooted path with
|
||||||
|
// no ".." path elements.
|
||||||
|
filePath := input
|
||||||
|
filePath = strings.TrimPrefix(filePath, "/")
|
||||||
|
if filePath == "" {
|
||||||
|
filePath = "."
|
||||||
|
}
|
||||||
|
filePath = strings.TrimSuffix(filePath, "/")
|
||||||
|
|
||||||
|
localPath, err := filepath.Localize(filePath)
|
||||||
|
if err != nil || !filepath.IsLocal(localPath) {
|
||||||
|
return "", errors.NewError(fmt.Errorf("could not construct local path from %s: %s", input, err))
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath = path.Join(basePath, localPath)
|
||||||
|
return filePath, nil
|
||||||
|
}
|
||||||
225
server/server_test.go
Normal file
225
server/server_test.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateLocalPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
basePath string
|
||||||
|
want string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
// Basic path handling
|
||||||
|
{
|
||||||
|
name: "Simple valid path",
|
||||||
|
input: "folder/file.txt",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/folder/file.txt",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty path",
|
||||||
|
input: "",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Current directory",
|
||||||
|
input: ".",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Leading/trailing slash handling
|
||||||
|
{
|
||||||
|
name: "Path with leading slash",
|
||||||
|
input: "/folder/file.txt",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/folder/file.txt",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Path with trailing slash",
|
||||||
|
input: "folder/",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/folder",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Path with both leading and trailing slashes",
|
||||||
|
input: "/folder/",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/folder",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Path traversal attempts
|
||||||
|
{
|
||||||
|
name: "Simple path traversal attempt",
|
||||||
|
input: "../file.txt",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Complex path traversal attempt",
|
||||||
|
input: "folder/../../../etc/passwd",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Encoded path traversal attempt",
|
||||||
|
input: "folder/..%2F..%2F..%2Fetc%2Fpasswd",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/folder/..%2F..%2F..%2Fetc%2Fpasswd",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Double dot hidden in path",
|
||||||
|
input: "folder/.../.../etc/passwd",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/folder/.../.../etc/passwd",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{
|
||||||
|
name: "Multiple sequential slashes",
|
||||||
|
input: "folder///subfolder////file.txt",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unicode characters in path",
|
||||||
|
input: "фольдер/файл.txt",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/фольдер/файл.txt",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Path with spaces and special characters",
|
||||||
|
input: "my folder/my file!@#$%.txt",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/my folder/my file!@#$%.txt",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Very long path",
|
||||||
|
input: "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.txt",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "/base/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/file.txt",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Base path variations
|
||||||
|
{
|
||||||
|
name: "Empty base path",
|
||||||
|
input: "file.txt",
|
||||||
|
basePath: "",
|
||||||
|
want: "file.txt",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Relative base path",
|
||||||
|
input: "file.txt",
|
||||||
|
basePath: "base/folder",
|
||||||
|
want: "base/folder/file.txt",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Base path with trailing slash",
|
||||||
|
input: "file.txt",
|
||||||
|
basePath: "/base/",
|
||||||
|
want: "/base/file.txt",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Symbolic link-like paths (if supported)
|
||||||
|
{
|
||||||
|
name: "Path with symbolic link-like components",
|
||||||
|
input: "folder/symlink/../file.txt",
|
||||||
|
basePath: "/base",
|
||||||
|
want: "",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := calculateLocalPath(tt.input, tt.basePath)
|
||||||
|
|
||||||
|
// Check error expectation
|
||||||
|
if (err != nil) != tt.expectError {
|
||||||
|
t.Errorf("calculateLocalPath() error = %v, expectError = %v", err, tt.expectError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we expect an error, don't check the returned path
|
||||||
|
if tt.expectError {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the returned path matches expected
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("calculateLocalPath() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional security checks for non-error cases
|
||||||
|
if !tt.expectError {
|
||||||
|
// Verify the returned path is within base path
|
||||||
|
if !isWithinBasePath(got, tt.basePath) {
|
||||||
|
t.Errorf("Result path %v escapes base path %v", got, tt.basePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no '..' components in final path
|
||||||
|
if containsParentRef(got) {
|
||||||
|
t.Errorf("Result path %v contains parent references", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if a path is contained within the base path
|
||||||
|
func isWithinBasePath(path, basePath string) bool {
|
||||||
|
if basePath == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
absBase, err := filepath.Abs(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(absBase, absPath)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !strings.HasPrefix(rel, "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if a path contains parent directory references
|
||||||
|
func containsParentRef(path string) bool {
|
||||||
|
parts := strings.Split(filepath.Clean(path), string(filepath.Separator))
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == ".." {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
14
uid/uid.go
Normal file
14
uid/uid.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package uid
|
||||||
|
|
||||||
|
import (
|
||||||
|
nanoid "github.com/matoous/go-nanoid/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UID() string {
|
||||||
|
// No 'o','O' and 'l'
|
||||||
|
id, err := nanoid.Generate("abcdefghijkmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ0123456789", 20)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user