348 lines
10 KiB
Go
348 lines
10 KiB
Go
//go:build linux || freebsd
|
|
// +build linux freebsd
|
|
|
|
package gemini
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/petrbalvin/goget/internal/core"
|
|
"codeberg.org/petrbalvin/goget/internal/output"
|
|
"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{"resume"},
|
|
}),
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// Certificate pinning: require the leaf certificate to match the
|
|
// caller-provided SHA-256 hash. The check runs after the standard
|
|
// verification chain so a pinned self-signed cert still needs
|
|
// InsecureSkipVerify (or a matching CA) to clear the normal path.
|
|
if req != nil && req.PinnedCertHash != "" {
|
|
expected := strings.ToLower(strings.TrimSpace(req.PinnedCertHash))
|
|
tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
|
for _, rawCert := range rawCerts {
|
|
cert, err := x509.ParseCertificate(rawCert)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
hash := sha256.Sum256(cert.Raw)
|
|
if hex.EncodeToString(hash[:]) == expected {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("certificate pinning failed: no certificate matches sha-256 %s", expected)
|
|
}
|
|
}
|
|
|
|
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))
|
|
|
|
// Close the connection when the context is cancelled so any
|
|
// blocking Read returns immediately instead of waiting for the
|
|
// 30-second deadline. The done channel bounds the goroutine to
|
|
// the lifetime of this call: when Download returns, defer
|
|
// close(done) lets the goroutine exit even if the parent context
|
|
// was never cancelled.
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
go func() {
|
|
select {
|
|
case <-ctx.Done():
|
|
conn.Close()
|
|
case <-done:
|
|
}
|
|
}()
|
|
|
|
// 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,
|
|
PinnedCertHash: req.PinnedCertHash,
|
|
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 // total bytes received from server (including skipped)
|
|
var writer io.Writer
|
|
var file *os.File
|
|
resumed := false
|
|
var startOffset int64 // bytes to skip on resume
|
|
|
|
// Set up output destination. When req.Output is set and no external
|
|
// Writer was provided, we open the file directly. This covers both
|
|
// fresh downloads (create) and resume (append). When req.Writer is
|
|
// set (caller manages the file), we use it as-is.
|
|
if req.Output != "" && req.Output != "-" {
|
|
if req.Resume {
|
|
canResume, resumeMeta, rerr := output.CanResume(req.Output, req.URL.String())
|
|
if rerr != nil {
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[gemini] Resume check failed: %v\n", rerr)
|
|
}
|
|
output.CleanupResumeFiles(req.Output)
|
|
} else if canResume && resumeMeta != nil {
|
|
file, rerr = os.OpenFile(req.Output, os.O_APPEND|os.O_WRONLY, 0644)
|
|
if rerr != nil {
|
|
return nil, core.NewFileError("failed to open file for resume", rerr)
|
|
}
|
|
defer file.Close()
|
|
writer = file
|
|
startOffset = resumeMeta.Downloaded
|
|
resumed = true
|
|
if req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[gemini] Resuming from byte %d\n", startOffset)
|
|
}
|
|
}
|
|
}
|
|
if writer == nil && req.Writer == nil {
|
|
// Fresh download — create the output file.
|
|
dir := filepath.Dir(req.Output)
|
|
if dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return nil, core.NewFileError("failed to create output directory", err)
|
|
}
|
|
}
|
|
file, err = os.Create(req.Output)
|
|
if err != nil {
|
|
return nil, core.NewFileError("failed to create output file", err)
|
|
}
|
|
defer file.Close()
|
|
writer = file
|
|
}
|
|
}
|
|
if writer == nil {
|
|
if req.Writer != nil {
|
|
writer = req.Writer
|
|
} else {
|
|
writer = io.Discard
|
|
}
|
|
}
|
|
|
|
// Gemini does not support range requests, so resume re-downloads
|
|
// the full content and skips the bytes already saved to disk.
|
|
var skipped int64
|
|
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
saveGeminiResume(req, totalRead)
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
n, err := reader.Read(buf)
|
|
if n > 0 {
|
|
totalRead += int64(n)
|
|
chunk := buf[:n]
|
|
// On resume, discard bytes we already have on disk.
|
|
if skipped < startOffset {
|
|
remain := startOffset - skipped
|
|
if int64(n) <= remain {
|
|
skipped += int64(n)
|
|
chunk = nil
|
|
} else {
|
|
skipped += remain
|
|
chunk = chunk[int(remain):]
|
|
}
|
|
}
|
|
if len(chunk) > 0 {
|
|
if _, werr := writer.Write(chunk); werr != nil {
|
|
saveGeminiResume(req, totalRead)
|
|
return nil, core.NewFileError("failed to write gemini data", werr)
|
|
}
|
|
}
|
|
if req.ProgressCallback != nil {
|
|
speed := float64(totalRead) / time.Since(startTime).Seconds()
|
|
req.ProgressCallback(totalRead, -1, speed)
|
|
}
|
|
}
|
|
if err == io.EOF {
|
|
// The connection may have been closed by our context
|
|
// cancellation goroutine. If so, the EOF is not a genuine
|
|
// end-of-data but a side-effect of the cancel.
|
|
if ctx.Err() != nil {
|
|
saveGeminiResume(req, totalRead)
|
|
return nil, ctx.Err()
|
|
}
|
|
break
|
|
}
|
|
if err != nil {
|
|
saveGeminiResume(req, totalRead)
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
return nil, core.NewNetworkError("failed to read gemini response body", err, core.SafeURL(req.URL))
|
|
}
|
|
}
|
|
|
|
// Download complete — remove resume metadata sidecar.
|
|
if req.Output != "" && req.Output != "-" {
|
|
output.DeleteResumeMetadata(req.Output)
|
|
}
|
|
|
|
duration := time.Since(startTime)
|
|
speed := float64(0)
|
|
if duration.Seconds() > 0 {
|
|
speed = float64(totalRead) / duration.Seconds()
|
|
}
|
|
|
|
return &core.DownloadResult{
|
|
BytesDownloaded: totalRead,
|
|
TotalSize: totalRead,
|
|
Duration: duration,
|
|
Protocol: "GEMINI",
|
|
IPVersion: 4,
|
|
Speed: speed,
|
|
OutputPath: req.Output,
|
|
Resumed: resumed,
|
|
}, nil
|
|
}
|
|
|
|
// saveGeminiResume persists partial Gemini download progress to the
|
|
// standard `.goget.meta` sidecar. No-op for stdout writes or zero-byte
|
|
// transfers. Gemini does not expose Content-Length in the response
|
|
// header, so Total is recorded as 0 (size unknown).
|
|
func saveGeminiResume(req *core.DownloadRequest, downloaded int64) {
|
|
if req == nil || req.Output == "" || req.Output == "-" || downloaded <= 0 {
|
|
return
|
|
}
|
|
meta := output.NewResumeMetadata(req.URL.String(), "", "", downloaded, 0)
|
|
if err := meta.Save(req.Output); err != nil && req.Verbose {
|
|
fmt.Fprintf(os.Stderr, "[gemini] Warning: failed to save resume metadata: %v\n", err)
|
|
}
|
|
}
|