diff options
| author | Atarwn Gard <a@qwa.su> | 2025-12-17 11:28:50 +0500 |
|---|---|---|
| committer | Atarwn Gard <a@qwa.su> | 2025-12-17 11:28:50 +0500 |
| commit | e61fc91785d6d70556daee0cb5f3d7a1f5c44090 (patch) | |
| tree | dfc4088deb464fb62a264ead04d322ebf4d72329 /qtd.go | |
Diffstat (limited to 'qtd.go')
| -rw-r--r-- | qtd.go | 342 |
1 files changed, 342 insertions, 0 deletions
@@ -0,0 +1,342 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "html" + "io" + "log" + "net" + "net/http" + "net/http/fcgi" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var ( + dir = flag.String("d", ".", "root directory") + port = flag.Int("p", 0, "http port (optional, for standalone or reverse-proxy)") + fcgiSocket = flag.String("s", "", "fastcgi socket path (optional, e.g., /var/run/app.sock)") + linkPattern = regexp.MustCompile(`^\s*=>\s*(.*)$`) +) + +func parseFile(path string) (title, content string, err error) { + file, err := os.Open(path) + if err != nil { + return "", "", err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + if !scanner.Scan() { + return "", "", fmt.Errorf("empty file") + } + + firstLine := scanner.Text() + if strings.HasPrefix(firstLine, "title ") { + title = strings.TrimPrefix(firstLine, "title ") + } else { + content = firstLine + "\n" + } + + var buf strings.Builder + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "#") { + continue + } + buf.WriteString(line) + buf.WriteString("\n") + } + if content == "" { + content = buf.String() + } else { + content += buf.String() + } + + return title, content, nil +} + +func resolvePath(root, inputPath string) (title, content string, isDir bool, err error) { + cleanPath := filepath.Clean("/" + inputPath) + target := filepath.Join(root, cleanPath) + + info, err := os.Stat(target) + if err != nil { + if os.IsNotExist(err) { + // Try as file first + title, content, err2 := parseFile(target) + if err2 == nil { + return title, content, false, nil + } + // Try index file in directory + indexFile := filepath.Join(target, "index") + if _, err3 := os.Stat(indexFile); err3 == nil { + title, content, _ := parseFile(indexFile) + return title, content, true, nil + } + // Try directory listing + listing, err := dirListing(target) + if err == nil { + return "", listing, true, nil + } + return "", "", false, fmt.Errorf("not found") + } + return "", "", false, err + } + + if info.IsDir() { + indexFile := filepath.Join(target, "index") + if _, err := os.Stat(indexFile); err == nil { + title, content, _ := parseFile(indexFile) + return title, content, true, nil + } + listing, err := dirListing(target) + if err != nil { + return "", "", true, err + } + return "", listing, true, nil + } + + title, content, err = parseFile(target) + return title, content, false, err +} + +func dirListing(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + var items []string + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue + } + suffix := "" + if e.IsDir() { + suffix = "/" + } + items = append(items, " => "+e.Name()+suffix) + } + sort.Strings(items) + return strings.Join(items, "\n"), nil +} + +func linkify(text string) string { + lines := strings.Split(text, "\n") + var result []string + + for _, line := range lines { + matches := linkPattern.FindStringSubmatch(line) + if matches != nil { + rest := strings.TrimSpace(matches[1]) + if rest == "" { + result = append(result, html.EscapeString(line)) + continue + } + + parts := strings.Fields(rest) + if len(parts) >= 1 { + target := parts[0] + label := target + if len(parts) > 1 { + label = strings.Join(parts[1:], " ") + } + + href := target + if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") { + href = "./" + strings.TrimPrefix(target, "/") + } + + linkLine := ` => <a href="` + html.EscapeString(href) + `">` + html.EscapeString(label) + `</a>` + result = append(result, linkLine) + continue + } + } + result = append(result, html.EscapeString(line)) + } + + return strings.Join(result, "\n") +} + +func isCliUserAgent(userAgent string) bool { + cliAgents := []string{"curl", "wget", "lynx", "links", "w3m", "elinks"} + userAgentLower := strings.ToLower(userAgent) + for _, agent := range cliAgents { + if strings.Contains(userAgentLower, agent) { + return true + } + } + return false +} + +func handleHTTP(w http.ResponseWriter, r *http.Request) { + root := *dir + path := strings.TrimPrefix(r.URL.Path, "/") + + title, content, isDir, err := resolvePath(root, path) + if err != nil { + http.NotFound(w, r) + return + } + + // Check if this is a CLI browser + userAgent := r.Header.Get("User-Agent") + if isCliUserAgent(userAgent) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte(content)) + return + } + + // For regular browsers, always return HTML + if !isDir { + // Check if this is actually a binary file + cleanPath := filepath.Clean("/" + path) + target := filepath.Join(root, cleanPath) + file, err := os.Open(target) + if err == nil { + defer file.Close() + // Read first few bytes to check if it's a text file + buf := make([]byte, 512) + n, _ := file.Read(buf) + if n > 0 && http.DetectContentType(buf[:n]) != "text/plain; charset=utf-8" { + // Binary file, serve as-is + file.Seek(0, 0) + io.Copy(w, file) + return + } + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + content = linkify(content) + + htmlContent := `<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <style> + @import url(https://fonts.bunny.net/css?family=jetbrains-mono:400); + *{margin:0;box-sizing:border-box;font-family:'JetBrains Mono',monospace;} + body { + background: #1f1a1b; + color: #ebe0e1; + line-height: 1.2em; + } + .qtd { + background: #100d0e; + padding: 4px 12px; + } + .qtd, .qtd a, .qtd a:visited { + color: #ebe0e1; + } + pre { + padding: 8px; + overflow-x: auto; + } + a, a:visited { + color: #e2bdc7; + } + </style> + <title>` + html.EscapeString(title) + `</title> +</head> +<body> + <div class="qtd"> + echo ` + html.EscapeString(path) + ` | nc <a href="//` + html.EscapeString(r.Host) + `">` + html.EscapeString(r.Host) + `</a> 1900 | less + </div> + <pre>` + content + `</pre> +</body> +</html>` + + w.Write([]byte(htmlContent)) +} + +func handleNex(conn net.Conn, root string) { + defer conn.Close() + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return + } + path := strings.TrimSpace(line) + + _, content, _, err := resolvePath(root, path) + if err != nil { + content = "Not found\n" + } + + conn.Write([]byte(content)) +} + +func startNexServer(root string) { + listener, err := net.Listen("tcp", ":1900") + if err != nil { + log.Fatalf("Nex server error: %v", err) + } + defer listener.Close() + + log.Printf("Nex server on :1900, root=%s", root) + + for { + conn, err := listener.Accept() + if err != nil { + continue + } + go handleNex(conn, root) + } +} + +func startFastCGI(socketPath string) { + os.Remove(socketPath) + + listener, err := net.Listen("unix", socketPath) + if err != nil { + log.Fatalf("FastCGI socket error: %v", err) + } + defer listener.Close() + + os.Chmod(socketPath, 0666) + + log.Printf("FastCGI server on %s", socketPath) + + handler := http.HandlerFunc(handleHTTP) + if err := fcgi.Serve(listener, handler); err != nil { + log.Fatalf("FastCGI serve error: %v", err) + } +} + +func main() { + flag.Parse() + if flag.NArg() > 0 { + log.Fatal("unexpected arguments; use flags only") + } + + root, err := filepath.Abs(*dir) + if err != nil { + log.Fatal(err) + } + + http.HandleFunc("/", handleHTTP) + + if *fcgiSocket != "" { + go startNexServer(root) + startFastCGI(*fcgiSocket) + return + } + + if *port > 0 { + go startNexServer(root) + addr := fmt.Sprintf(":%d", *port) + log.Printf("HTTP server on %s, root=%s", addr, root) + log.Fatal(http.ListenAndServe(addr, nil)) + return + } + + startNexServer(root) +} |
