diff --git a/go.mod b/go.mod index 5c72624..7efe9ea 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.schreifuchs.ch/schreifuchs/schreifuchs.ch -go 1.25.7 +go 1.26 tool github.com/a-h/templ/cmd/templ @@ -22,6 +22,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + golang.org/x/image v0.36.0 // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.16.0 // indirect diff --git a/go.sum b/go.sum index 4381f14..862c19c 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= +golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= @@ -51,6 +53,7 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/components/images/controller.go b/internal/components/images/controller.go index 73929db..56a6613 100644 --- a/internal/components/images/controller.go +++ b/internal/components/images/controller.go @@ -3,10 +3,20 @@ package images import ( "bytes" "context" + "encoding/base64" + "encoding/binary" "errors" "fmt" + "hash/fnv" + "image" + "image/jpeg" + _ "image/jpeg" + _ "image/png" "io" "strings" + "time" + + "github.com/valkey-io/valkey-go" ) var ErrNotAnImage = errors.New("not an image") @@ -24,7 +34,7 @@ func (s *Service) getMime(path string) (mimeType string, err error) { return } -func (s *Service) GetImage(ctx context.Context, uid string) (img io.Reader, mimeType string, err error) { +func (s *Service) getImage(uid string) (file []byte, mimeType string, err error) { path, err := PathFromUID(uid) if err != nil { err = fmt.Errorf("could not get path: %w", err) @@ -40,10 +50,69 @@ func (s *Service) GetImage(ctx context.Context, uid string) (img io.Reader, mime return } - file, err := s.fs.Read(path) + file, err = s.fs.Read(path) if err != nil { err = fmt.Errorf("image file could not be fetched") } - return bytes.NewBuffer(file), mimeType, err + return +} + +func (s *Service) GetImage(ctx context.Context, uid string, options Options) (img io.Reader, mimeType string, err error) { + rawImage, mimeType, err := s.getImage(uid) + if err != nil { + return + } + + hash, err := hash(rawImage, options) + if err != nil { + return + } + + if imgBytes, err := s.cache.Do(ctx, s.cache.B().Get().Key("img:"+hash).Build()).AsBytes(); err == nil { + return bytes.NewBuffer(imgBytes), mimeType, err + } + + decImage, _, err := image.Decode(bytes.NewBuffer(rawImage)) + if err != nil { + return + } + + decImage = scale(decImage, options) + outBuff := bytes.NewBuffer(make([]byte, 0, 1024)) + + if options.Quality == 0 { + options.Quality = 80 + } + + err = jpeg.Encode(outBuff, decImage, &jpeg.Options{Quality: options.Quality}) + if err != nil { + return + } + if err = s.cache.Do(ctx, s.cache.B().Set().Key("img:"+hash).Value(valkey.BinaryString(outBuff.Bytes())).Ex(time.Hour*48).Build()).Error(); err == nil { + return outBuff, "image/jpeg", err + } + + return outBuff, "image/jpeg", err +} + +func hash(bytes []byte, options Options) (hash string, err error) { + h := fnv.New128() + + if _, err = h.Write(bytes); err != nil { + return + } + if err = binary.Write(h, binary.LittleEndian, int64(options.Height)); err != nil { + return + } + if err = binary.Write(h, binary.LittleEndian, int64(options.Width)); err != nil { + return + } + if err = binary.Write(h, binary.LittleEndian, int64(options.Quality)); err != nil { + return + } + + res := h.Sum(make([]byte, 0, 128/8)) + + return base64.RawStdEncoding.EncodeToString(res), nil } diff --git a/internal/components/images/resorce.go b/internal/components/images/resorce.go index 23d3c57..98d36e8 100644 --- a/internal/components/images/resorce.go +++ b/internal/components/images/resorce.go @@ -1,14 +1,26 @@ package images -import "os" +import ( + "os" -type Service struct { - fs fileSystem + "github.com/valkey-io/valkey-go" +) + +type Options struct { + Width int // max width, 0 is unlimited + Height int // max Height, 0 is unlimited + Quality int // quality of the output jpeg } -func New(fs fileSystem) *Service { +type Service struct { + fs fileSystem + cache valkey.Client +} + +func New(fs fileSystem, cache valkey.Client) *Service { return &Service{ - fs: fs, + fs: fs, + cache: cache, } } diff --git a/internal/components/images/scaling.go b/internal/components/images/scaling.go new file mode 100644 index 0000000..1244c27 --- /dev/null +++ b/internal/components/images/scaling.go @@ -0,0 +1,55 @@ +package images + +import ( + "image" + "math" + + "golang.org/x/image/draw" +) + +func scale(src image.Image, options Options) image.Image { + // Set the expected size that you want: + dst := image.NewRGBA( + calculateFit(src.Bounds(), options), + ) + + // Resize: + draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil) + + // Encode to `output`: + return dst +} + +// calculateFit returns the new dimensions (width, height) that fit within the +// provided Options without upscaling and while maintaining the aspect ratio. +func calculateFit(bounds image.Rectangle, opt Options) image.Rectangle { + // If no limits are set, or the image is already smaller than the limits, + // return original dimensions (No Upscaling). + if (opt.Width == 0 || bounds.Max.X <= opt.Width) && (opt.Height == 0 || bounds.Max.Y <= opt.Height) { + return bounds + } + + // Calculate ratios for width and height + ratioW := float64(opt.Width) / float64(bounds.Max.X) + ratioH := float64(opt.Height) / float64(bounds.Max.Y) + + // Determine the scale factor + var scale float64 + if opt.Width > 0 && opt.Height > 0 { + // Use the smaller ratio to ensure it fits in both bounds + scale = math.Min(ratioW, ratioH) + } else if opt.Width > 0 { + scale = ratioW + } else if opt.Height > 0 { + scale = ratioH + } else { + return bounds + } + + return image.Rect( + 0, + 0, + int(math.Round(float64(bounds.Max.X)*scale)), + int(math.Round(float64(bounds.Max.Y)*scale)), + ) +} diff --git a/internal/filesystem/cache.go b/internal/filesystem/cache.go index 0d24a7a..022e346 100644 --- a/internal/filesystem/cache.go +++ b/internal/filesystem/cache.go @@ -14,7 +14,7 @@ const keyStat = "webdav:stat:" // CachedClient wraps a FileSystem implementation with Valkey caching. type CachedClient struct { impl FS - client valkey.Client + cache valkey.Client ttl time.Duration hits atomic.Int64 misses atomic.Int64 @@ -25,9 +25,9 @@ type CachedClient struct { // NewCachedClient creates a new CachedClient. func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *CachedClient { c := &CachedClient{ - impl: impl, - client: client, - ttl: refreshtime * 5, + impl: impl, + cache: client, + ttl: refreshtime * 5, } go func() { @@ -50,7 +50,7 @@ func (c *CachedClient) revalidate(ctx context.Context) { func (c *CachedClient) scanAndProcess(ctx context.Context, match string, process func(key string) error) { cursor := uint64(0) for { - res, err := c.client.Do(ctx, c.client.B().Scan().Cursor(cursor).Match(match).Build()).AsScanEntry() + res, err := c.cache.Do(ctx, c.cache.B().Scan().Cursor(cursor).Match(match).Build()).AsScanEntry() if err != nil { slog.Error("scan failed", "match", match, "err", err) return diff --git a/internal/filesystem/read.go b/internal/filesystem/read.go index bd8480d..ac4b728 100644 --- a/internal/filesystem/read.go +++ b/internal/filesystem/read.go @@ -22,7 +22,7 @@ func (c *CachedClient) Read(path string) ([]byte, error) { ctx := context.Background() // Try cache - cached, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).AsBytes() + cached, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).AsBytes() if err == nil && len(cached) > 0 { c.readHits.Add(1) c.hits.Add(1) @@ -58,7 +58,7 @@ func (c *CachedClient) Read(path string) ([]byte, error) { } // Cache - c.client.Do(ctx, c.client.B().Set().Key(key).Value(valkey.BinaryString(cached)).Ex(c.ttl).Build()) + c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(valkey.BinaryString(cached)).Ex(c.ttl).Build()) return data, nil } @@ -67,7 +67,7 @@ func (c *CachedClient) revalidateRead(ctx context.Context) { startTime := time.Now() c.scanAndProcess(ctx, keyRead+"*", func(key string) error { path := strings.TrimPrefix(key, keyRead) - cached, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).AsBytes() + cached, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).AsBytes() if err != nil { // Key might be gone or error fetching return nil @@ -76,13 +76,13 @@ func (c *CachedClient) revalidateRead(ctx context.Context) { var cachedFile readFile err = json.Unmarshal(cached, &cachedFile) if err != nil { - c.client.Do(ctx, c.client.B().Del().Key(key).Build()) + c.cache.Do(ctx, c.cache.B().Del().Key(key).Build()) return err } info, err := c.impl.Stat(path) if err != nil { - c.client.Do(ctx, c.client.B().Del().Key(key).Build()) + c.cache.Do(ctx, c.cache.B().Del().Key(key).Build()) return nil } @@ -99,9 +99,9 @@ func (c *CachedClient) revalidateRead(ctx context.Context) { return err } - c.client.Do(ctx, c.client.B().Set().Key(key).Value(valkey.BinaryString(newCached)).Ex(c.ttl).Build()) + c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(valkey.BinaryString(newCached)).Ex(c.ttl).Build()) } else { - c.client.Do(ctx, c.client.B().Expire().Key(key).Seconds(int64(c.ttl/time.Second)).Build()) + c.cache.Do(ctx, c.cache.B().Expire().Key(key).Seconds(int64(c.ttl/time.Second)).Build()) } return nil }) diff --git a/internal/filesystem/readdir.go b/internal/filesystem/readdir.go index 6e7c3d9..a9d0fc2 100644 --- a/internal/filesystem/readdir.go +++ b/internal/filesystem/readdir.go @@ -16,7 +16,7 @@ func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) { ctx := context.Background() // Try cache - val, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).ToString() + val, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).ToString() if err == nil && val != "" { var cached []File if err := json.Unmarshal([]byte(val), &cached); err == nil { @@ -47,7 +47,7 @@ func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) { bytes, err := json.Marshal(cached) if err == nil { - c.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) + c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) } return infos, nil @@ -59,7 +59,7 @@ func (c *CachedClient) revalidateReadDir(ctx context.Context) { path := strings.TrimPrefix(key, keyReadDir) infos, err := c.impl.ReadDir(path) if err != nil { - c.client.Do(ctx, c.client.B().Del().Key(key).Build()) + c.cache.Do(ctx, c.cache.B().Del().Key(key).Build()) return err } @@ -71,7 +71,7 @@ func (c *CachedClient) revalidateReadDir(ctx context.Context) { bytes, err := json.Marshal(cached) if err == nil { - c.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) + c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) } return nil }) diff --git a/internal/filesystem/stat.go b/internal/filesystem/stat.go index fd07db1..05c3eba 100644 --- a/internal/filesystem/stat.go +++ b/internal/filesystem/stat.go @@ -14,7 +14,7 @@ func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) { ctx := context.Background() // Try cache - val, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).ToString() + val, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).ToString() if err == nil && val != "" { var f File if err := json.Unmarshal([]byte(val), &f); err == nil { @@ -39,7 +39,7 @@ func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) { bytes, err := json.Marshal(info) if err == nil { - c.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) + c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) } return @@ -51,7 +51,7 @@ func (c *CachedClient) revalidateStat(ctx context.Context) { path := strings.TrimPrefix(key, keyStat) info, err := c.impl.Stat(path) if err != nil { - c.client.Do(ctx, c.client.B().Del().Key(key).Build()) + c.cache.Do(ctx, c.cache.B().Del().Key(key).Build()) return err } @@ -59,7 +59,7 @@ func (c *CachedClient) revalidateStat(ctx context.Context) { f := ParseToFile(info) bytes, err := json.Marshal(f) if err == nil { - c.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) + c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) } return nil }) diff --git a/internal/handlers/handleimage/conroler.go b/internal/handlers/handleimage/conroler.go deleted file mode 100644 index 8a53e31..0000000 --- a/internal/handlers/handleimage/conroler.go +++ /dev/null @@ -1,20 +0,0 @@ -package handleimage - -import ( - "io" - "log/slog" - "net/http" -) - -func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) { - uid := r.PathValue("uid") - - img, mime, err := h.img.GetImage(r.Context(), uid) - if err != nil { - slog.Error("error wile serving image", "err", err, "uid", uid) - w.WriteHeader(http.StatusInternalServerError) - return - } - w.Header().Add("Content-Type", mime) - io.Copy(w, img) -} diff --git a/internal/handlers/handlehome/controller.go b/internal/handlers/resthome/controller.go similarity index 97% rename from internal/handlers/handlehome/controller.go rename to internal/handlers/resthome/controller.go index 87d3210..0c22bb2 100644 --- a/internal/handlers/handlehome/controller.go +++ b/internal/handlers/resthome/controller.go @@ -1,4 +1,4 @@ -package handlehome +package resthome import ( "net/http" diff --git a/internal/handlers/handlehome/resource.go b/internal/handlers/resthome/resource.go similarity index 96% rename from internal/handlers/handlehome/resource.go rename to internal/handlers/resthome/resource.go index 6bb5371..f1d0cb2 100644 --- a/internal/handlers/handlehome/resource.go +++ b/internal/handlers/resthome/resource.go @@ -1,4 +1,4 @@ -package handlehome +package resthome import ( "context" diff --git a/internal/handlers/restimage/conroller.go b/internal/handlers/restimage/conroller.go new file mode 100644 index 0000000..03645db --- /dev/null +++ b/internal/handlers/restimage/conroller.go @@ -0,0 +1,43 @@ +package restimage + +import ( + "io" + "log/slog" + "net/http" + + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restutil" +) + +func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) { + uid := r.PathValue("uid") + + var err error + options := images.Options{} + options.Height, err = restutil.IntParam(r, "h") + if err != nil { + restutil.SendErr(w, err) + return + } + options.Width, err = restutil.IntParam(r, "w") + if err != nil { + restutil.SendErr(w, err) + return + } + + options.Quality, err = restutil.IntParam(r, "q") + if err != nil { + restutil.SendErr(w, err) + return + } + + img, mime, err := h.img.GetImage(r.Context(), uid, options) + if err != nil { + slog.Error("error wile serving image", "err", err, "uid", uid) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", mime) + io.Copy(w, img) +} diff --git a/internal/handlers/handleimage/resource.go b/internal/handlers/restimage/resource.go similarity index 56% rename from internal/handlers/handleimage/resource.go rename to internal/handlers/restimage/resource.go index 913b07e..89f7b5f 100644 --- a/internal/handlers/handleimage/resource.go +++ b/internal/handlers/restimage/resource.go @@ -1,9 +1,11 @@ -package handleimage +package restimage import ( "context" "io" "net/http" + + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" ) type Handler struct { @@ -20,5 +22,5 @@ func New(mux *http.ServeMux, srv imageService) *Handler { } type imageService interface { - GetImage(ctx context.Context, uid string) (img io.Reader, mimeType string, err error) + GetImage(ctx context.Context, uid string, options images.Options) (img io.Reader, mimeType string, err error) } diff --git a/internal/handlers/restutil/error.go b/internal/handlers/restutil/error.go new file mode 100644 index 0000000..d264e62 --- /dev/null +++ b/internal/handlers/restutil/error.go @@ -0,0 +1,68 @@ +package restutil + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" +) + +type ErrorType string + +const ( + ErrorTypeUndefinded = "" + ErrorTypeValidation = "VALIDATION" + ErrorTypeNotFound = "NOT_FOUND" + ErrorTypeServerError = "SERVER_ERROR" +) + +type Error struct { + Type ErrorType `json:"type"` + Message string `json:"message"` + err error +} + +func WrappErr(err error, errorType ErrorType, message string) *Error { + return &Error{ + Type: errorType, + Message: message, + err: err, + } +} + +func (e Error) Unwrap() error { + return e.err +} + +func (e Error) Error() string { + return fmt.Sprintf("%s: %s", e.Message, e.err.Error()) +} + +func (e Error) StatusCode() int { + switch e.Type { + case ErrorTypeValidation: + return http.StatusBadRequest + case ErrorTypeNotFound: + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} + +func SendErr(w http.ResponseWriter, err error) { + restErr := &Error{} + + if !errors.As(err, &restErr) { + slog.Error("internal server error", "err", err) + restErr = &Error{ + Type: ErrorTypeServerError, + } + } + + w.WriteHeader(restErr.StatusCode()) + w.Header().Add("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(restErr) + + slog.Error("could not send error", "err", err) +} diff --git a/internal/handlers/restutil/params.go b/internal/handlers/restutil/params.go new file mode 100644 index 0000000..1bbf153 --- /dev/null +++ b/internal/handlers/restutil/params.go @@ -0,0 +1,24 @@ +package restutil + +import ( + "fmt" + "net/http" + "strconv" +) + +func IntParam(r *http.Request, key string) (val int, err error) { + str := r.URL.Query().Get(key) + if str == "" { + return + } + + val, err = strconv.Atoi(str) + if err != nil { + err = WrappErr( + err, + ErrorTypeValidation, + fmt.Sprintf("failed to parse query parameter '%s': not an int", key), + ) + } + return +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 197f221..390f353 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -10,8 +10,8 @@ import ( "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem" - "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/handlehome" - "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/handleimage" + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/resthome" + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restimage" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web" "github.com/studio-b12/gowebdav" @@ -54,8 +54,8 @@ func registerOther() (h http.Handler) { fs = filesystem.NewCachedClient(webdavClient, valkeyClient, 5*time.Hour) } - _ = handlehome.New(mux, page.New(fs)) - _ = handleimage.New(mux, images.New(fs)) + _ = resthome.New(mux, page.New(fs)) + _ = restimage.New(mux, images.New(fs, valkeyClient)) mux.Handle("/", http.FileServer(http.FS(web.GetStaticFS()))) diff --git a/web/components/imagetile.templ b/web/components/imagetile.templ index 9a5d6e4..e9ac511 100644 --- a/web/components/imagetile.templ +++ b/web/components/imagetile.templ @@ -1,9 +1,13 @@ package components +import "strings" +import "fmt" + templ ImageTile(src string) {