204 lines
5.6 KiB
Go
204 lines
5.6 KiB
Go
//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
|
|
}
|