summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAtarwn Gard <a@qwa.su>2025-12-17 11:28:50 +0500
committerAtarwn Gard <a@qwa.su>2025-12-17 11:28:50 +0500
commite61fc91785d6d70556daee0cb5f3d7a1f5c44090 (patch)
treedfc4088deb464fb62a264ead04d322ebf4d72329
InitialHEADmaster
-rw-r--r--go.mod3
-rw-r--r--qtd.go342
2 files changed, 345 insertions, 0 deletions
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 := ` => <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)
+}