feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user