tagpicd.go (4768B)
1 // Copyright (c) 2026 Andrew Kloet <andrew@kloet.net> 2 // 3 // Permission to use, copy, modify, and distribute this software for any 4 // purpose with or without fee is hereby granted, provided that the above 5 // copyright notice and this permission notice appear in all copies. 6 // 7 // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 15 package main 16 17 import ( 18 "flag" 19 "fmt" 20 "html/template" 21 "log" 22 "log/syslog" 23 "net/http" 24 "os" 25 "path/filepath" 26 "slices" 27 "strconv" 28 "strings" 29 "time" 30 ) 31 32 const pageSize = 10 33 34 var ( 35 foreground bool 36 host string 37 imgDir string 38 port int 39 ) 40 41 type ImageData struct { 42 URL string 43 Date string 44 Tags []string 45 } 46 47 type PageData struct { 48 Tag string 49 Tags []string 50 Imgs []ImageData 51 Prev int 52 Next int 53 } 54 55 var tmpl = template.Must(template.New("").Parse(`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> 56 <html xmlns="http://www.w3.org/1999/xhtml" lang="en"> 57 <head> 58 <title>tagpic</title> 59 <style type="text/css"> 60 body{font-family:monospace;max-width:700px;margin:auto;padding:1em} 61 img{max-width:100%;height:auto} 62 </style> 63 </head> 64 <body> 65 <h3>/{{.Tag}}/</h3> 66 <p>{{range .Tags}}<a href="/{{.}}/">{{.}}</a> {{end}}</p> 67 <hr/> 68 {{range .Imgs -}} 69 <div> 70 <small>{{.Date}} | {{range .Tags}}<a href="/{{.}}/">{{.}}</a> {{end}}</small> 71 <a href="{{.URL}}"><img src="{{.URL}}" alt="" /></a> 72 </div> 73 {{else}}<p>No images.</p>{{- end -}} 74 <hr/> 75 <div> 76 {{if gt .Prev 0}}<a href="?page={{.Prev}}">prev</a>{{end -}} 77 {{- if .Next}}<a href="?page={{.Next}}">next</a>{{end}} 78 </div> 79 </body></html>`)) 80 81 func main() { 82 flag.BoolVar(&foreground, "d", false, "log to foreground") 83 flag.StringVar(&host, "h", "127.0.0.1", "host to listen on") 84 flag.IntVar(&port, "p", 4740, "port to listen on") 85 flag.Parse() 86 87 if foreground { 88 log.SetOutput(os.Stdout) 89 } else { 90 syslogWriter, err := syslog.New(syslog.LOG_INFO|syslog.LOG_DAEMON, "tagpic") 91 if err == nil { 92 log.SetOutput(syslogWriter) 93 } 94 } 95 96 /* $TAGPIC_IMGDIR ->$HOME/imgdir -> ./imgdir */ 97 imgDir = os.Getenv("TAGPIC_IMGDIR") 98 if imgDir == "" { 99 if home, err := os.UserHomeDir(); err == nil { 100 imgDir = filepath.Join(home, "imgdir") 101 } else { 102 imgDir = "imgdir" 103 } 104 } 105 106 http.Handle("/f/", http.StripPrefix("/f/", http.FileServer(http.Dir(filepath.Join(imgDir, "all"))))) 107 http.HandleFunc("/", gallery) 108 109 addr := fmt.Sprintf("%s:%d", host, port) 110 log.Printf("Starting server on http://%s (serving from %s)", addr, imgDir) 111 if err := http.ListenAndServe(addr, nil); err != nil { 112 log.Fatalf("Server failed: %v", err) 113 } 114 } 115 116 func gallery(w http.ResponseWriter, r *http.Request) { 117 entries, err := os.ReadDir(imgDir) 118 if err != nil { 119 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 120 return 121 } 122 var tags []string 123 for _, e := range entries { 124 if e.IsDir() { 125 tags = append(tags, e.Name()) 126 } 127 } 128 129 tag := strings.Trim(r.URL.Path, "/") 130 if tag == "" { 131 tag = "all" 132 } 133 target := filepath.Join(imgDir, tag) 134 135 /* Protect path traversal */ 136 rel, err := filepath.Rel(imgDir, target) 137 if err != nil || strings.HasPrefix(rel, "..") { 138 http.Error(w, "Forbidden", http.StatusForbidden) 139 return 140 } 141 142 files, err := os.ReadDir(target) 143 if err != nil { 144 http.Error(w, "Not Found", http.StatusNotFound) 145 return 146 } 147 148 var names []string 149 for _, f := range files { 150 if !f.IsDir() { 151 names = append(names, f.Name()) 152 } 153 } 154 slices.Sort(names) 155 slices.Reverse(names) 156 157 page, _ := strconv.Atoi(r.URL.Query().Get("page")) 158 if page < 1 { 159 page = 1 160 } 161 start := min((page-1)*pageSize, len(names)) 162 end := min(start+pageSize, len(names)) 163 164 var imgs []ImageData 165 for _, name := range names[start:end] { 166 var imgTags []string 167 for _, t := range tags { 168 _, err := os.Stat(filepath.Join(imgDir, t, name)) 169 if err == nil { 170 imgTags = append(imgTags, t) 171 } 172 } 173 174 sec, _ := strconv.ParseInt(strings.TrimSuffix(name, filepath.Ext(name)), 10, 64) 175 date := time.Unix(sec, 0).Format("2006-01-02 15:04") 176 177 imgs = append(imgs, ImageData{ 178 URL: "/f/" + name, 179 Date: date, 180 Tags: imgTags, 181 }) 182 } 183 184 next := 0 185 if end < len(names) { 186 next = page + 1 187 } 188 189 /* xhtml is the reasonable choice */ 190 w.Header().Set("Content-Type", "application/xhtml+xml; charset=utf-8") 191 tmpl.Execute(w, PageData{ 192 Tag: tag, 193 Tags: tags, 194 Imgs: imgs, 195 Prev: page - 1, 196 Next: next, 197 }) 198 }