tagpic

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

commit 679e87d4fc5b55d8fa9c5a418e86bc153bc2c2fc
Author: Andrew Kloet <andrew@kloet.net>
Date:   Thu, 17 Sep 2026 12:54:49 -0400

initial commit

Diffstat:
ALICENSE | 13+++++++++++++
AMakefile | 26++++++++++++++++++++++++++
Atagpic | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atagpicd.go | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 307 insertions(+), 0 deletions(-)

diff --git a/LICENSE b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2026 Andrew Kloet <andrew@kloet.net> + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile @@ -0,0 +1,26 @@ +# tagpic - posix tagged gallery +# See LICENSE file for copyright and license details. + +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin + +BINARIES = tagpic tagpicd + +all: tagpicd + +tagpicd: tagpicd.go + go build -o tagpicd tagpicd.go + +clean: + rm -f tagpicd + +install: all + mkdir -p $(DESTDIR)$(BINDIR) + cp $(BINARIES) $(DESTDIR)$(BINDIR)/ + chmod 755 $(DESTDIR)$(BINDIR)/tagpic $(DESTDIR)$(BINDIR)/tagpicd + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/tagpic + rm -f $(DESTDIR)$(BINDIR)/tagpicd + +.PHONY: all clean install uninstall diff --git a/tagpic b/tagpic @@ -0,0 +1,112 @@ +#!/bin/sh + +# Copyright (c) 2026 Andrew Kloet <andrew@kloet.net> +# +# Permission to use, copy, modify, and distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +set -e # errexit +set -u # nounset + +IMGDIR="${TAGPIC_IMGDIR:-$HOME/imgdir}" + +usage() +{ + echo "Usage: tagpic <command> [args...]" >&2 + echo "Commands:" >&2 + echo " i, insert <path/to/file> [tag1 tag2 ...]" >&2 + echo " d, delete <filename>" >&2 + echo " m, modify [-a|--append] <filename> [tag1 tag2 ...]" >&2 + exit 1 +} + +assert_file_exists() +{ + [ -f "$1" ] || { echo "${0##*/}: File '$1' not found." >&2; exit 1; } +} + +delete_symlinks() +{ + for _tag_dir in "$IMGDIR"/*; do + [ -d "$_tag_dir" ] || continue + case "$_tag_dir" in + */all) continue ;; + esac + + _symlink="$_tag_dir/$1" + if [ -L "$_symlink" ]; then + rm -f "$_symlink" + fi + done +} + +cmd_insert() +{ + [ "$#" -lt 1 ] && usage + + _filepath="$1" + shift + + assert_file_exists "$_filepath" + mkdir -p "$IMGDIR/all" + + _ext="${_filepath##*.}" + _unixtime=$(date +%s) + _filename="${_unixtime}.${_ext}" + + cp "$_filepath" "$IMGDIR/all/$_filename" + + for _tag do + mkdir -p "$IMGDIR/$_tag" + ln -s "../all/$_filename" "$IMGDIR/$_tag/$_filename" + done +} + +cmd_delete() +{ + [ "$#" -ne 1 ] && usage + _filename="$1" + + assert_file_exists "$IMGDIR/all/$_filename" + + delete_symlinks "$_filename" + rm -f "$IMGDIR/all/$_filename" +} + +cmd_modify() +{ + _append=0 + if [ "$1" = "-a" ] || [ "$1" = "--append" ]; then + _append=1 + shift + fi + + [ "$#" -lt 1 ] && usage + + _filename="$1" + shift + + assert_file_exists "$IMGDIR/all/$_filename" + + [ "$_append" -eq 0 ] && delete_symlinks "$_filename" + for _tag do + mkdir -p "$IMGDIR/$_tag" + ln -s "../all/$_filename" "$IMGDIR/$_tag/$_filename" + done +} + +case "$1" in + i|insert) shift; cmd_insert "$@" ;; + d|delete) shift; cmd_delete "$@" ;; + m|modify) shift; cmd_modify "$@" ;; + *) usage ;; +esac diff --git a/tagpicd.go b/tagpicd.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Andrew Kloet <andrew@kloet.net> +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "flag" + "fmt" + "html/template" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" +) + +const pageSize = 10 + +var ( + imgDir string + port string +) + +var tmpl = template.Must(template.New("").Parse(`<!DOCTYPE html> +<html> +<head><style>body{font-family:monospace;max-width:700px;margin:auto;padding:1em}img{max-width:100%;height:auto}</style></head> +<body> +<h3>/{{.Tag}}/</h3> +<p>{{range .Tags}}<a href="/{{.}}/">{{.}}</a> {{end}}</p> +<hr> +{{range .Imgs}} +<div> + <small>{{.Date}} | {{range .Tags}}<a href="/{{.}}/">{{.}}</a> {{end}}</small><br> + <a href="{{.URL}}" target="_blank"><img src="{{.URL}}"></a> +</div><br> +{{else}}<p>No images.</p>{{end}} +<hr> +{{if gt .Prev 0}}<a href="?page={{.Prev}}">prev</a>{{end}} +{{if .Next}}<a href="?page={{.Next}}">next</a>{{end}} +</body></html>`)) + +func main() { + portFlag := flag.String("p", "4740", "port to listen on") + flag.Parse() + port = *portFlag + + /* $TAGPIC_IMGDIR -> $HOME/imgdir -> ./imgdir */ + imgDir = os.Getenv("TAGPIC_IMGDIR") + if imgDir == "" { + if home, err := os.UserHomeDir(); err == nil { + imgDir = filepath.Join(home, "imgdir") + } else { + imgDir = "imgdir" + } + } + + http.Handle("/f/", http.StripPrefix("/f/", http.FileServer(http.Dir(filepath.Join(imgDir, "all"))))) + http.HandleFunc("/", gallery) + + addr := fmt.Sprintf(":%s", port) + fmt.Printf("Starting server on http://localhost%s (serving from %s)\n", addr, imgDir) + if err := http.ListenAndServe(addr, nil); err != nil { + fmt.Fprintf(os.Stderr, "Server failed: %v\n", err) + os.Exit(1) + } +} + +func gallery(w http.ResponseWriter, r *http.Request) { + entries, _ := os.ReadDir(imgDir) + var tags []string + for _, e := range entries { + if e.IsDir() { + tags = append(tags, e.Name()) + } + } + + tag := strings.Trim(r.URL.Path, "/") + if tag == "" { + tag = "all" + } + target := filepath.Join(imgDir, tag) + + // Protect path traversal + rel, err := filepath.Rel(imgDir, target) + if err != nil || strings.HasPrefix(rel, "..") { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + files, err := os.ReadDir(target) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + var names []string + for _, f := range files { + if !f.IsDir() { + names = append(names, f.Name()) + } + } + slices.Sort(names) + slices.Reverse(names) + + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + start := min((page-1)*pageSize, len(names)) + end := min(start+pageSize, len(names)) + + var data []map[string]any + for _, name := range names[start:end] { + var imgTags []string + for _, t := range tags { + if _, err := os.Stat(filepath.Join(imgDir, t, name)); err == nil { + imgTags = append(imgTags, t) + } + } + + sec, _ := strconv.ParseInt(strings.TrimSuffix(name, filepath.Ext(name)), 10, 64) + date := time.Unix(sec, 0).Format("2006-01-02 15:04") + + data = append(data, map[string]any{ + "URL": "/f/" + name, + "Date": date, + "Tags": imgTags, + }) + } + + next := 0 + if end < len(names) { + next = page + 1 + } + + tmpl.Execute(w, map[string]any{ + "Tag": tag, + "Tags": tags, + "Imgs": data, + "Prev": page - 1, + "Next": next, + }) +}