tagpicd.go (4443B)
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 "net/http" 22 "os" 23 "path/filepath" 24 "slices" 25 "strconv" 26 "strings" 27 "time" 28 ) 29 30 const pageSize = 10 31 32 var ( 33 imgDir string 34 port string 35 ) 36 37 type ImageData struct { 38 URL string 39 Date string 40 Tags []string 41 } 42 43 type PageData struct { 44 Tag string 45 Tags []string 46 Imgs []ImageData 47 Prev int 48 Next int 49 } 50 51 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"> 52 <html xmlns="http://www.w3.org/1999/xhtml" lang="en"> 53 <head> 54 <title>tagpic</title> 55 <style type="text/css"> 56 body{font-family:monospace;max-width:700px;margin:auto;padding:1em} 57 img{max-width:100%;height:auto} 58 </style> 59 </head> 60 <body> 61 <h3>/{{.Tag}}/</h3> 62 <p>{{range .Tags}}<a href="/{{.}}/">{{.}}</a> {{end}}</p> 63 <hr/> 64 {{range .Imgs -}} 65 <div> 66 <small>{{.Date}} | {{range .Tags}}<a href="/{{.}}/">{{.}}</a> {{end}}</small> 67 <a href="{{.URL}}"><img src="{{.URL}}" alt="" /></a> 68 </div> 69 {{else}}<p>No images.</p>{{- end -}} 70 <hr/> 71 <div> 72 {{if gt .Prev 0}}<a href="?page={{.Prev}}">prev</a>{{end -}} 73 {{- if .Next}}<a href="?page={{.Next}}">next</a>{{end}} 74 </div> 75 </body></html>`)) 76 77 func main() { 78 portFlag := flag.String("p", "4740", "port to listen on") 79 flag.Parse() 80 port = *portFlag 81 82 /* $TAGPIC_IMGDIR ->$HOME/imgdir -> ./imgdir */ 83 imgDir = os.Getenv("TAGPIC_IMGDIR") 84 if imgDir == "" { 85 if home, err := os.UserHomeDir(); err == nil { 86 imgDir = filepath.Join(home, "imgdir") 87 } else { 88 imgDir = "imgdir" 89 } 90 } 91 92 http.Handle("/f/", http.StripPrefix("/f/", http.FileServer(http.Dir(filepath.Join(imgDir, "all"))))) 93 http.HandleFunc("/", gallery) 94 95 addr := fmt.Sprintf(":%s", port) 96 fmt.Printf("Starting server on http://localhost%s (serving from %s)\n", addr, imgDir) 97 if err := http.ListenAndServe(addr, nil); err != nil { 98 fmt.Fprintf(os.Stderr, "Server failed: %v\n", err) 99 os.Exit(1) 100 } 101 } 102 103 func gallery(w http.ResponseWriter, r *http.Request) { 104 entries, err := os.ReadDir(imgDir) 105 if err != nil { 106 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 107 return 108 } 109 var tags []string 110 for _, e := range entries { 111 if e.IsDir() { 112 tags = append(tags, e.Name()) 113 } 114 } 115 116 tag := strings.Trim(r.URL.Path, "/") 117 if tag == "" { 118 tag = "all" 119 } 120 target := filepath.Join(imgDir, tag) 121 122 // Protect path traversal 123 rel, err := filepath.Rel(imgDir, target) 124 if err != nil || strings.HasPrefix(rel, "..") { 125 http.Error(w, "Forbidden", http.StatusForbidden) 126 return 127 } 128 129 files, err := os.ReadDir(target) 130 if err != nil { 131 http.Error(w, "Not Found", http.StatusNotFound) 132 return 133 } 134 135 var names []string 136 for _, f := range files { 137 if !f.IsDir() { 138 names = append(names, f.Name()) 139 } 140 } 141 slices.Sort(names) 142 slices.Reverse(names) 143 144 page, _ := strconv.Atoi(r.URL.Query().Get("page")) 145 if page < 1 { 146 page = 1 147 } 148 start := min((page-1)*pageSize, len(names)) 149 end := min(start+pageSize, len(names)) 150 151 var imgs []ImageData 152 for _, name := range names[start:end] { 153 var imgTags []string 154 for _, t := range tags { 155 _, err := os.Stat(filepath.Join(imgDir, t, name)) 156 if err == nil { 157 imgTags = append(imgTags, t) 158 } 159 } 160 161 sec, _ := strconv.ParseInt(strings.TrimSuffix(name, filepath.Ext(name)), 10, 64) 162 date := time.Unix(sec, 0).Format("2006-01-02 15:04") 163 164 imgs = append(imgs, ImageData{ 165 URL: "/f/" + name, 166 Date: date, 167 Tags: imgTags, 168 }) 169 } 170 171 next := 0 172 if end < len(names) { 173 next = page + 1 174 } 175 176 // xhtml is the reasonable choice 177 w.Header().Set("Content-Type", "application/xhtml+xml; charset=utf-8") 178 tmpl.Execute(w, PageData{ 179 Tag: tag, 180 Tags: tags, 181 Imgs: imgs, 182 Prev: page - 1, 183 Next: next, 184 }) 185 }