510 lines
14 KiB
Go
510 lines
14 KiB
Go
//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 != "-" {
|
||
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 {
|
||
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 "❓"
|
||
}
|
||
}
|