tagpic

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

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 }