commit 679e87d4fc5b55d8fa9c5a418e86bc153bc2c2fc
Author: Andrew Kloet <andrew@kloet.net>
Date: Thu, 17 Sep 2026 12:54:49 -0400
initial commit
Diffstat:
| A | LICENSE | | | 13 | +++++++++++++ |
| A | Makefile | | | 26 | ++++++++++++++++++++++++++ |
| A | tagpic | | | 112 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | tagpicd.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,
+ })
+}