package server import ( "context" "crypto/tls" "fmt" "net" "net/url" "os" "path" "path/filepath" "strconv" "strings" "unicode/utf8" "gemserve/lib/apperrors" "gemserve/lib/logging" "gemserve/config" "gemserve/gemini" "github.com/gabriel-vasile/mimetype" ) type ServerConfig interface { DirIndexingEnabled() bool RootPath() string } func checkRequestURL(url *gemini.URL) error { if !utf8.ValidString(url.String()) { return apperrors.NewGeminiError(fmt.Errorf("invalid URL"), gemini.StatusBadRequest) } if url.Protocol != "gemini" { return apperrors.NewGeminiError(fmt.Errorf("invalid URL"), gemini.StatusProxyRequestRefused) } _, portStr, err := net.SplitHostPort(config.CONFIG.ListenAddr) if err != nil { return apperrors.NewGeminiError(fmt.Errorf("failed to parse server listen address: %w", err), gemini.StatusBadRequest) } listenPort, err := strconv.Atoi(portStr) if err != nil { return apperrors.NewGeminiError(fmt.Errorf("invalid server listen port: %w", err), gemini.StatusBadRequest) } if url.Port != listenPort { return apperrors.NewGeminiError(fmt.Errorf("failed to parse URL: %w", err), gemini.StatusProxyRequestRefused) } return nil } 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, apperrors.NewGeminiError(fmt.Errorf("failed to parse URL: %w", err), gemini.StatusBadRequest) } logger.Debug("normalized URL path", "id", connId, "remoteAddr", conn.RemoteAddr(), "path", url.Path) err = checkRequestURL(url) if err != nil { return nil, err } serverRootPath := config.CONFIG.RootPath localPath, err := calculateLocalPath(url.Path, serverRootPath) if err != nil { return nil, apperrors.NewGeminiError(err, gemini.StatusBadRequest) } logger.Debug("request path", "id", connId, "remoteAddr", conn.RemoteAddr(), "local path", localPath) // Get file/directory information info, err := os.Stat(localPath) if err != nil { return nil, apperrors.NewGeminiError(fmt.Errorf("failed to access path: %w", err), gemini.StatusNotFound) } // Handle directory. if info.IsDir() { return generateResponseDir(localPath) } return generateResponseFile(localPath) } func generateResponseFile(localPath string) ([]byte, error) { data, err := os.ReadFile(localPath) if err != nil { return nil, apperrors.NewGeminiError(fmt.Errorf("failed to access path: %w", err), gemini.StatusNotFound) } var mimeType string if path.Ext(localPath) == ".gmi" { mimeType = "text/gemini" } else { mimeType = mimetype.Detect(data).String() } headerBytes := []byte(fmt.Sprintf("%d %s; lang=en\r\n", gemini.StatusSuccess, mimeType)) response := append(headerBytes, data...) return response, nil } func generateResponseDir(localPath string) (output []byte, err error) { entries, err := os.ReadDir(localPath) if err != nil { return nil, apperrors.NewGeminiError(fmt.Errorf("failed to access path: %w", err), gemini.StatusNotFound) } if config.CONFIG.DirIndexingEnabled { var contents []string 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", safeName)) } else { 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 } 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 "", apperrors.NewGeminiError(fmt.Errorf("invalid characters in path: %s", input), gemini.StatusBadRequest) } // 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 "", apperrors.NewGeminiError(fmt.Errorf("could not construct local path from %s: %s", input, err), gemini.StatusBadRequest) } filePath = path.Join(basePath, localPath) return filePath, nil }