Files
goget/internal/protocol/gopher/gopher.go
T

529 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build linux || freebsd
// +build linux freebsd
package gopher
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol"
)
// Gopher item types (RFC 1436).
const (
typeTextFile = '0'
typeDirectory = '1'
typeError = '3'
typeBinaryFile = '9'
typeGIF = 'g'
typeImage = 'I'
typeHTML = 'h'
typeInformation = 'i'
typeSound = 's'
typeTelnet = '8'
typeSearch = '7' // index search
)
// Protocol implements gopher:// downloads (RFC 1436).
type Protocol struct {
*protocol.BaseProtocol
}
// NewProtocol creates a new Gopher protocol handler.
func NewProtocol() *Protocol {
return &Protocol{
BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{
Name: "GOPHER",
Scheme: "gopher",
DefaultPort: 70,
Features: []string{"resume"},
}),
}
}
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
startTime := time.Now()
host := req.URL.Hostname()
port := req.URL.Port()
if port == "" {
port = "70"
}
selector := req.URL.Path
if req.URL.RawQuery != "" {
selector += "?" + req.URL.RawQuery
}
if selector == "" {
selector = "/"
}
addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, 30*time.Second)
if err != nil {
return nil, core.NewNetworkError("failed to connect to gopher 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.
go func() {
<-ctx.Done()
conn.Close()
}()
// Send selector + CRLF
if _, err := fmt.Fprintf(conn, "%s\r\n", selector); err != nil {
return nil, core.NewNetworkError("failed to send gopher selector", err, core.SafeURL(req.URL))
}
// Peek at first byte to determine response type
reader := bufio.NewReader(conn)
firstByte, err := reader.Peek(1)
if err != nil {
return nil, core.NewNetworkError("failed to read gopher response", err, core.SafeURL(req.URL))
}
itemType := firstByte[0]
switch itemType {
case typeError:
// Type 3: error — read the error message
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return nil, core.NewNetworkError("failed to read gopher error", err, core.SafeURL(req.URL))
}
msg := strings.TrimSpace(line)
if len(msg) > 2 {
msg = msg[2:] // strip type + first char
}
return nil, core.NewProtocolError("gopher server error: "+msg, nil, core.SafeURL(req.URL))
case typeDirectory:
// Type 1: directory listing — format as readable text
return p.downloadDirectory(reader, req, startTime)
case typeTextFile, typeHTML, typeInformation:
// Text-based types — strip item-type prefix, handle .\r\n terminator
return p.downloadText(ctx, reader, req, startTime, itemType)
case typeSearch:
// Type 7: index search — server expects a query
return nil, core.NewProtocolError(
"gopher search server requires a query (append ?query to URL)", nil, core.SafeURL(req.URL))
case typeTelnet:
// Type 8: telnet session — interactive, cannot download
return nil, core.NewProtocolError(
"gopher telnet session type cannot be downloaded", nil, core.SafeURL(req.URL))
default:
// Binary types (9, g, I, s, etc.) — raw data with .\r\n termination
// But first check which ones have the type-prefix-per-line format
if isTextGopherType(itemType) {
return p.downloadText(ctx, reader, req, startTime, itemType)
}
return p.downloadBinary(ctx, reader, req, startTime)
}
}
// isTextGopherType returns true for types that use line-prefix format.
func isTextGopherType(t byte) bool {
switch t {
case typeTextFile, typeDirectory, typeError, typeInformation, typeHTML:
return true
default:
return false
}
}
// downloadText handles text responses where each line has a type prefix.
// Supports resume for typeHTML and typeTextFile responses: when interrupted,
// writes a `.goget.meta` sidecar; on resume with valid metadata, skips
// already-written output bytes by length. Gopher does not expose
// Content-Length, so the resume metadata records only the bytes
// actually delivered to the writer.
func (p *Protocol) downloadText(ctx context.Context, reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time, firstType byte) (*core.DownloadResult, error) {
var writer io.Writer
var file *os.File
resumed := false
var startOffset int64
// 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 != "-" {
// Resume is meaningful only when we own the output file. If the
// caller already supplied a Writer we cannot append to Output
// behind their back, so disable resume to keep the partial-state
// sidecar consistent with what actually lands on disk.
if req.Resume && req.Writer != nil && req.Verbose {
fmt.Fprintf(os.Stderr, "[gopher] warning: --resume ignored because a Writer is set; output goes to the Writer only\n")
}
if req.Resume && req.Writer != nil {
req.Resume = false
}
if req.Resume {
canResume, resumeMeta, rerr := output.CanResume(req.Output, req.URL.String())
if rerr != nil {
if req.Verbose {
fmt.Fprintf(os.Stderr, "[gopher] 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, "[gopher] 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)
}
}
var ferr error
file, ferr = os.Create(req.Output)
if ferr != nil {
return nil, core.NewFileError("failed to create output file", ferr)
}
defer file.Close()
writer = file
}
}
if writer == nil {
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
}
}
// bytesWritten tracks output payload actually delivered to the writer
// (after the per-line metadata strip). On resume we skip ahead by
// startOffset bytes of this post-transform stream so the file on
// disk ends up as the concatenation of the previous run and the
// new one — same convention as Gemini.
var bytesWritten int64
var skipped int64
lineCount := 0
for {
select {
case <-ctx.Done():
saveGopherResume(req, bytesWritten)
return nil, ctx.Err()
default:
}
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
// The connection may have been closed by our context
// cancellation goroutine (or by the server after the
// client cancelled). If so, the EOF is not a genuine
// end-of-data but a side-effect of the cancel and we
// must persist the partial state before bailing.
if ctx.Err() != nil {
saveGopherResume(req, bytesWritten)
return nil, ctx.Err()
}
break
}
saveGopherResume(req, bytesWritten)
return nil, core.NewNetworkError("failed to read gopher line", err, core.SafeURL(req.URL))
}
// End-of-transmission: line containing only ".\r\n"
if line == ".\r\n" || line == ".\n" {
break
}
// Strip item-type prefix if present (first char = type, second char is usually tab/space)
displayLine := line
if len(line) > 1 && line[1] != '.' {
clean := stripGopherMeta(line)
displayLine = clean + "\n"
}
// For informational lines (type i), don't strip anything — just pass through
if firstType == typeInformation {
displayLine = line
}
// On resume, drop the first startOffset bytes of the
// post-transform output so the file appends cleanly.
payload := []byte(displayLine)
if skipped < startOffset {
remain := startOffset - skipped
if int64(len(payload)) <= remain {
skipped += int64(len(payload))
payload = nil
} else {
skipped += remain
payload = payload[remain:]
}
}
if len(payload) > 0 {
n, werr := writer.Write(payload)
if werr != nil {
saveGopherResume(req, bytesWritten)
return nil, core.NewFileError("failed to write gopher text", werr)
}
bytesWritten += int64(n)
}
lineCount++
if req.ProgressCallback != nil {
speed := float64(bytesWritten) / time.Since(startTime).Seconds()
req.ProgressCallback(bytesWritten, -1, speed)
}
}
// 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(bytesWritten) / duration.Seconds()
}
return &core.DownloadResult{
BytesDownloaded: bytesWritten,
TotalSize: bytesWritten,
Duration: duration,
Protocol: "GOPHER",
IPVersion: 4,
Speed: speed,
OutputPath: req.Output,
ContentType: gopherContentType(firstType),
Resumed: resumed,
}, nil
}
// saveGopherResume persists partial Gopher text download progress to the
// standard `.goget.meta` sidecar. No-op for stdout writes, nil requests,
// or zero-byte transfers. Gopher does not expose Content-Length, so the
// total field stays 0; the resume offset is taken from the number of
// bytes already delivered to the writer.
func saveGopherResume(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, "[gopher] Warning: failed to save resume metadata: %v\n", err)
}
}
// gopherContentType returns the MIME content type for a gopher item type.
func gopherContentType(t byte) string {
switch t {
case typeHTML:
return "text/html"
case typeTextFile, typeInformation:
return "text/plain"
default:
return "application/octet-stream"
}
}
// downloadDirectory formats a directory listing as readable text.
func (p *Protocol) downloadDirectory(reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time) (*core.DownloadResult, error) {
var writer io.Writer
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
}
var totalRead int64
var sb strings.Builder
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
return nil, core.NewNetworkError("failed to read gopher directory", err, core.SafeURL(req.URL))
}
if line == ".\r\n" || line == ".\n" {
break
}
// Parse gopher menu line: type + display string + tab + selector + tab + host + tab + port
formatted := formatGopherMenu(line)
sb.WriteString(formatted)
sb.WriteByte('\n')
}
output := sb.String()
n, err := writer.Write([]byte(output))
if err != nil {
return nil, core.NewFileError("failed to write gopher directory", err)
}
totalRead = int64(n)
duration := time.Since(startTime)
speed := float64(totalRead) / duration.Seconds()
return &core.DownloadResult{
BytesDownloaded: totalRead,
TotalSize: totalRead,
Duration: duration,
Protocol: "GOPHER",
IPVersion: 4,
Speed: speed,
OutputPath: req.Output,
ContentType: "text/plain",
}, nil
}
// downloadBinary handles binary responses.
func (p *Protocol) downloadBinary(ctx context.Context, reader *bufio.Reader, req *core.DownloadRequest, startTime time.Time) (*core.DownloadResult, error) {
var writer io.Writer
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
}
var totalRead int64
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 gopher binary 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 gopher binary", err, core.SafeURL(req.URL))
}
}
duration := time.Since(startTime)
speed := float64(totalRead) / duration.Seconds()
return &core.DownloadResult{
BytesDownloaded: totalRead,
TotalSize: totalRead,
Duration: duration,
Protocol: "GOPHER",
IPVersion: 4,
Speed: speed,
OutputPath: req.Output,
ContentType: "application/octet-stream",
}, nil
}
// stripGopherMeta removes the gopher metadata (tabs + selector + host + port) from a menu line.
func stripGopherMeta(line string) string {
line = strings.TrimRight(line, "\r\n")
if len(line) < 2 {
return line
}
// Format: Xdisplay string\tselector\thost\tport
// Everything before the first tab is the type + display string
tabIdx := strings.IndexByte(line, '\t')
if tabIdx <= 1 {
return line // no metadata
}
return line[1:tabIdx] // strip type char, keep display text
}
// formatGopherMenu formats a gopher menu line for human display.
func formatGopherMenu(line string) string {
line = strings.TrimRight(line, "\r\n")
if len(line) < 2 {
return line
}
itemType := line[0]
rest := line[1:]
// Split by tabs: display\0selector\0host\0port
parts := strings.Split(rest, "\t")
display := parts[0]
selector := ""
if len(parts) > 1 {
selector = parts[1]
}
icon := gopherIcon(itemType)
if selector != "" && selector != "(null)" {
return fmt.Sprintf("%s %s [%s]", icon, display, selector)
}
return fmt.Sprintf("%s %s", icon, display)
}
// gopherIcon returns an icon for a gopher item type.
func gopherIcon(t byte) string {
switch t {
case typeDirectory:
return "📁"
case typeTextFile:
return "📄"
case typeBinaryFile:
return "💾"
case typeGIF, typeImage:
return "🖼"
case typeHTML:
return "🌐"
case typeSound:
return "🔊"
case typeSearch:
return "🔍"
case typeTelnet:
return "🖥"
case typeInformation:
return "️"
default:
return "❓"
}
}