From e61fc91785d6d70556daee0cb5f3d7a1f5c44090 Mon Sep 17 00:00:00 2001 From: Atarwn Gard Date: Wed, 17 Dec 2025 11:28:50 +0500 Subject: Initial --- go.mod | 3 + qtd.go | 342 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 go.mod create mode 100644 qtd.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4ffd628 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module qtd + +go 1.24 \ No newline at end of file diff --git a/qtd.go b/qtd.go new file mode 100644 index 0000000..bc5e5c1 --- /dev/null +++ b/qtd.go @@ -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 := ` => ` + html.EscapeString(label) + `` + 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 := ` + +
+ + + +` + content + `+ +` + + 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) +} -- cgit v1.2.3