tagpic

posix tagging gallery
git clone git://kloet.net/tagpic
Download | Log | Files | Refs | LICENSE

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 }