feat: implement shared-album multi-user photo album

Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client
(path-style, region garage), embedded golang-migrate migrations, JWT auth with
refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix,
photo upload + async imaging/goexif WebP variants, cursor pagination, presigned
URLs, likes/comments, SSE hub (album + personal), notifications, share links.

Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh,
auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with
reconnect + REST catch-up, three themes + system via data-theme.

Deploy without Docker: Makefile builds the frontend into backend/static and
embeds it (go:embed) into a single statically linked binary; systemd unit and
nginx SSE example included.

Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep
CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
iu
2026-10-10 12:50:33 +03:00
commit 2b2b342492
105 changed files with 12494 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
package photos
import (
"bytes"
"image"
"time"
"github.com/disintegration/imaging"
"github.com/gen2brain/webp"
"github.com/rwcarlsen/goexif/exif"
)
type ImageMeta struct {
Width int
Height int
MimeType string
TakenAt *time.Time
Latitude *float64
Longitude *float64
Thumb []byte
Preview []byte
}
func inspect(data []byte) (width, height int, ok bool) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return 0, 0, false
}
return cfg.Width, cfg.Height, true
}
func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) {
x, err := exif.Decode(bytes.NewReader(data))
if err != nil {
return nil, nil, nil
}
if dt, err := x.Get(exif.DateTimeOriginal); err == nil {
if s, err := dt.StringVal(); err == nil {
if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil {
utc := t.UTC()
taken = &utc
}
}
}
if la, lo, err := x.LatLong(); err == nil {
lat, lon = &la, &lo
}
return taken, lat, lon
}
func buildVariants(data []byte) (thumb, preview []byte, err error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return nil, nil, err
}
thumbImg := imaging.Fit(img, 200, 200, imaging.Lanczos)
previewImg := imaging.Fit(img, 1200, 1200, imaging.Lanczos)
opts := webp.Options{Quality: 82}
var thumbBuf bytes.Buffer
if err := webp.Encode(&thumbBuf, thumbImg, opts); err != nil {
return nil, nil, err
}
var previewBuf bytes.Buffer
if err := webp.Encode(&previewBuf, previewImg, opts); err != nil {
return nil, nil, err
}
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
}