This commit is contained in:
antanst
2025-10-09 17:43:23 +03:00
parent 2ead66f012
commit 3a5835fc42
54 changed files with 5881 additions and 120 deletions

View File

@@ -1,10 +1,11 @@
package server
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
"os"
"path"
"path/filepath"
@@ -12,10 +13,12 @@ import (
"strings"
"unicode/utf8"
"gemserve/lib/apperrors"
"gemserve/lib/logging"
"gemserve/config"
"gemserve/gemini"
"git.antanst.com/antanst/logging"
"git.antanst.com/antanst/xerrors"
"github.com/gabriel-vasile/mimetype"
)
@@ -26,35 +29,36 @@ type ServerConfig interface {
func checkRequestURL(url *gemini.URL) error {
if !utf8.ValidString(url.String()) {
return xerrors.NewError(fmt.Errorf("invalid URL"), gemini.StatusBadRequest, "Invalid URL", false)
return apperrors.NewGeminiError(fmt.Errorf("invalid URL"), gemini.StatusBadRequest)
}
if url.Protocol != "gemini" {
return xerrors.NewError(fmt.Errorf("invalid URL"), gemini.StatusProxyRequestRefused, "URL Protocol not Gemini, proxying refused", false)
return apperrors.NewGeminiError(fmt.Errorf("invalid URL"), gemini.StatusProxyRequestRefused)
}
_, portStr, err := net.SplitHostPort(config.CONFIG.ListenAddr)
if err != nil {
return xerrors.NewError(fmt.Errorf("failed to parse server listen address: %w", err), 0, "", true)
return apperrors.NewGeminiError(fmt.Errorf("failed to parse server listen address: %w", err), gemini.StatusBadRequest)
}
listenPort, err := strconv.Atoi(portStr)
if err != nil {
return xerrors.NewError(fmt.Errorf("invalid server listen port: %w", err), 0, "", true)
return apperrors.NewGeminiError(fmt.Errorf("invalid server listen port: %w", err), gemini.StatusBadRequest)
}
if url.Port != listenPort {
return xerrors.NewError(fmt.Errorf("failed to parse URL: %w", err), gemini.StatusProxyRequestRefused, "invalid request port, proxying refused", false)
return apperrors.NewGeminiError(fmt.Errorf("failed to parse URL: %w", err), gemini.StatusProxyRequestRefused)
}
return nil
}
func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, error) {
func GenerateResponse(ctx context.Context, conn *tls.Conn, connId string, input string) ([]byte, error) {
logger := logging.FromContext(ctx)
trimmedInput := strings.TrimSpace(input)
// url will have a cleaned and normalized path after this
url, err := gemini.ParseURL(trimmedInput, "", true)
if err != nil {
return nil, xerrors.NewError(fmt.Errorf("failed to parse URL: %w", err), gemini.StatusBadRequest, "Invalid URL", false)
return nil, apperrors.NewGeminiError(fmt.Errorf("failed to parse URL: %w", err), gemini.StatusBadRequest)
}
logging.LogDebug("%s %s normalized URL path: %s", connId, conn.RemoteAddr(), url.Path)
logger.Debug("normalized URL path", "id", connId, "remoteAddr", conn.RemoteAddr(), "path", url.Path)
err = checkRequestURL(url)
if err != nil {
@@ -64,31 +68,27 @@ func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, erro
serverRootPath := config.CONFIG.RootPath
localPath, err := calculateLocalPath(url.Path, serverRootPath)
if err != nil {
return nil, xerrors.NewError(err, gemini.StatusBadRequest, "Invalid path", false)
return nil, apperrors.NewGeminiError(err, gemini.StatusBadRequest)
}
logging.LogDebug("%s %s request file path: %s", connId, conn.RemoteAddr(), localPath)
logger.Debug("request path", "id", connId, "remoteAddr", conn.RemoteAddr(), "local path", localPath)
// Get file/directory information
info, err := os.Stat(localPath)
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
return nil, xerrors.NewError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Not found or access denied", false)
} else if err != nil {
return nil, xerrors.NewError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Path access failed", false)
if err != nil {
return nil, apperrors.NewGeminiError(fmt.Errorf("failed to access path: %w", err), gemini.StatusNotFound)
}
// Handle directory.
if info.IsDir() {
return generateResponseDir(conn, connId, url, localPath)
return generateResponseDir(localPath)
}
return generateResponseFile(conn, connId, url, localPath)
return generateResponseFile(localPath)
}
func generateResponseFile(conn *tls.Conn, connId string, url *gemini.URL, localPath string) ([]byte, error) {
func generateResponseFile(localPath string) ([]byte, error) {
data, err := os.ReadFile(localPath)
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
return nil, xerrors.NewError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Path access failed", false)
} else if err != nil {
return nil, xerrors.NewError(fmt.Errorf("%s %s failed to read file: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Path access failed", false)
if err != nil {
return nil, apperrors.NewGeminiError(fmt.Errorf("failed to access path: %w", err), gemini.StatusNotFound)
}
var mimeType string
@@ -102,10 +102,10 @@ func generateResponseFile(conn *tls.Conn, connId string, url *gemini.URL, localP
return response, nil
}
func generateResponseDir(conn *tls.Conn, connId string, url *gemini.URL, localPath string) (output []byte, err error) {
func generateResponseDir(localPath string) (output []byte, err error) {
entries, err := os.ReadDir(localPath)
if err != nil {
return nil, xerrors.NewError(fmt.Errorf("%s %s failed to read directory: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Directory access failed", false)
return nil, apperrors.NewGeminiError(fmt.Errorf("failed to access path: %w", err), gemini.StatusNotFound)
}
if config.CONFIG.DirIndexingEnabled {
@@ -113,27 +113,27 @@ func generateResponseDir(conn *tls.Conn, connId string, url *gemini.URL, localPa
contents = append(contents, "Directory index:\n\n")
contents = append(contents, "=> ../\n")
for _, entry := range entries {
// URL-encode entry names for safety
safeName := url.PathEscape(entry.Name())
if entry.IsDir() {
contents = append(contents, fmt.Sprintf("=> %s/\n", entry.Name()))
contents = append(contents, fmt.Sprintf("=> %s/\n", safeName))
} else {
contents = append(contents, fmt.Sprintf("=> %s\n", entry.Name()))
contents = append(contents, fmt.Sprintf("=> %s\n", safeName))
}
}
data := []byte(strings.Join(contents, ""))
headerBytes := []byte(fmt.Sprintf("%d text/gemini; lang=en\r\n", gemini.StatusSuccess))
response := append(headerBytes, data...)
return response, nil
} else {
filePath := path.Join(localPath, "index.gmi")
return generateResponseFile(conn, connId, url, filePath)
}
filePath := filepath.Join(localPath, "index.gmi")
return generateResponseFile(filePath)
}
func calculateLocalPath(input string, basePath string) (string, error) {
// Check for invalid characters early
if strings.ContainsAny(input, "\\") {
return "", xerrors.NewError(fmt.Errorf("invalid characters in path: %s", input), gemini.StatusBadRequest, "Invalid path", false)
return "", apperrors.NewGeminiError(fmt.Errorf("invalid characters in path: %s", input), gemini.StatusBadRequest)
}
// If IsLocal(path) returns true, then Join(base, path)
@@ -149,7 +149,7 @@ func calculateLocalPath(input string, basePath string) (string, error) {
localPath, err := filepath.Localize(filePath)
if err != nil || !filepath.IsLocal(localPath) {
return "", xerrors.NewError(fmt.Errorf("could not construct local path from %s: %s", input, err), gemini.StatusBadRequest, "Invalid path", false)
return "", apperrors.NewGeminiError(fmt.Errorf("could not construct local path from %s: %s", input, err), gemini.StatusBadRequest)
}
filePath = path.Join(basePath, localPath)