//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 "❓" } }