diff --git a/backend/go.mod b/backend/go.mod index c22750d..a3f7dc7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,6 +17,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -43,6 +44,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.39.0 // indirect + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index c9b9712..246d912 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -10,6 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= @@ -101,6 +103,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= @@ -108,6 +112,7 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/backend/internal/port/storage.go b/backend/internal/port/storage.go index d01629c..480e2b5 100644 --- a/backend/internal/port/storage.go +++ b/backend/internal/port/storage.go @@ -11,15 +11,15 @@ import ( // thumbnails, and previews. type FileStorage interface { // Save writes the reader's content to storage and returns the number of - // bytes written. ext is the file extension without a leading dot (e.g. "jpg"). - Save(ctx context.Context, id uuid.UUID, ext string, r io.Reader) (int64, error) + // bytes written. + Save(ctx context.Context, id uuid.UUID, r io.Reader) (int64, error) // Read opens the file content for reading. The caller must close the returned // ReadCloser. - Read(ctx context.Context, id uuid.UUID, ext string) (io.ReadCloser, error) + Read(ctx context.Context, id uuid.UUID) (io.ReadCloser, error) // Delete removes the file content from storage. - Delete(ctx context.Context, id uuid.UUID, ext string) error + Delete(ctx context.Context, id uuid.UUID) error // Thumbnail opens the pre-generated thumbnail (JPEG). Returns ErrNotFound // if the thumbnail has not been generated yet. diff --git a/backend/internal/storage/disk.go b/backend/internal/storage/disk.go new file mode 100644 index 0000000..a59023d --- /dev/null +++ b/backend/internal/storage/disk.go @@ -0,0 +1,223 @@ +// Package storage provides a local-filesystem implementation of port.FileStorage. +package storage + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "image/jpeg" + _ "image/gif" // register GIF decoder + _ "image/png" // register PNG decoder + "io" + "os" + "path/filepath" + + "github.com/disintegration/imaging" + "github.com/google/uuid" + + "tanabata/backend/internal/domain" + "tanabata/backend/internal/port" +) + +// DiskStorage implements port.FileStorage using the local filesystem. +// +// Directory layout: +// +// {filesPath}/{id} — original file (UUID basename, no extension) +// {thumbsPath}/{id}_thumb.jpg — thumbnail cache +// {thumbsPath}/{id}_preview.jpg — preview cache +type DiskStorage struct { + filesPath string + thumbsPath string + thumbWidth int + thumbHeight int + previewWidth int + previewHeight int +} + +var _ port.FileStorage = (*DiskStorage)(nil) + +// NewDiskStorage creates a DiskStorage and ensures both directories exist. +func NewDiskStorage( + filesPath, thumbsPath string, + thumbW, thumbH, prevW, prevH int, +) (*DiskStorage, error) { + for _, p := range []string{filesPath, thumbsPath} { + if err := os.MkdirAll(p, 0o755); err != nil { + return nil, fmt.Errorf("storage: create directory %q: %w", p, err) + } + } + return &DiskStorage{ + filesPath: filesPath, + thumbsPath: thumbsPath, + thumbWidth: thumbW, + thumbHeight: thumbH, + previewWidth: prevW, + previewHeight: prevH, + }, nil +} + +// --------------------------------------------------------------------------- +// port.FileStorage implementation +// --------------------------------------------------------------------------- + +// Save writes r to {filesPath}/{id} and returns the number of bytes written. +func (s *DiskStorage) Save(_ context.Context, id uuid.UUID, r io.Reader) (int64, error) { + dst := s.originalPath(id) + f, err := os.Create(dst) + if err != nil { + return 0, fmt.Errorf("storage.Save create %q: %w", dst, err) + } + n, copyErr := io.Copy(f, r) + closeErr := f.Close() + if copyErr != nil { + os.Remove(dst) + return 0, fmt.Errorf("storage.Save write: %w", copyErr) + } + if closeErr != nil { + os.Remove(dst) + return 0, fmt.Errorf("storage.Save close: %w", closeErr) + } + return n, nil +} + +// Read opens the original file for reading. The caller must close the result. +func (s *DiskStorage) Read(_ context.Context, id uuid.UUID) (io.ReadCloser, error) { + f, err := os.Open(s.originalPath(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("storage.Read: %w", err) + } + return f, nil +} + +// Delete removes the original file. Returns ErrNotFound if it does not exist. +func (s *DiskStorage) Delete(_ context.Context, id uuid.UUID) error { + if err := os.Remove(s.originalPath(id)); err != nil { + if os.IsNotExist(err) { + return domain.ErrNotFound + } + return fmt.Errorf("storage.Delete: %w", err) + } + return nil +} + +// Thumbnail returns a JPEG that fits within the configured max width×height +// (never upscaled, never cropped). Generated on first call and cached. +// Non-image source files receive a solid-colour placeholder. +func (s *DiskStorage) Thumbnail(_ context.Context, id uuid.UUID) (io.ReadCloser, error) { + return s.serveGenerated(id, s.thumbCachePath(id), s.thumbWidth, s.thumbHeight) +} + +// Preview returns a JPEG that fits within the configured max width×height +// (never upscaled, never cropped). Generated on first call and cached. +// Non-image source files receive a solid-colour placeholder. +func (s *DiskStorage) Preview(_ context.Context, id uuid.UUID) (io.ReadCloser, error) { + return s.serveGenerated(id, s.previewCachePath(id), s.previewWidth, s.previewHeight) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// serveGenerated is the shared implementation for Thumbnail and Preview. +// imaging.Thumbnail fits the source within maxW×maxH without upscaling or cropping. +func (s *DiskStorage) serveGenerated(id uuid.UUID, cachePath string, maxW, maxH int) (io.ReadCloser, error) { + // Fast path: cache hit. + if f, err := os.Open(cachePath); err == nil { + return f, nil + } + + // Verify the original file exists before doing any work. + srcPath := s.originalPath(id) + if _, err := os.Stat(srcPath); err != nil { + if os.IsNotExist(err) { + return nil, domain.ErrNotFound + } + return nil, fmt.Errorf("storage: stat %q: %w", srcPath, err) + } + + // Attempt to decode as an image. imaging.Open handles JPEG, PNG, and GIF + // (decoders registered via blank imports). Any non-decodable file (video, + // archive, …) silently produces a placeholder. + var img image.Image + if decoded, err := imaging.Open(srcPath, imaging.AutoOrientation(true)); err == nil { + img = imaging.Thumbnail(decoded, maxW, maxH, imaging.Lanczos) + } else { + img = placeholder(maxW, maxH) + } + + // Write to cache atomically (temp→rename) and return an open reader. + if rc, err := writeCache(cachePath, img); err == nil { + return rc, nil + } + + // Cache write failed (read-only fs, disk full, …). Serve from an + // in-memory buffer so the request still succeeds. + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil { + return nil, fmt.Errorf("storage: encode in-memory JPEG: %w", err) + } + return io.NopCloser(&buf), nil +} + +// writeCache encodes img as JPEG to cachePath via an atomic temp→rename write, +// then opens and returns the cache file. +func writeCache(cachePath string, img image.Image) (io.ReadCloser, error) { + dir := filepath.Dir(cachePath) + tmp, err := os.CreateTemp(dir, ".cache-*.tmp") + if err != nil { + return nil, fmt.Errorf("storage: create temp file: %w", err) + } + tmpName := tmp.Name() + + encErr := jpeg.Encode(tmp, img, &jpeg.Options{Quality: 85}) + closeErr := tmp.Close() + if encErr != nil { + os.Remove(tmpName) + return nil, fmt.Errorf("storage: encode cache JPEG: %w", encErr) + } + if closeErr != nil { + os.Remove(tmpName) + return nil, fmt.Errorf("storage: close temp file: %w", closeErr) + } + if err := os.Rename(tmpName, cachePath); err != nil { + os.Remove(tmpName) + return nil, fmt.Errorf("storage: rename cache file: %w", err) + } + f, err := os.Open(cachePath) + if err != nil { + return nil, fmt.Errorf("storage: open cache file: %w", err) + } + return f, nil +} + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +func (s *DiskStorage) originalPath(id uuid.UUID) string { + return filepath.Join(s.filesPath, id.String()) +} + +func (s *DiskStorage) thumbCachePath(id uuid.UUID) string { + return filepath.Join(s.thumbsPath, id.String()+"_thumb.jpg") +} + +func (s *DiskStorage) previewCachePath(id uuid.UUID) string { + return filepath.Join(s.thumbsPath, id.String()+"_preview.jpg") +} + +// --------------------------------------------------------------------------- +// Image helpers +// --------------------------------------------------------------------------- + +// placeholder returns a solid-colour image of size w×h for files that cannot +// be decoded as images. Uses #444455 from the design palette. +func placeholder(w, h int) *image.NRGBA { + return imaging.New(w, h, color.NRGBA{R: 0x44, G: 0x44, B: 0x55, A: 0xFF}) +}