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/tmp/build-errors.log b/tmp/build-errors.log index 1fa1b86..fbbb0c6 100644 --- a/tmp/build-errors.log +++ b/tmp/build-errors.log @@ -1 +1 @@ -exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main index 1b46103..d713bf3 100755 Binary files a/tmp/main and b/tmp/main differ 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) {