Add Technical Kiwi website with Go, templ, and HTMX.
Single-page site with gallery by album and event, contact form over SMTP, Docker dev/prod setup, and on-server image derivatives. Gallery photos stay local (app/images/ is gitignored). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
65
app/internal/contact/validate.go
Normal file
65
app/internal/contact/validate.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxNameLen = 120
|
||||
maxEmailLen = 254
|
||||
maxMessageLen = 8000
|
||||
minMessageLen = 10
|
||||
)
|
||||
|
||||
// Submission is validated contact form input.
|
||||
type Submission struct {
|
||||
Name string
|
||||
Email string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Errors maps field names to user-facing messages.
|
||||
type Errors map[string]string
|
||||
|
||||
func (e Errors) Any() bool {
|
||||
return len(e) > 0
|
||||
}
|
||||
|
||||
// Parse reads and validates a contact form POST.
|
||||
func Parse(name, email, message string) (Submission, Errors) {
|
||||
errs := make(Errors)
|
||||
|
||||
name = strings.TrimSpace(name)
|
||||
email = strings.TrimSpace(email)
|
||||
message = strings.TrimSpace(message)
|
||||
|
||||
if name == "" {
|
||||
errs["name"] = "Name is required."
|
||||
} else if utf8.RuneCountInString(name) > maxNameLen {
|
||||
errs["name"] = "Name is too long."
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
errs["email"] = "Email is required."
|
||||
} else if utf8.RuneCountInString(email) > maxEmailLen {
|
||||
errs["email"] = "Email is too long."
|
||||
} else if _, err := mail.ParseAddress(email); err != nil {
|
||||
errs["email"] = "Enter a valid email address."
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
errs["message"] = "Message is required."
|
||||
} else if utf8.RuneCountInString(message) < minMessageLen {
|
||||
errs["message"] = "Message must be at least 10 characters."
|
||||
} else if utf8.RuneCountInString(message) > maxMessageLen {
|
||||
errs["message"] = "Message is too long."
|
||||
}
|
||||
|
||||
if errs.Any() {
|
||||
return Submission{}, errs
|
||||
}
|
||||
|
||||
return Submission{Name: name, Email: email, Message: message}, nil
|
||||
}
|
||||
27
app/internal/contact/validate_test.go
Normal file
27
app/internal/contact/validate_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package contact
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParse_valid(t *testing.T) {
|
||||
sub, errs := Parse("Jimmy", "jim@example.com", "Hello there, this is a test message.")
|
||||
if errs.Any() {
|
||||
t.Fatalf("unexpected errors: %v", errs)
|
||||
}
|
||||
if sub.Name != "Jimmy" || sub.Email != "jim@example.com" {
|
||||
t.Fatalf("got %+v", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_shortMessage(t *testing.T) {
|
||||
_, errs := Parse("Jimmy", "jim@example.com", "short")
|
||||
if !errs.Any() {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_invalidEmail(t *testing.T) {
|
||||
_, errs := Parse("Jimmy", "not-an-email", "This message is long enough to pass.")
|
||||
if errs["email"] == "" {
|
||||
t.Fatal("expected email error")
|
||||
}
|
||||
}
|
||||
198
app/internal/gallery/gallery.go
Normal file
198
app/internal/gallery/gallery.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Image is a gallery entry discovered on disk.
|
||||
type Image struct {
|
||||
RelPath string // e.g. templeoftechno/Anniversary/photo.jpg
|
||||
Album string // top-level folder, e.g. templeoftechno
|
||||
Collection string // event subfolder label, e.g. Anniversary
|
||||
CollectionKey string // slug for filters
|
||||
Filename string // basename
|
||||
URL string
|
||||
ThumbURL string
|
||||
HeroURL string
|
||||
Date time.Time
|
||||
Year int
|
||||
}
|
||||
|
||||
// List returns JPEG images from dir and subfolders (newest first).
|
||||
// Skips generated thumbs/ and hero/ directories.
|
||||
func List(dir string) ([]Image, error) {
|
||||
var images []Image
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if d.Name() == thumbDirName || d.Name() == heroDirName {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isJPEG(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !safeRelPath(rel) {
|
||||
return nil
|
||||
}
|
||||
|
||||
album := albumFromRel(rel)
|
||||
cKey, cLabel := collectionFromRel(rel)
|
||||
date, year := imageDate(path, d.Name())
|
||||
images = append(images, Image{
|
||||
RelPath: rel,
|
||||
Album: album,
|
||||
Collection: cLabel,
|
||||
CollectionKey: cKey,
|
||||
Filename: d.Name(),
|
||||
URL: "/images/" + URLPath(rel),
|
||||
ThumbURL: derivativeURL(dir, thumbDirName, rel),
|
||||
HeroURL: derivativeURL(dir, heroDirName, rel),
|
||||
Date: date,
|
||||
Year: year,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(images, func(i, j int) bool {
|
||||
if images[i].Date.Equal(images[j].Date) {
|
||||
return images[i].RelPath < images[j].RelPath
|
||||
}
|
||||
return images[i].Date.After(images[j].Date)
|
||||
})
|
||||
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// Collections returns sorted collection keys for an album (empty album = all).
|
||||
func Collections(images []Image, album string) []string {
|
||||
seen := make(map[string]string)
|
||||
var keys []string
|
||||
for _, img := range images {
|
||||
if img.CollectionKey == "" {
|
||||
continue
|
||||
}
|
||||
if album != "" && img.Album != album {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[img.CollectionKey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[img.CollectionKey] = img.Collection
|
||||
keys = append(keys, img.CollectionKey)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return seen[keys[i]] < seen[keys[j]]
|
||||
})
|
||||
return keys
|
||||
}
|
||||
|
||||
// Albums returns sorted unique album folder names.
|
||||
func Albums(images []Image) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var albums []string
|
||||
for _, img := range images {
|
||||
if img.Album == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[img.Album]; ok {
|
||||
continue
|
||||
}
|
||||
seen[img.Album] = struct{}{}
|
||||
albums = append(albums, img.Album)
|
||||
}
|
||||
sort.Strings(albums)
|
||||
return albums
|
||||
}
|
||||
|
||||
// AlbumLabel returns a display name for an album folder.
|
||||
func AlbumLabel(album string) string {
|
||||
labels := map[string]string{
|
||||
"escaperoom": "Escape room",
|
||||
"ledlyra": "LED Lyra",
|
||||
"misc": "Misc",
|
||||
"portal": "Portal",
|
||||
"templeoftechno": "Temple of techno",
|
||||
"connectionmachine": "Connection machine",
|
||||
}
|
||||
if label, ok := labels[album]; ok {
|
||||
return label
|
||||
}
|
||||
return strings.ReplaceAll(album, "-", " ")
|
||||
}
|
||||
|
||||
func albumFromRel(rel string) string {
|
||||
if i := strings.Index(rel, "/"); i >= 0 {
|
||||
return rel[:i]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SafeRelPath reports whether rel is safe to serve (exported for handlers).
|
||||
func SafeRelPath(rel string) bool {
|
||||
return safeRelPath(rel)
|
||||
}
|
||||
|
||||
func safeRelPath(rel string) bool {
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func derivativeURL(imagesDir, subdir, rel string) string {
|
||||
if _, err := os.Stat(filepath.Join(imagesDir, subdir, filepath.FromSlash(rel))); err == nil {
|
||||
return "/images/" + subdir + "/" + URLPath(rel)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func imageDate(fullPath, filename string) (time.Time, int) {
|
||||
if t, y := parseDate(filename); !t.IsZero() {
|
||||
return t, y
|
||||
}
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return time.Time{}, 0
|
||||
}
|
||||
mod := info.ModTime()
|
||||
return mod, mod.Year()
|
||||
}
|
||||
|
||||
func parseDate(filename string) (time.Time, int) {
|
||||
base := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
|
||||
// IMG_YYYYMMDD_HHMMSS.jpg
|
||||
if strings.HasPrefix(base, "IMG_") && len(base) >= 12 {
|
||||
raw := strings.TrimPrefix(base, "IMG_")
|
||||
if t, err := time.Parse("20060102", raw[:8]); err == nil {
|
||||
return t, t.Year()
|
||||
}
|
||||
}
|
||||
|
||||
// 20220723_231556.jpg
|
||||
if len(base) >= 15 && base[8] == '_' {
|
||||
if t, err := time.Parse("20060102_150405", base[:15]); err == nil {
|
||||
return t, t.Year()
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}, 0
|
||||
}
|
||||
57
app/internal/gallery/gallery_test.go
Normal file
57
app/internal/gallery/gallery_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlbumFromRel(t *testing.T) {
|
||||
if albumFromRel("portal/IMG_20241012_115041.jpg") != "portal" {
|
||||
t.Fatal("expected portal album")
|
||||
}
|
||||
if albumFromRel("IMG_20241012_115041.jpg") != "" {
|
||||
t.Fatal("expected empty album for root file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRelPath(t *testing.T) {
|
||||
if !safeRelPath("portal/foo.jpg") {
|
||||
t.Fatal("valid path rejected")
|
||||
}
|
||||
if safeRelPath("../etc/passwd") {
|
||||
t.Fatal("traversal allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_subfolders(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
album := filepath.Join(dir, "portal")
|
||||
if err := os.MkdirAll(album, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "IMG_20240101_120000.jpg"
|
||||
if err := os.WriteFile(filepath.Join(album, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
images, err := List(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(images) != 1 || images[0].Album != "portal" || images[0].RelPath != "portal/"+name {
|
||||
t.Fatalf("got %+v", images)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollections_nested(t *testing.T) {
|
||||
images := []Image{
|
||||
{Album: "templeoftechno", CollectionKey: "anniversary", Collection: "Anniversary"},
|
||||
{Album: "templeoftechno", CollectionKey: "groovatory", Collection: "Groovatory"},
|
||||
{Album: "portal", CollectionKey: "x", Collection: "X"},
|
||||
}
|
||||
keys := Collections(images, "templeoftechno")
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("got %v", keys)
|
||||
}
|
||||
}
|
||||
34
app/internal/gallery/hero.go
Normal file
34
app/internal/gallery/hero.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const defaultHeroRel = "connectionmachine/20220723_231556.jpg"
|
||||
|
||||
// HeroRelPath returns the configured hero image path relative to images/.
|
||||
func HeroRelPath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("HERO_IMAGE")); p != "" {
|
||||
return filepathToSlash(p)
|
||||
}
|
||||
return defaultHeroRel
|
||||
}
|
||||
|
||||
// SelectHero picks the homepage hero from the gallery list.
|
||||
func SelectHero(images []Image) (Image, bool) {
|
||||
want := HeroRelPath()
|
||||
for _, img := range images {
|
||||
if img.RelPath == want {
|
||||
return img, true
|
||||
}
|
||||
}
|
||||
if len(images) > 0 {
|
||||
return images[0], true
|
||||
}
|
||||
return Image{}, false
|
||||
}
|
||||
|
||||
func filepathToSlash(p string) string {
|
||||
return strings.ReplaceAll(p, "\\", "/")
|
||||
}
|
||||
22
app/internal/gallery/hero_test.go
Normal file
22
app/internal/gallery/hero_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package gallery
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSelectHero_configured(t *testing.T) {
|
||||
t.Setenv("HERO_IMAGE", "connectionmachine/20220723_231556.jpg")
|
||||
images := []Image{
|
||||
{RelPath: "templeoftechno/IMG_20250817_020719.jpg"},
|
||||
{RelPath: "connectionmachine/20220723_231556.jpg", HeroURL: "/images/hero/connectionmachine/20220723_231556.jpg"},
|
||||
}
|
||||
hero, ok := SelectHero(images)
|
||||
if !ok || hero.RelPath != "connectionmachine/20220723_231556.jpg" {
|
||||
t.Fatalf("got %+v ok=%v", hero, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDate_20220723(t *testing.T) {
|
||||
tm, year := parseDate("20220723_231556.jpg")
|
||||
if tm.IsZero() || year != 2022 {
|
||||
t.Fatalf("got %v year=%d", tm, year)
|
||||
}
|
||||
}
|
||||
59
app/internal/gallery/path.go
Normal file
59
app/internal/gallery/path.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var zipExportSuffix = regexp.MustCompile(`-\d{8}T\d{6}Z-1-\d+$`)
|
||||
|
||||
// URLPath escapes a relative image path for use in URL paths.
|
||||
func URLPath(rel string) string {
|
||||
parts := strings.Split(rel, "/")
|
||||
for i, p := range parts {
|
||||
parts[i] = url.PathEscape(p)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func collectionFromRel(rel string) (key, label string) {
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) <= 2 {
|
||||
return "", ""
|
||||
}
|
||||
inner := strings.TrimSpace(parts[len(parts)-2])
|
||||
label = cleanCollectionName(inner)
|
||||
if label == "" {
|
||||
return "", ""
|
||||
}
|
||||
return collectionKey(label), label
|
||||
}
|
||||
|
||||
func cleanCollectionName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = zipExportSuffix.ReplaceAllString(name, "")
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func collectionKey(label string) string {
|
||||
key := strings.ToLower(label)
|
||||
key = strings.ReplaceAll(key, " ", "-")
|
||||
key = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, key)
|
||||
return key
|
||||
}
|
||||
|
||||
// CollectionLabel returns display name for a collection filter key.
|
||||
func CollectionLabel(key string, images []Image) string {
|
||||
for _, img := range images {
|
||||
if img.CollectionKey == key {
|
||||
return img.Collection
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
22
app/internal/gallery/path_test.go
Normal file
22
app/internal/gallery/path_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package gallery
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCollectionFromRel(t *testing.T) {
|
||||
rel := "templeoftechno/Anniversary-20251105T063538Z-1-001/Anniversary/photo.jpg"
|
||||
key, label := collectionFromRel(rel)
|
||||
if label != "Anniversary" {
|
||||
t.Fatalf("label=%q", label)
|
||||
}
|
||||
if key != "anniversary" {
|
||||
t.Fatalf("key=%q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLPath_spaces(t *testing.T) {
|
||||
got := URLPath("templeoftechno/The Groovatory/foo.jpg")
|
||||
want := "templeoftechno/The%20Groovatory/foo.jpg"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
151
app/internal/gallery/thumbs.go
Normal file
151
app/internal/gallery/thumbs.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
const (
|
||||
gridThumbMaxEdge = 320
|
||||
heroThumbMaxEdge = 640
|
||||
gridThumbQuality = 75
|
||||
heroThumbQuality = 78
|
||||
thumbDirName = "thumbs"
|
||||
heroDirName = "hero"
|
||||
)
|
||||
|
||||
// EnsureThumbnails creates grid and hero JPEG derivatives mirroring album folders.
|
||||
func EnsureThumbnails(imagesDir string) error {
|
||||
if err := os.MkdirAll(filepath.Join(imagesDir, thumbDirName), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(imagesDir, heroDirName), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return filepath.WalkDir(imagesDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if d.Name() == thumbDirName || d.Name() == heroDirName {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isJPEG(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(imagesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !safeRelPath(rel) {
|
||||
return nil
|
||||
}
|
||||
|
||||
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(rel))
|
||||
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeThumb(path, thumbDst, gridThumbMaxEdge, gridThumbQuality); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeThumb(path, heroDst, heroThumbMaxEdge, heroThumbQuality)
|
||||
})
|
||||
}
|
||||
|
||||
// EnsurePriority generates thumbnails for one image before first page load.
|
||||
func EnsurePriority(imagesDir, relPath string) error {
|
||||
if !safeRelPath(relPath) || !isJPEG(filepath.Base(relPath)) {
|
||||
return nil
|
||||
}
|
||||
src := filepath.Join(imagesDir, filepath.FromSlash(relPath))
|
||||
thumbDst := filepath.Join(imagesDir, thumbDirName, filepath.FromSlash(relPath))
|
||||
heroDst := filepath.Join(imagesDir, heroDirName, filepath.FromSlash(relPath))
|
||||
if err := os.MkdirAll(filepath.Dir(thumbDst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(heroDst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeThumb(src, heroDst, heroThumbMaxEdge, heroThumbQuality); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeThumb(src, thumbDst, gridThumbMaxEdge, gridThumbQuality)
|
||||
}
|
||||
|
||||
func writeThumb(src, dst string, maxEdge, quality int) error {
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dstInfo, err := os.Stat(dst); err == nil && !dstInfo.ModTime().Before(srcInfo.ModTime()) {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, err := jpeg.Decode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
thumb := resizeThumb(img, maxEdge)
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
return jpeg.Encode(out, thumb, &jpeg.Options{Quality: quality})
|
||||
}
|
||||
|
||||
func resizeThumb(src image.Image, maxEdge int) image.Image {
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= 0 || h <= 0 {
|
||||
return src
|
||||
}
|
||||
|
||||
scale := float64(maxEdge) / float64(w)
|
||||
if h > w {
|
||||
scale = float64(maxEdge) / float64(h)
|
||||
}
|
||||
nw := int(float64(w) * scale)
|
||||
nh := int(float64(h) * scale)
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
if nw >= w && nh >= h {
|
||||
return src
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
func isJPEG(name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
return ext == ".jpg" || ext == ".jpeg"
|
||||
}
|
||||
183
app/internal/handlers/handlers.go
Normal file
183
app/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"technical.kiwi/website/internal/contact"
|
||||
"technical.kiwi/website/internal/gallery"
|
||||
"technical.kiwi/website/internal/mail"
|
||||
"technical.kiwi/website/internal/middleware"
|
||||
"technical.kiwi/website/templates"
|
||||
)
|
||||
|
||||
// Server holds shared state for HTTP handlers.
|
||||
type Server struct {
|
||||
ImagesDir string
|
||||
StaticDir string
|
||||
Images []gallery.Image
|
||||
Hero gallery.Image
|
||||
HasHero bool
|
||||
Mail *mail.Config
|
||||
ContactEnabled bool
|
||||
}
|
||||
|
||||
// New builds a Server after loading the image gallery.
|
||||
func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) {
|
||||
images, err := gallery.List(imagesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hero, hasHero := gallery.SelectHero(images)
|
||||
if hasHero {
|
||||
if err := gallery.EnsurePriority(imagesDir, hero.RelPath); err != nil {
|
||||
log.Printf("hero image: %v", err)
|
||||
} else {
|
||||
images, err = gallery.List(imagesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hero, hasHero = gallery.SelectHero(images)
|
||||
}
|
||||
log.Printf("hero image: %s", hero.RelPath)
|
||||
}
|
||||
srv := &Server{
|
||||
ImagesDir: imagesDir,
|
||||
StaticDir: staticDir,
|
||||
Images: images,
|
||||
Hero: hero,
|
||||
HasHero: hasHero,
|
||||
Mail: mailCfg,
|
||||
ContactEnabled: mailCfg != nil,
|
||||
}
|
||||
go func() {
|
||||
if err := gallery.EnsureThumbnails(imagesDir); err != nil {
|
||||
log.Printf("gallery thumbnails: %v", err)
|
||||
return
|
||||
}
|
||||
log.Print("gallery thumbnails: ready")
|
||||
}()
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
templates.Home(s.Images, s.Hero, s.HasHero, s.ContactEnabled).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(r.FormValue("website")) != "" {
|
||||
templates.ContactSuccess().Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
sub, errs := contact.Parse(
|
||||
r.FormValue("name"),
|
||||
r.FormValue("email"),
|
||||
r.FormValue("message"),
|
||||
)
|
||||
if errs.Any() {
|
||||
templates.ContactValidationAlert(errs).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if s.Mail == nil {
|
||||
templates.ContactSendError().Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Mail.ContactEmail(sub.Name, sub.Email, sub.Message); err != nil {
|
||||
log.Printf("contact mail: %v", err)
|
||||
templates.ContactSendError().Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
templates.ContactSuccess().Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) galleryPartial(w http.ResponseWriter, r *http.Request) {
|
||||
album := r.URL.Query().Get("album")
|
||||
collection := r.URL.Query().Get("collection")
|
||||
filtered := s.filterImages(album, collection)
|
||||
templates.GalleryGrid(filtered).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) imageModal(w http.ResponseWriter, r *http.Request) {
|
||||
rel := r.PathValue("path")
|
||||
if !gallery.SafeRelPath(rel) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
img, ok := s.findImage(rel)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
idx := s.indexOf(rel)
|
||||
prev, next := "", ""
|
||||
if idx > 0 {
|
||||
prev = s.Images[idx-1].RelPath
|
||||
}
|
||||
if idx >= 0 && idx < len(s.Images)-1 {
|
||||
next = s.Images[idx+1].RelPath
|
||||
}
|
||||
templates.ImageModal(img, prev, next).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (s *Server) filterImages(album, collection string) []gallery.Image {
|
||||
if album == "" && collection == "" {
|
||||
return s.Images
|
||||
}
|
||||
var out []gallery.Image
|
||||
for _, img := range s.Images {
|
||||
if album != "" && img.Album != album {
|
||||
continue
|
||||
}
|
||||
if collection != "" && img.CollectionKey != collection {
|
||||
continue
|
||||
}
|
||||
out = append(out, img)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) findImage(rel string) (gallery.Image, bool) {
|
||||
for _, img := range s.Images {
|
||||
if img.RelPath == rel {
|
||||
return img, true
|
||||
}
|
||||
}
|
||||
return gallery.Image{}, false
|
||||
}
|
||||
|
||||
func (s *Server) indexOf(rel string) int {
|
||||
for i, img := range s.Images {
|
||||
if img.RelPath == rel {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// RegisterRoutes attaches handlers to mux.
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /{$}", s.index)
|
||||
mux.HandleFunc("POST /contact", s.contact)
|
||||
mux.HandleFunc("GET /gallery", s.galleryPartial)
|
||||
mux.HandleFunc("GET /gallery/{path...}", s.imageModal)
|
||||
staticHandler := middleware.CacheStatic(s.StaticDir, 7*24*time.Hour)
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", staticHandler))
|
||||
mux.Handle("GET /images/", http.StripPrefix("/images/", middleware.CacheImages(s.ImagesDir)))
|
||||
}
|
||||
71
app/internal/mail/config.go
Normal file
71
app/internal/mail/config.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config holds SMTP relay settings from the environment.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
From string
|
||||
To string
|
||||
// TLS: "auto" (STARTTLS on 587, plain on 25), "tls" (implicit TLS, e.g. 465), "plain"
|
||||
TLS string
|
||||
}
|
||||
|
||||
// Load reads SMTP settings from environment variables.
|
||||
//
|
||||
// Required: SMTP_HOST, SMTP_FROM, SMTP_TO
|
||||
// Optional: SMTP_PORT (587), SMTP_USER, SMTP_PASSWORD, SMTP_TLS (auto)
|
||||
func Load() (Config, error) {
|
||||
host := os.Getenv("SMTP_HOST")
|
||||
if host == "" {
|
||||
return Config{}, fmt.Errorf("SMTP_HOST is required")
|
||||
}
|
||||
|
||||
from := os.Getenv("SMTP_FROM")
|
||||
if from == "" {
|
||||
return Config{}, fmt.Errorf("SMTP_FROM is required")
|
||||
}
|
||||
|
||||
to := os.Getenv("SMTP_TO")
|
||||
if to == "" {
|
||||
return Config{}, fmt.Errorf("SMTP_TO is required")
|
||||
}
|
||||
|
||||
port := 587
|
||||
if p := os.Getenv("SMTP_PORT"); p != "" {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("SMTP_PORT: %w", err)
|
||||
}
|
||||
port = n
|
||||
}
|
||||
|
||||
tls := os.Getenv("SMTP_TLS")
|
||||
if tls == "" {
|
||||
tls = "auto"
|
||||
}
|
||||
|
||||
return Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: os.Getenv("SMTP_USER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: from,
|
||||
To: to,
|
||||
TLS: tls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether relay settings are present.
|
||||
func Enabled() bool {
|
||||
return os.Getenv("SMTP_HOST") != "" &&
|
||||
os.Getenv("SMTP_FROM") != "" &&
|
||||
os.Getenv("SMTP_TO") != ""
|
||||
}
|
||||
163
app/internal/mail/send.go
Normal file
163
app/internal/mail/send.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContactEmail sends a contact form message via the configured SMTP relay.
|
||||
func (c Config) ContactEmail(name, replyTo, message string) error {
|
||||
subject := fmt.Sprintf("Contact form: %s", name)
|
||||
var body bytes.Buffer
|
||||
body.WriteString(fmt.Sprintf("Name: %s\n", name))
|
||||
body.WriteString(fmt.Sprintf("Email: %s\n\n", replyTo))
|
||||
body.WriteString(message)
|
||||
|
||||
return c.send(subject, body.String(), replyTo)
|
||||
}
|
||||
|
||||
func (c Config) send(subject, body, replyTo string) error {
|
||||
addr := fmt.Sprintf("%s:%d", c.Host, c.Port)
|
||||
|
||||
var msg bytes.Buffer
|
||||
msg.WriteString(fmt.Sprintf("From: %s\r\n", c.From))
|
||||
msg.WriteString(fmt.Sprintf("To: %s\r\n", c.To))
|
||||
if replyTo != "" {
|
||||
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", replyTo))
|
||||
}
|
||||
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
|
||||
raw := msg.Bytes()
|
||||
from := extractAddr(c.From)
|
||||
|
||||
var auth smtp.Auth
|
||||
if c.User != "" {
|
||||
auth = smtp.PlainAuth("", c.User, c.Password, c.Host)
|
||||
}
|
||||
|
||||
switch c.tlsMode() {
|
||||
case "plain":
|
||||
return smtp.SendMail(addr, auth, from, []string{c.To}, raw)
|
||||
case "tls":
|
||||
return c.sendTLS(addr, auth, from, raw)
|
||||
default:
|
||||
return c.sendStartTLS(addr, auth, from, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) tlsMode() string {
|
||||
switch strings.ToLower(c.TLS) {
|
||||
case "plain", "tls":
|
||||
return strings.ToLower(c.TLS)
|
||||
}
|
||||
if c.Port == 465 {
|
||||
return "tls"
|
||||
}
|
||||
if c.Port == 25 {
|
||||
return "plain"
|
||||
}
|
||||
return "starttls"
|
||||
}
|
||||
|
||||
func (c Config) sendStartTLS(addr string, auth smtp.Auth, from string, raw []byte) error {
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, c.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(c.To); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func (c Config) sendTLS(addr string, auth smtp.Auth, from string, raw []byte) error {
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: c.Host})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, c.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Rcpt(c.To); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func extractAddr(from string) string {
|
||||
if i := strings.Index(from, "<"); i >= 0 {
|
||||
if j := strings.Index(from[i:], ">"); j > 0 {
|
||||
return strings.TrimSpace(from[i+1 : i+j])
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(from)
|
||||
}
|
||||
61
app/internal/middleware/middleware.go
Normal file
61
app/internal/middleware/middleware.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.Writer.Write(b)
|
||||
}
|
||||
|
||||
// Gzip compresses responses when the client accepts it.
|
||||
func Gzip(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CacheStatic wraps a file server with long-lived cache headers.
|
||||
func CacheStatic(dir string, maxAge time.Duration) http.Handler {
|
||||
fs := http.FileServer(http.Dir(dir))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age="+formatMaxAge(maxAge)+", immutable")
|
||||
fs.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// CacheImages wraps image serving; thumbs cache longer than originals.
|
||||
func CacheImages(dir string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if strings.HasPrefix(rel, "thumbs/") || strings.HasPrefix(rel, "hero/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
}
|
||||
http.FileServer(http.Dir(dir)).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func formatMaxAge(d time.Duration) string {
|
||||
return strconv.Itoa(int(d.Seconds()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user