package imgcache import ( "errors" "fmt" "os" "path/filepath" ) // 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") ) // FileStore is a directory of files, each named by a hex key. The file for // a key is at ///, where and are the key's // first and second pairs of characters. The cache keeps its source images in // one FileStore and its variants in another. type FileStore struct { baseDir string } // NewFileStore creates the directory baseDir if it is missing and returns // the FileStore of the files in it. func NewFileStore(baseDir string) (*FileStore, error) { err := os.MkdirAll(baseDir, StorageDirPerm) if err != nil { return nil, fmt.Errorf("failed to create storage directory: %w", err) } return &FileStore{baseDir: baseDir}, nil } // Write writes data with writeFileSynced to the file for key, replacing any // file already there. func (s *FileStore) Write(key string, data []byte) error { path := s.keyToPath(key) err := os.MkdirAll(filepath.Dir(path), StorageDirPerm) if err != nil { return fmt.Errorf("failed to create directory: %w", err) } return writeFileSynced(path, data) } // Open opens the file for key and returns it with its size in bytes. A // missing file is ErrNotFound. func (s *FileStore) Open(key string) (*os.File, int64, error) { f, err := os.Open(s.keyToPath(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 } // Size returns the size in bytes of the file for key. A missing file is // ErrNotFound. func (s *FileStore) Size(key string) (int64, error) { stat, err := os.Stat(s.keyToPath(key)) if err != nil { if os.IsNotExist(err) { return 0, ErrNotFound } return 0, fmt.Errorf("failed to stat content: %w", err) } return stat.Size(), nil } // Delete removes the file for key. A missing file is not an error. func (s *FileStore) Delete(key string) error { err := os.Remove(s.keyToPath(key)) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to delete content: %w", err) } return 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 *FileStore) keyToPath(key string) string { if len(key) < MinHashLength { return filepath.Join(s.baseDir, key) } return filepath.Join(s.baseDir, key[0:2], key[2:4], key) }