//go:build linux || freebsd // +build linux freebsd package gemini import ( "bufio" "context" "crypto/tls" "fmt" "io" "net" "net/url" "os" "strconv" "strings" "time" "codeberg.org/petrbalvin/goget/internal/core" "codeberg.org/petrbalvin/goget/internal/protocol" ) // Protocol implements gemini:// downloads. type Protocol struct { *protocol.BaseProtocol TLSInsecure bool // allow self-signed certs (testing only) } // maxGeminiRedirects limits redirect chain depth to prevent stack overflow. const maxGeminiRedirects = 10 // NewProtocol creates a new Gemini protocol handler. func NewProtocol() *Protocol { return &Protocol{ BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{ Name: "GEMINI", Scheme: "gemini", DefaultPort: 1965, Features: []string{}, }), } } // Download starts a Gemini download. It handles redirect limits internally. func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) { return p.downloadWithRedirects(ctx, req, 0) } func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.DownloadRequest, redirectCount int) (*core.DownloadResult, error) { startTime := time.Now() host := req.URL.Hostname() port := req.URL.Port() if port == "" { port = "1965" } addr := net.JoinHostPort(host, port) tlsCfg := &tls.Config{ MinVersion: tls.VersionTLS12, ServerName: host, InsecureSkipVerify: p.TLSInsecure, } dialer := &net.Dialer{Timeout: 30 * time.Second} conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsCfg) if err != nil { return nil, core.NewNetworkError("failed to connect to gemini server", err, core.SafeURL(req.URL)) } defer conn.Close() conn.SetDeadline(time.Now().Add(30 * time.Second)) // Send request: URL + CRLF requestURL := req.URL.String() if _, err := fmt.Fprintf(conn, "%s\r\n", requestURL); err != nil { return nil, core.NewNetworkError("failed to send gemini request", err, core.SafeURL(req.URL)) } // Read response header reader := bufio.NewReader(conn) header, err := reader.ReadString('\n') if err != nil { return nil, core.NewNetworkError("failed to read gemini response header", err, core.SafeURL(req.URL)) } header = strings.TrimSpace(header) // Parse status code (first two chars) if len(header) < 2 { return nil, core.NewProtocolError("invalid gemini response header", nil, core.SafeURL(req.URL)) } statusCode, _ := strconv.Atoi(header[:2]) meta := "" if len(header) > 3 { meta = header[3:] } switch { case statusCode >= 10 && statusCode < 20: // 1x INPUT — server requests input (prompt user with meta) return nil, core.NewProtocolError( fmt.Sprintf("gemini input required: %s", meta), nil, core.SafeURL(req.URL)) case statusCode >= 30 && statusCode < 40: // 3x REDIRECT — follow the redirect if meta == "" { return nil, core.NewProtocolError("gemini redirect with no target URL", nil, core.SafeURL(req.URL)) } if redirectCount >= maxGeminiRedirects { return nil, core.NewProtocolError( fmt.Sprintf("gemini redirect chain exceeded %d", maxGeminiRedirects), nil, core.SafeURL(req.URL)) } redirectURL, err := url.Parse(meta) if err != nil { return nil, core.NewProtocolError(fmt.Sprintf("invalid gemini redirect URL: %s", meta), err, core.SafeURL(req.URL)) } // Resolve relative redirects resolved := req.URL.ResolveReference(redirectURL) if req.Verbose { fmt.Fprintf(os.Stderr, "[gemini] redirecting to: %s\n", resolved.String()) } // Follow redirect with incremented depth redirectReq := &core.DownloadRequest{ URL: resolved, Output: req.Output, Verbose: req.Verbose, Writer: req.Writer, Ctx: ctx, } return p.downloadWithRedirects(ctx, redirectReq, redirectCount+1) case statusCode >= 40 && statusCode < 50: // 4x TEMPORARY FAILURE return nil, core.NewNetworkError( fmt.Sprintf("gemini temporary failure: %d %s", statusCode, meta), nil, core.SafeURL(req.URL)) case statusCode >= 50 && statusCode < 60: // 5x PERMANENT FAILURE return nil, core.NewProtocolError( fmt.Sprintf("gemini permanent failure: %d %s", statusCode, meta), nil, core.SafeURL(req.URL)) case statusCode >= 60 && statusCode < 70: // 6x CLIENT CERTIFICATE REQUIRED return nil, core.NewProtocolError( fmt.Sprintf("gemini client certificate required: %d %s", statusCode, meta), nil, core.SafeURL(req.URL)) case statusCode < 20 || statusCode >= 30: return nil, core.NewProtocolError( fmt.Sprintf("gemini server returned %d %s", statusCode, meta), nil, core.SafeURL(req.URL)) } // Category 2x: success — continue to read body var totalRead int64 var writer io.Writer if req.Writer != nil { writer = req.Writer } else { writer = io.Discard } buf := make([]byte, 32*1024) for { select { case <-ctx.Done(): return nil, ctx.Err() default: } n, err := reader.Read(buf) if n > 0 { if _, werr := writer.Write(buf[:n]); werr != nil { return nil, core.NewFileError("failed to write gemini data", werr) } totalRead += int64(n) if req.ProgressCallback != nil { speed := float64(totalRead) / time.Since(startTime).Seconds() req.ProgressCallback(totalRead, -1, speed) } } if err == io.EOF { break } if err != nil { return nil, core.NewNetworkError("failed to read gemini response body", err, core.SafeURL(req.URL)) } } duration := time.Since(startTime) speed := float64(totalRead) / duration.Seconds() return &core.DownloadResult{ BytesDownloaded: totalRead, TotalSize: totalRead, Duration: duration, Protocol: "GEMINI", IPVersion: 4, Speed: speed, OutputPath: req.Output, }, nil }