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

400 lines
10 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"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"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{},
}),
}
}
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))
// 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.
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
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
}
var totalRead int64
lineCount := 0
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
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] != '.' {
// Full menu line: type + display string + tab + selector + tab + host + tab + port
if true {
// For text downloads, strip the type and metadata, show only display string + \n
clean := stripGopherMeta(line)
displayLine = clean + "\n"
}
}
// For informational lines (type i), don't strip anything — just pass through
if firstType == typeInformation {
displayLine = line
}
n, err := writer.Write([]byte(displayLine))
if err != nil {
return nil, core.NewFileError("failed to write gopher text", err)
}
totalRead += int64(n)
lineCount++
if req.ProgressCallback != nil {
speed := float64(totalRead) / time.Since(startTime).Seconds()
req.ProgressCallback(totalRead, -1, speed)
}
}
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: gopherContentType(firstType),
}, nil
}
// 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 "❓"
}
}