package imgcache import ( "encoding/json" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "time" "sneak.berlin/go/pixa/internal/imagerequest" ) // Storage constants. const ( // StorageDirPerm is the permission mode for storage directories. StorageDirPerm = 0750 // StorageFilePerm is the permission mode for storage files. StorageFilePerm = 0600 // MinHashLength is the minimum hash length for path splitting. MinHashLength = 4 ) // Storage errors. var ( ErrNotFound = errors.New("content not found") ) // ContentStorage handles source image file storage, one file per source // URL. Files are stored at: /// type ContentStorage struct { baseDir string } // NewContentStorage creates a new content storage at the given base directory. func NewContentStorage(baseDir string) (*ContentStorage, error) { err := os.MkdirAll(baseDir, StorageDirPerm) if err != nil { return nil, fmt.Errorf("failed to create storage directory: %w", err) } return &ContentStorage{baseDir: baseDir}, nil } // Store writes data with writeFileSynced to the path derived from key, // replacing any file already there, and returns its size. func (s *ContentStorage) Store( key imagerequest.SourceKey, data []byte, ) (int64, error) { path := s.keyToPath(key) err := os.MkdirAll(filepath.Dir(path), StorageDirPerm) if err != nil { return 0, fmt.Errorf("failed to create directory: %w", err) } err = writeFileSynced(path, data) if err != nil { return 0, err } return int64(len(data)), nil } // Load returns a reader for the content with the given key. func (s *ContentStorage) Load(key imagerequest.SourceKey) (io.ReadCloser, error) { path := s.keyToPath(key) f, err := os.Open(path) //nolint:gosec // path derived from source key if err != nil { if os.IsNotExist(err) { return nil, ErrNotFound } return nil, fmt.Errorf("failed to open content: %w", err) } return f, nil } // LoadWithSize returns a reader and file size for the content with the given key. func (s *ContentStorage) LoadWithSize( key imagerequest.SourceKey, ) (io.ReadCloser, int64, error) { path := s.keyToPath(key) f, err := os.Open(path) //nolint:gosec // path derived from source key if err != nil { if os.IsNotExist(err) { return nil, 0, ErrNotFound } return nil, 0, fmt.Errorf("failed to open content: %w", err) } stat, err := f.Stat() if err != nil { _ = f.Close() return nil, 0, fmt.Errorf("failed to stat content: %w", err) } return f, stat.Size(), nil } // Delete removes content with the given key. func (s *ContentStorage) Delete(key imagerequest.SourceKey) error { path := s.keyToPath(key) err := os.Remove(path) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to delete content: %w", err) } return nil } // Exists checks if content with the given key exists. func (s *ContentStorage) Exists(key imagerequest.SourceKey) bool { path := s.keyToPath(key) _, err := os.Stat(path) return err == nil } // writeFileSynced writes data to path through a temp file in the same // directory: the temp file is synced to disk, renamed to path, and the // directory synced after, so that neither a concurrent reader nor a // crash finds a partial file under path. When the directory sync fails, // the file is removed from path, so that a failed write leaves no file // there. func writeFileSynced(path string, data []byte) error { dir := filepath.Dir(path) tmpFile, err := os.CreateTemp(dir, ".tmp-*") if err != nil { return fmt.Errorf("failed to create temp file: %w", err) } tmpPath := tmpFile.Name() _, err = tmpFile.Write(data) if err != nil { _ = tmpFile.Close() _ = os.Remove(tmpPath) return fmt.Errorf("failed to write temp file: %w", err) } err = tmpFile.Sync() if err != nil { _ = tmpFile.Close() _ = os.Remove(tmpPath) return fmt.Errorf("failed to sync temp file: %w", err) } err = tmpFile.Close() if err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("failed to close temp file: %w", err) } err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path)) if err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("failed to rename temp file: %w", err) } err = syncDirectory(dir) if err != nil { _ = os.Remove(path) return err } return nil } // syncDirectory syncs the directory dir to disk, so that a file just // renamed into it is still there after a crash. func syncDirectory(dir string) error { d, err := os.Open(filepath.Clean(dir)) if err != nil { return fmt.Errorf("failed to open directory: %w", err) } err = d.Sync() _ = d.Close() if err != nil { return fmt.Errorf("failed to sync directory: %w", err) } return nil } // keyToPath converts a key to a file path: /// func (s *ContentStorage) keyToPath(key imagerequest.SourceKey) string { k := string(key) if len(k) < MinHashLength { return filepath.Clean(filepath.Join(s.baseDir, k)) } return filepath.Clean(filepath.Join(s.baseDir, k[0:2], k[2:4], k)) } // VariantStorage handles key-based file storage for processed image variants. // Files are stored at: /// // Metadata is stored at: ///.meta type VariantStorage struct { baseDir string log *slog.Logger } // VariantMeta contains metadata about a cached variant. // //nolint:tagliatelle // stored metadata format uses snake_case type VariantMeta struct { ContentType string `json:"content_type"` Size int64 `json:"size"` CreatedAt int64 `json:"created_at"` } // NewVariantStorage creates a new variant storage at the given base directory. // A failed .meta write is logged to log. func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error) { err := os.MkdirAll(baseDir, StorageDirPerm) if err != nil { return nil, fmt.Errorf("failed to create variant storage directory: %w", err) } return &VariantStorage{baseDir: baseDir, log: log}, nil } // Store writes content and metadata to storage at the given key, each // with writeFileSynced. The .meta file is written first, so that a crash // cannot leave the content in place without it. func (s *VariantStorage) Store( key imagerequest.VariantKey, r io.Reader, contentType string, ) (int64, error) { data, err := io.ReadAll(r) if err != nil { return 0, fmt.Errorf("failed to read content: %w", err) } size := int64(len(data)) path := s.keyToPath(key) metaPath := path + ".meta" err = os.MkdirAll(filepath.Dir(path), StorageDirPerm) if err != nil { return 0, fmt.Errorf("failed to create directory: %w", err) } meta := VariantMeta{ ContentType: contentType, Size: size, CreatedAt: time.Now().UTC().Unix(), } metaData, err := json.Marshal(meta) if err != nil { return 0, fmt.Errorf("failed to marshal metadata: %w", err) } // Metadata write failure is non-fatal: the content is then served as // application/octet-stream. err = writeFileSynced(metaPath, metaData) if err != nil { s.log.Warn("failed to write variant metadata sidecar", "path", metaPath, "error", err) } err = writeFileSynced(path, data) if err != nil { return 0, err } return size, nil } // Load returns a reader for the content at the given key. func (s *VariantStorage) Load(key imagerequest.VariantKey) (io.ReadCloser, error) { path := s.keyToPath(key) f, err := os.Open(path) //nolint:gosec // path derived from cache key if err != nil { if os.IsNotExist(err) { return nil, ErrNotFound } return nil, fmt.Errorf("failed to open content: %w", err) } return f, nil } // LoadWithSize returns a reader and file size for the content at the // given key. func (s *VariantStorage) LoadWithSize( key imagerequest.VariantKey, ) (io.ReadCloser, int64, error) { path := s.keyToPath(key) f, err := os.Open(path) //nolint:gosec // path derived from cache key if err != nil { if os.IsNotExist(err) { return nil, 0, ErrNotFound } return nil, 0, fmt.Errorf("failed to open content: %w", err) } stat, err := f.Stat() if err != nil { _ = f.Close() return nil, 0, fmt.Errorf("failed to stat content: %w", err) } return f, stat.Size(), nil } // LoadWithMeta returns a reader, size, and content type for the content at // the given key. The content type is read from the .meta file, and is // empty when that file is missing or unreadable. func (s *VariantStorage) LoadWithMeta( key imagerequest.VariantKey, ) (io.ReadCloser, int64, string, error) { f, size, err := s.LoadWithSize(key) if err != nil { return nil, 0, "", err } var contentType string metaPath := s.keyToPath(key) + ".meta" metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key if err == nil { var meta VariantMeta if json.Unmarshal(metaData, &meta) == nil && meta.ContentType != "" { contentType = meta.ContentType } } return f, size, contentType, nil } // Exists checks if content exists at the given key. func (s *VariantStorage) Exists(key imagerequest.VariantKey) bool { path := s.keyToPath(key) _, err := os.Stat(path) return err == nil } // Delete removes the content at the given key together with its .meta // sidecar file. A missing file is not an error. func (s *VariantStorage) Delete(key imagerequest.VariantKey) error { path := s.keyToPath(key) err := os.Remove(path) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to delete content: %w", err) } metaPath := path + ".meta" err = os.Remove(metaPath) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to delete variant metadata: %w", err) } return nil } // keyToPath converts a key to a file path: /// func (s *VariantStorage) keyToPath(key imagerequest.VariantKey) string { k := string(key) if len(k) < MinHashLength { return filepath.Join(s.baseDir, k) } return filepath.Join(s.baseDir, k[0:2], k[2:4], k) }