Files

205 lines
6.1 KiB
Go

package bootstrap
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources"
)
const (
applicationImagesPath = "application-images"
deletedApplicationImagesPath = applicationImagesPath + "/.deleted"
legacyApplicationImagesInitedPath = applicationImagesPath + "/.inited"
deletableBundledApplicationImage = "background"
)
// initApplicationImages copies embedded images to storage and returns the detected file extensions
//
//nolint:gocognit
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"logoLight.svg": {
mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"),
},
"logoDark.svg": {
mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"),
},
"background.jpg": {
mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
},
"background.webp": {
mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
},
}
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
destinationFiles, err := fileStorage.List(ctx, applicationImagesPath)
if err != nil {
if storage.IsNotExist(err) {
destinationFiles = []storage.ObjectInfo{}
} else {
return nil, fmt.Errorf("failed to list application images: %w", err)
}
}
dstNameToExt := make(map[string]string, len(destinationFiles))
listedImageNames := make(map[string]struct{}, len(destinationFiles))
for _, f := range destinationFiles {
// Skip bootstrap state that recursive storage backends may include in the listing
if f.Path == legacyApplicationImagesInitedPath || strings.HasPrefix(f.Path, deletedApplicationImagesPath+"/") {
continue
}
// Skip directory entries returned by storage backends
_, name := path.Split(f.Path)
if name == "" {
continue
}
nameWithoutExt, ext := utils.SplitFileName(name)
listedImageNames[nameWithoutExt] = struct{}{}
reader, _, err := fileStorage.Open(ctx, f.Path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
slog.Warn("Failed to open application image for hashing", slog.String("name", name), slog.Any("error", err))
continue
}
hash, err := hashStream(reader)
reader.Close()
if err != nil {
slog.Warn("Failed to hash application image", slog.String("name", name), slog.Any("error", err))
continue
}
// Remove bundled legacy images so their current versions can be restored
if legacyImageHashes.Matches(name, hash) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
if err := fileStorage.Delete(ctx, f.Path); err != nil {
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
}
continue
}
dstNameToExt[nameWithoutExt] = ext
}
// Preserve an intentionally deleted background when replacing the legacy global initialization marker
legacyInited, err := storageObjectExists(ctx, fileStorage, legacyApplicationImagesInitedPath)
if err != nil {
return nil, fmt.Errorf("failed to read legacy application images marker: %w", err)
}
if legacyInited {
_, backgroundWasPresent := listedImageNames[deletableBundledApplicationImage]
if !backgroundWasPresent {
deletedPath := deletedApplicationImagePath(deletableBundledApplicationImage)
if err := fileStorage.Save(ctx, deletedPath, strings.NewReader("")); err != nil {
return nil, fmt.Errorf("failed to store deleted application image marker '%s': %w", deletableBundledApplicationImage, err)
}
}
if err := fileStorage.Delete(ctx, legacyApplicationImagesInitedPath); err != nil {
return nil, fmt.Errorf("failed to remove legacy application images marker: %w", err)
}
}
// Copy missing bundled images unless an administrator intentionally deleted them
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() {
continue
}
name := sourceFile.Name()
nameWithoutExt, ext := utils.SplitFileName(name)
srcFilePath := path.Join("images", name)
if _, exists := dstNameToExt[nameWithoutExt]; exists {
continue
}
deleted, err := storageObjectExists(ctx, fileStorage, deletedApplicationImagePath(nameWithoutExt))
if err != nil {
return nil, fmt.Errorf("failed to read deleted application image marker '%s': %w", nameWithoutExt, err)
}
if deleted {
continue
}
slog.Info("Writing new application image", slog.String("name", name))
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open embedded file '%s': %w", name, err)
}
if err := fileStorage.Save(ctx, path.Join(applicationImagesPath, name), srcFile); err != nil {
srcFile.Close()
return nil, fmt.Errorf("failed to store application image '%s': %w", name, err)
}
srcFile.Close()
dstNameToExt[nameWithoutExt] = ext
}
return dstNameToExt, nil
}
type imageHashMap map[string][][]byte
func (m imageHashMap) Matches(name string, target []byte) bool {
if len(target) == 0 {
return false
}
for _, hash := range m[name] {
if bytes.Equal(hash, target) {
return true
}
}
return false
}
func deletedApplicationImagePath(name string) string {
return path.Join(deletedApplicationImagesPath, name)
}
func storageObjectExists(ctx context.Context, fileStorage storage.FileStorage, objectPath string) (bool, error) {
reader, _, err := fileStorage.Open(ctx, objectPath)
if err == nil {
reader.Close()
return true, nil
}
if storage.IsNotExist(err) {
return false, nil
}
return false, err
}
func mustDecodeHex(str string) []byte {
b, err := hex.DecodeString(str)
if err != nil {
panic(err)
}
return b
}
func hashStream(r io.Reader) ([]byte, error) {
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return nil, err
}
return h.Sum(nil), nil
}