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:
2026-05-25 23:57:59 +12:00
parent c21be097b0
commit 509e7ccb43
33 changed files with 2635 additions and 1 deletions

View 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
}

View 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")
}
}

View 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
}

View 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)
}
}

View 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, "\\", "/")
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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"
}

View 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)))
}

View 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
View 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)
}

View 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()))
}