refac: decouple persistence and image storage behind driver registries

This commit is contained in:
fsylvain committed 2026-10-06 02:13:35 +02:00
1 parent 421cfafec3
commit 81e299f09a
46 files changed
+1036 -374

No files matched your search

+30 -45
View File
@@ -13,13 +13,13 @@ import (
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/persistence/models"
"RenewCMS/internal/infrastructure/persistence/database"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/scheduler"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"crypto/ed25519"
"fmt"
"log"
"net/http"
"os"
@@ -28,9 +28,7 @@ import (
"sync"
"time"
"github.com/glebarez/sqlite"
"github.com/go-chi/jwtauth/v5"
"gorm.io/gorm"
)
type UseCases struct {
@@ -60,6 +58,7 @@ type UseCases struct {
AcceptInviteUseCase *useCases.AcceptInviteUseCase
SendMailUseCase *useCases.SendMailUseCase
CreateImageUseCase *useCases.CreateImageUseCase
OpenMediaUseCase *useCases.OpenMediaUseCase
ListMediaUseCase *useCases.ListMediaUseCase
RenameMediaUseCase *useCases.RenameMediaUseCase
DeleteMediaUseCase *useCases.DeleteMediaUseCase
@@ -103,7 +102,6 @@ type Repositories struct {
}
const (
sqliteBusyTimeoutMs = 5000
updateStatusFileName = "update-status.json"
updateJobFileName = "update-job.json"
updateHelperFileName = "update-helper.json"
@@ -111,32 +109,23 @@ const (
backupsDirName = "backups"
)
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
func openImageStorage(settings config.Settings) domainImage.Storage {
imageStorage, err := storage.Open(storage.Config{Driver: storage.DriverLocal, Location: settings.UploadDir})
if err != nil {
panic("Unable to open the image storage: " + err.Error())
}
return imageStorage
}
func getDb(dbName string, uploadDir string) *gorm.DB {
if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil {
panic("Unable to create necessary subdirectories: " + err.Error())
}
db, err := gorm.Open(sqlite.Open(SQLiteDSN(dbName)), &gorm.Config{})
func openDatabase(settings config.Settings) *database.Connection {
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: settings.DBFile})
if err != nil {
panic("Unable to open the database: " + err.Error())
}
if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}); err != nil {
if err := database.Migrate(connection.DB); err != nil {
panic("Failed to migrate database: " + err.Error())
}
if err := persistence.BackfillUserRoles(db); err != nil {
panic("Failed to backfill user roles: " + err.Error())
}
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return db
return connection
}
func releasePublicKey() ed25519.PublicKey {
@@ -147,12 +136,13 @@ func releasePublicKey() ed25519.PublicKey {
return key
}
func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
dataDir := filepath.Dir(settings.DBFile)
func getRepositories(connection *database.Connection, settings config.Settings) *Repositories {
db := connection.DB
dataDir := settings.DataDir()
return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db),
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
ImageRepo: persistence.NewImageRepository(db),
InviteRepo: persistence.NewInviteRepository(db),
APIKeyRepo: persistence.NewAPIKeyRepository(db),
PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db),
@@ -164,18 +154,18 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
StagedRunner: update.NewExecStagedBinaryRunner(),
DatabaseBackup: update.NewSQLiteDatabaseBackup(db, filepath.Join(dataDir, backupsDirName), time.Now),
DatabaseBackup: connection.NewBackup(filepath.Join(dataDir, backupsDirName), time.Now),
UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)),
RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)),
RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)),
}
}
func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases {
hasher := hashing.NewBcryptHasher()
renderer := markdown.NewConverter()
keyGenerator := hashing.NewSHA256KeyGenerator()
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo)
mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage)
runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default())
purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge)
@@ -204,10 +194,11 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher),
SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage),
ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage),
MergeMediaUseCase: mergeMedia,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo),
@@ -238,7 +229,7 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *
Verifier: repos.SignatureVerifier,
Runner: repos.StagedRunner,
Backup: repos.DatabaseBackup,
Staging: update.NewOSStagingArea(filepath.Join(filepath.Dir(settings.DBFile), updatesDirName)),
Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)),
Logger: log.Default(),
CurrentVersion: settings.Version,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
@@ -248,19 +239,13 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *
}
func InitContainer(settings config.Settings) (*UseCases, func() error) {
db := getDb(settings.DBFile, settings.UploadDir)
repos := getRepositories(db, settings)
return getUseCases(repos, settings), closeDb(db)
}
func closeDb(db *gorm.DB) func() error {
return func() error {
sqlDb, err := db.DB()
if err != nil {
return err
}
return sqlDb.Close()
imageStorage := openImageStorage(settings)
connection := openDatabase(settings)
repos := getRepositories(connection, settings)
if err := useCases.NewBackfillImageMetadataUseCase(repos.ImageRepo, imageStorage).Run(); err != nil {
panic("Failed to backfill image metadata: " + err.Error())
}
return getUseCases(repos, imageStorage, settings), connection.Close
}
type BrowserUpdateCommands struct {
@@ -1,11 +1,13 @@
package staticimages
import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"io"
"io/fs"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
@@ -24,13 +26,13 @@ var contentTypeExtensions = map[string]string{
}
type Handler struct {
uploadDir fs.FS
openMedia *useCases.OpenMediaUseCase
modTime time.Time
}
func NewHandler(uploadDir string) *Handler {
func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler {
return &Handler{
uploadDir: os.DirFS(uploadDir),
openMedia: openMedia,
modTime: time.Now(),
}
}
@@ -39,18 +41,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
cleaned := cleanPath(requestedPath(r))
file, stat, ok := h.openFile(cleaned)
if !ok {
blob, err := h.openMedia.Open(cleaned)
if errors.Is(err, image.ErrBlobNotFound) {
http.NotFound(w, r)
return
}
defer file.Close()
content, ok := file.(io.ReadSeeker)
if !ok {
http.Error(w, "File not seekable", http.StatusInternalServerError)
if err != nil {
http.Error(w, "Unable to read the file", http.StatusInternalServerError)
return
}
defer blob.Content.Close()
contentType := contentTypeFor(cleaned)
w.Header().Set("Content-Type", contentType)
@@ -59,7 +59,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy)
}
http.ServeContent(w, r, cleaned, stat.ModTime(), content)
if content, ok := blob.Content.(io.ReadSeeker); ok {
http.ServeContent(w, r, cleaned, blob.ModTime, content)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10))
if !blob.ModTime.IsZero() {
w.Header().Set("Last-Modified", blob.ModTime.UTC().Format(http.TimeFormat))
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = io.Copy(w, blob.Content)
}
}
func requestedPath(r *http.Request) string {
@@ -73,22 +84,6 @@ func cleanPath(p string) string {
return strings.TrimPrefix(path.Clean("/"+p), "/")
}
func (h *Handler) openFile(p string) (fs.File, fs.FileInfo, bool) {
if p == "" || p == "." {
return nil, nil, false
}
file, err := h.uploadDir.Open(p)
if err != nil {
return nil, nil, false
}
stat, err := file.Stat()
if err != nil || stat.IsDir() {
file.Close()
return nil, nil, false
}
return file, stat, true
}
func contentTypeFor(p string) string {
ext := path.Ext(p)
if contentType, ok := contentTypeExtensions[ext]; ok {
+1 -1
View File
@@ -292,7 +292,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux {
apiRouter.Use(chimiddleware.GetHead)
apiRouter.Get("/healthz", GetHealth)
apiRouter.Mount("/v1", backend)
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(settings.UploadDir))
apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase))
apiRouter.Mount("/", frontend)
return apiRouter
+12 -2
View File
@@ -15,8 +15,10 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain
| Domain | `internal/domain/<name>/` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. |
| Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. |
| Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. |
| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. |
| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. |
| Delivery | `api/handlers/<name>/`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. |
| Composition root | `api/dependecyInjection.go` | The only place that opens the DB, builds repositories and use cases, and lists models for `AutoMigrate`. |
| Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. |
| Entry point | `cmd/server/main.go` | Env/config, HTTP server, graceful shutdown. |
Nothing under `internal/` imports `api/`.
@@ -55,7 +57,7 @@ The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `Run
### Image metadata backfill
At boot, `BackfillImageMetadata` fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the files in `UPLOAD_DIR`. It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
## Frontend ↔ backend
@@ -73,3 +75,11 @@ bin/renewcms ── /v1/* API
- **Production:** a single origin. The SPA calls the API with relative URLs, so the JWT cookie works without CORS. Files under `assets/` are content-hashed by Vite and cached for a year. `index.html` is never cached.
- **Development:** built with `-tags nofrontend` (`assets_stub.go`, `assets.Dist == nil`). Vite serves the SPA on :5173 and proxies `/v1` to the API on :8080. See [development.md](development.md).
## Storage and database seams
Uploaded images are addressed by a stored name (`<uuid>.<ext>`). `CreateImageUseCase` calls `Storage.Put` (which generates the name), then `Repository.Create` with `Path = /static/uploadedImages/<name>`, and deletes the blob again if the row cannot be created. `DeleteMediaUseCase` and `MergeMediaUseCase` remove the blob first and the row second (`mediaRemover`), so a failed blob delete keeps the row and the delete can be retried. `GET /static/uploadedImages/<name>` is served by `OpenMediaUseCase` through `api/handlers/staticimages`, which uses `http.ServeContent` when the blob is seekable and streams it otherwise; `ErrBlobNotFound` maps to 404 and any other error to 500.
SQL in repositories must run on SQLite, MySQL/MariaDB and PostgreSQL: substring searches use `LIKE ... ESCAPE '!'` through `containsPattern` instead of `instr`. `Settings.DataDir()` is where update state, staging and backups live; it is derived from `DB_FILE` today and does not depend on where the database is.
Adding a database driver: implement `database.Driver` (`Open`, and `NewBackup` returning a `domain/update.DatabaseBackup`), register it in `drivers`, then select it in `openDatabase` in the composition root (and add the env vars to `main.go`, `.env.example` and `configuration.md`). Adding a storage backend: implement `image.Storage`, add a case to `storage.Open`, and select it in `openImageStorage`. No use case, handler or repository changes.
+9
View File
@@ -191,6 +191,15 @@ type ImageInput struct {
UploadedByID uint32
}
type Metadata struct {
Filename string
Size int64
ContentType string
Width int
Height int
SHA256 string
}
type Image struct {
ID uint32 `json:"id"`
Path string `json:"path"`
@@ -6,7 +6,7 @@ type SearchQuery struct {
}
type Repository interface {
Create(input ImageInput) (Image, error)
Create(image Image) (Image, error)
Get(id uint32) (Image, error)
Delete(id uint32) error
List(query SearchQuery) ([]Image, error)
@@ -14,4 +14,6 @@ type Repository interface {
Rename(id uint32, filename string) (Image, error)
ListDuplicates() ([]Image, error)
Counts() (Counts, error)
ListMissingMetadata() ([]Image, error)
UpdateMetadata(id uint32, metadata Metadata) error
}
+27
View File
@@ -0,0 +1,27 @@
package image
import (
"errors"
"io"
"time"
)
var ErrBlobNotFound = errors.New("stored image content not found")
const PublicPathPrefix = "/static/uploadedImages/"
func PublicPath(storedName string) string {
return PublicPathPrefix + storedName
}
type Blob struct {
Content io.ReadCloser
Size int64
ModTime time.Time
}
type Storage interface {
Put(contentType string, content io.Reader) (string, error)
Open(storedName string) (Blob, error)
Delete(storedName string) error
}
@@ -2,6 +2,7 @@ package config
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -36,6 +37,10 @@ type Settings struct {
Container bool
}
func (s Settings) DataDir() string {
return filepath.Dir(s.DBFile)
}
func (s Settings) IsProduction() bool {
return s.Environment == "production"
}
@@ -0,0 +1,52 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"time"
"gorm.io/gorm"
)
type Config struct {
Driver string
Source string
}
type Driver interface {
Open(source string) (*gorm.DB, error)
NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup
}
var drivers = map[string]Driver{
DriverSQLite: sqliteDriver{},
}
type Connection struct {
DB *gorm.DB
driver Driver
}
func Connect(config Config) (*Connection, error) {
driver, ok := drivers[config.Driver]
if !ok {
return nil, fmt.Errorf("unsupported database driver %q", config.Driver)
}
db, err := driver.Open(config.Source)
if err != nil {
return nil, err
}
return &Connection{DB: db, driver: driver}, nil
}
func (c *Connection) NewBackup(directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return c.driver.NewBackup(c.DB, directory, now)
}
func (c *Connection) Close() error {
sqlDb, err := c.DB.DB()
if err != nil {
return err
}
return sqlDb.Close()
}
@@ -0,0 +1,15 @@
package database
import (
"RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func Migrate(db *gorm.DB) error {
err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{})
if err != nil {
return err
}
return BackfillUserRoles(db)
}
@@ -0,0 +1,33 @@
package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"fmt"
"os"
"path/filepath"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
const DriverSQLite = "sqlite"
const sqliteBusyTimeoutMs = 5000
type sqliteDriver struct{}
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
}
func (sqliteDriver) Open(file string) (*gorm.DB, error) {
if err := os.MkdirAll(filepath.Dir(file), os.ModePerm); err != nil {
return nil, fmt.Errorf("unable to create necessary subdirectories: %w", err)
}
return gorm.Open(sqlite.Open(SQLiteDSN(file)), &gorm.Config{})
}
func (sqliteDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewSQLiteBackup(db, directory, now)
}
@@ -1,4 +1,4 @@
package update
package database
import (
domain "RenewCMS/internal/domain/update"
@@ -21,19 +21,19 @@ const (
backupTimeLayout = "20060102T150405.000000000Z"
)
type SQLiteDatabaseBackup struct {
type SQLiteBackup struct {
db *gorm.DB
directory string
now func() time.Time
}
var _ domain.DatabaseBackup = &SQLiteDatabaseBackup{}
var _ domain.DatabaseBackup = &SQLiteBackup{}
func NewSQLiteDatabaseBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteDatabaseBackup {
return &SQLiteDatabaseBackup{db: db, directory: directory, now: now}
func NewSQLiteBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteBackup {
return &SQLiteBackup{db: db, directory: directory, now: now}
}
func (b *SQLiteDatabaseBackup) Backup() (string, error) {
func (b *SQLiteBackup) Backup() (string, error) {
if err := os.MkdirAll(b.directory, os.ModePerm); err != nil {
return "", err
}
@@ -56,7 +56,7 @@ type backupFile struct {
modified time.Time
}
func (b *SQLiteDatabaseBackup) prune() error {
func (b *SQLiteBackup) prune() error {
entries, err := os.ReadDir(b.directory)
if err != nil {
return err
@@ -1,4 +1,4 @@
package persistence
package database
import (
domainUser "RenewCMS/internal/domain/user"
@@ -126,7 +126,7 @@ func (a *ArticleRepository) Delete(id uint32) error {
func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
var entityArticles []entity.Article
err := a.db.Model(&entity.Article{}).Where("instr(body, ?) > 0", filename).Order("id asc").Find(&entityArticles).Error
err := a.db.Model(&entity.Article{}).Where("body LIKE ? ESCAPE '!'", containsPattern(filename)).Order("id asc").Find(&entityArticles).Error
if err != nil {
return nil, err
}
@@ -1,77 +0,0 @@
package persistence
import (
entity "RenewCMS/internal/infrastructure/persistence/models"
"bytes"
"crypto/sha256"
"encoding/hex"
stdimage "image"
"os"
"path/filepath"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
"gorm.io/gorm"
)
var extensionContentTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
func BackfillImageMetadata(db *gorm.DB, uploadDir string) error {
var pending []entity.Image
err := db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&pending).Error
if err != nil {
return err
}
for _, row := range pending {
metadata, ok := readStoredImage(uploadDir, row.Path)
if !ok {
continue
}
err := db.Model(&entity.Image{}).Where("id = ?", row.ID).UpdateColumns(metadata).Error
if err != nil {
return err
}
}
return nil
}
func readStoredImage(uploadDir string, storedPath string) (map[string]any, bool) {
fileName := filepath.Base(storedPath)
contentType, known := extensionContentTypes[filepath.Ext(fileName)]
if !known {
return nil, false
}
data, err := os.ReadFile(filepath.Join(uploadDir, fileName))
if err != nil {
return nil, false
}
width, height := 0, 0
if contentType != "image/svg+xml" {
config, _, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, false
}
width, height = config.Width, config.Height
}
checksum := sha256.Sum256(data)
return map[string]any{
"filename": fileName,
"size": int64(len(data)),
"content_type": contentType,
"width": width,
"height": height,
"sha256": hex.EncodeToString(checksum[:]),
}, true
}
@@ -4,67 +4,24 @@ import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
"io"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
)
const uploadedImagesURLPrefix = "/static/uploadedImages/"
type ImageRepository struct {
db *gorm.DB
uploadDir string
db *gorm.DB
}
func NewImageRepository(db *gorm.DB, uploadDir string) *ImageRepository {
return &ImageRepository{db, uploadDir}
func NewImageRepository(db *gorm.DB) *ImageRepository {
return &ImageRepository{db}
}
var contentTypeExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) {
fileBytes, err := io.ReadAll(input.Content)
if err != nil {
return domain.Image{}, err
}
err = os.MkdirAll(i.uploadDir, 0755)
if err != nil {
return domain.Image{}, err
}
newName := uuid.NewString() + contentTypeExtensions[input.ContentType]
finalPath := filepath.Join(i.uploadDir, newName)
err = os.WriteFile(finalPath, fileBytes, 0666)
if err != nil {
return domain.Image{}, err
}
model := mappers.ImageToModel(domain.Image{
Path: uploadedImagesURLPrefix + newName,
Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
})
func (i ImageRepository) Create(image domain.Image) (domain.Image, error) {
model := mappers.ImageToModel(image)
if err := i.db.Create(&model).Error; err != nil {
_ = os.Remove(finalPath)
return domain.Image{}, err
}
return mappers.ImageToDomain(model), nil
}
@@ -78,24 +35,13 @@ func (i ImageRepository) Get(id uint32) (domain.Image, error) {
}
func (i ImageRepository) Delete(id uint32) error {
var model entity.Image
err := i.db.First(&model, id).Error
if err != nil {
return mapNotFoundErr(err, domain.ErrImageNotFound)
}
err = os.Remove(filepath.Join(i.uploadDir, filepath.Base(model.Path)))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return i.db.Delete(&entity.Image{}, id).Error
return deleteOrNotFound(i.db.Delete(&entity.Image{}, id), domain.ErrImageNotFound)
}
func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) {
statement := i.db.Model(&entity.Image{})
if query.Search != "" {
statement = statement.Where("instr(lower(filename), ?) > 0", strings.ToLower(query.Search))
statement = statement.Where("LOWER(filename) LIKE ? ESCAPE '!'", containsPattern(strings.ToLower(query.Search)))
}
if query.Hash != "" {
statement = statement.Where("sha256 = ?", query.Hash)
@@ -171,4 +117,24 @@ func imagesToDomain(models []entity.Image) []domain.Image {
return images
}
func (i ImageRepository) ListMissingMetadata() ([]domain.Image, error) {
var models []entity.Image
err := i.db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&models).Error
if err != nil {
return nil, err
}
return imagesToDomain(models), nil
}
func (i ImageRepository) UpdateMetadata(id uint32, metadata domain.Metadata) error {
return i.db.Model(&entity.Image{}).Where("id = ?", id).UpdateColumns(map[string]any{
"filename": metadata.Filename,
"size": metadata.Size,
"content_type": metadata.ContentType,
"width": metadata.Width,
"height": metadata.Height,
"sha256": metadata.SHA256,
}).Error
}
var _ domain.Repository = &ImageRepository{}
@@ -2,6 +2,7 @@ package persistence
import (
"errors"
"strings"
"gorm.io/gorm"
)
@@ -22,3 +23,8 @@ func deleteOrNotFound(result *gorm.DB, sentinel error) error {
}
return nil
}
func containsPattern(fragment string) string {
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(fragment)
return "%" + escaped + "%"
}
@@ -0,0 +1,74 @@
package storage
import (
domain "RenewCMS/internal/domain/image"
"errors"
"io"
"os"
"path/filepath"
"github.com/google/uuid"
)
var contentTypeExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
type LocalStorage struct {
directory string
}
var _ domain.Storage = &LocalStorage{}
func NewLocalStorage(directory string) *LocalStorage {
return &LocalStorage{directory: directory}
}
func (s *LocalStorage) Put(contentType string, content io.Reader) (string, error) {
data, err := io.ReadAll(content)
if err != nil {
return "", err
}
if err := os.MkdirAll(s.directory, 0755); err != nil {
return "", err
}
storedName := uuid.NewString() + contentTypeExtensions[contentType]
if err := os.WriteFile(filepath.Join(s.directory, storedName), data, 0666); err != nil {
return "", err
}
return storedName, nil
}
func (s *LocalStorage) Open(storedName string) (domain.Blob, error) {
if !isPlainName(storedName) {
return domain.Blob{}, domain.ErrBlobNotFound
}
file, err := os.Open(filepath.Join(s.directory, storedName))
if err != nil {
return domain.Blob{}, domain.ErrBlobNotFound
}
info, err := file.Stat()
if err != nil || info.IsDir() {
_ = file.Close()
return domain.Blob{}, domain.ErrBlobNotFound
}
return domain.Blob{Content: file, Size: info.Size(), ModTime: info.ModTime()}, nil
}
func (s *LocalStorage) Delete(storedName string) error {
if !isPlainName(storedName) {
return nil
}
err := os.Remove(filepath.Join(s.directory, storedName))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
func isPlainName(name string) bool {
return name != "" && name != "." && name != ".." && filepath.Base(name) == name && filepath.ToSlash(name) == name
}
@@ -0,0 +1,22 @@
package storage
import (
domain "RenewCMS/internal/domain/image"
"fmt"
)
const DriverLocal = "local"
type Config struct {
Driver string
Location string
}
func Open(config Config) (domain.Storage, error) {
switch config.Driver {
case DriverLocal:
return NewLocalStorage(config.Location), nil
default:
return nil, fmt.Errorf("unsupported storage driver %q", config.Driver)
}
}
@@ -0,0 +1,90 @@
package useCases
import (
"RenewCMS/internal/domain/image"
"bytes"
"crypto/sha256"
"encoding/hex"
stdimage "image"
"io"
"path"
_ "golang.org/x/image/webp"
_ "image/jpeg"
_ "image/png"
)
var extensionContentTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".svg": "image/svg+xml",
}
type BackfillImageMetadataUseCase struct {
imageRepository image.Repository
storage image.Storage
}
func NewBackfillImageMetadataUseCase(imageRepository image.Repository, storage image.Storage) *BackfillImageMetadataUseCase {
return &BackfillImageMetadataUseCase{imageRepository, storage}
}
func (u *BackfillImageMetadataUseCase) Run() error {
pending, err := u.imageRepository.ListMissingMetadata()
if err != nil {
return err
}
for _, row := range pending {
metadata, ok := u.readStoredImage(row.Path)
if !ok {
continue
}
if err := u.imageRepository.UpdateMetadata(row.ID, metadata); err != nil {
return err
}
}
return nil
}
func (u *BackfillImageMetadataUseCase) readStoredImage(storedPath string) (image.Metadata, bool) {
fileName := image.StoredName(storedPath)
contentType, known := extensionContentTypes[path.Ext(fileName)]
if !known {
return image.Metadata{}, false
}
data, err := u.read(fileName)
if err != nil {
return image.Metadata{}, false
}
width, height := 0, 0
if contentType != "image/svg+xml" {
config, _, err := stdimage.DecodeConfig(bytes.NewReader(data))
if err != nil {
return image.Metadata{}, false
}
width, height = config.Width, config.Height
}
checksum := sha256.Sum256(data)
return image.Metadata{
Filename: fileName,
Size: int64(len(data)),
ContentType: contentType,
Width: width,
Height: height,
SHA256: hex.EncodeToString(checksum[:]),
}, true
}
func (u *BackfillImageMetadataUseCase) read(storedName string) ([]byte, error) {
blob, err := u.storage.Open(storedName)
if err != nil {
return nil, err
}
defer blob.Content.Close()
return io.ReadAll(blob.Content)
}
@@ -24,10 +24,11 @@ var rasterFormats = map[string]string{
type CreateImageUseCase struct {
imageRepository image.Repository
storage image.Storage
}
func NewCreateImageUseCase(imageRepository image.Repository) *CreateImageUseCase {
return &CreateImageUseCase{imageRepository}
func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage) *CreateImageUseCase {
return &CreateImageUseCase{imageRepository, storage}
}
func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, error) {
@@ -46,8 +47,13 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e
}
checksum := sha256.Sum256(data)
return g.imageRepository.Create(image.ImageInput{
Content: bytes.NewReader(data),
storedName, err := g.storage.Put(input.ContentType, bytes.NewReader(data))
if err != nil {
return image.Image{}, err
}
created, err := g.imageRepository.Create(image.Image{
Path: image.PublicPath(storedName),
Filename: image.NormalizeUploadFilename(input.Filename, input.ContentType),
Size: int64(len(data)),
ContentType: input.ContentType,
@@ -56,6 +62,11 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e
SHA256: hex.EncodeToString(checksum[:]),
UploadedByID: input.UploadedByID,
})
if err != nil {
_ = g.storage.Delete(storedName)
return image.Image{}, err
}
return created, nil
}
func readWithinLimit(content io.Reader) ([]byte, error) {
@@ -4,12 +4,17 @@ import "RenewCMS/internal/domain/image"
type DeleteMediaUseCase struct {
imageRepository image.Repository
remover mediaRemover
}
func NewDeleteMediaUseCase(imageRepository image.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}}
}
func (u *DeleteMediaUseCase) Delete(id uint32) error {
return u.imageRepository.Delete(id)
img, err := u.imageRepository.Get(id)
if err != nil {
return err
}
return u.remover.remove(img)
}
@@ -18,10 +18,11 @@ type MergeMediaResult struct {
type MergeMediaUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
remover mediaRemover
}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository}
func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage) *MergeMediaUseCase {
return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}}
}
type bodyRewrite struct {
@@ -53,7 +54,7 @@ func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaR
}
for _, duplicate := range copies {
if err := u.imageRepository.Delete(duplicate.ID); err != nil {
if err := u.remover.remove(duplicate); err != nil {
return MergeMediaResult{}, err
}
}
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/image"
type OpenMediaUseCase struct {
storage image.Storage
}
func NewOpenMediaUseCase(storage image.Storage) *OpenMediaUseCase {
return &OpenMediaUseCase{storage}
}
func (u *OpenMediaUseCase) Open(storedName string) (image.Blob, error) {
return u.storage.Open(storedName)
}
@@ -0,0 +1,15 @@
package useCases
import "RenewCMS/internal/domain/image"
type mediaRemover struct {
imageRepository image.Repository
storage image.Storage
}
func (r mediaRemover) remove(img image.Image) error {
if err := r.storage.Delete(image.StoredName(img.Path)); err != nil {
return err
}
return r.imageRepository.Delete(img.ID)
}
+3 -3
View File
@@ -111,11 +111,11 @@ func newAPIKeyEnv(t *testing.T) *apiKeyEnv {
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage()),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
+7 -17
View File
@@ -2,20 +2,15 @@ package test
import (
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/storage"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestImageRepositoryHasAnExtensionForEveryAllowedContentType(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
repo := persistence.NewImageRepository(db, t.TempDir())
func TestLocalStorageHasAnExtensionForEveryAllowedContentType(t *testing.T) {
store := storage.NewLocalStorage(t.TempDir())
allowedContentTypes := imageDomain.AllowedContentTypes()
expectedContentTypes := []string{"image/jpeg", "image/png", "image/svg+xml", "image/webp"}
@@ -25,17 +20,12 @@ func TestImageRepositoryHasAnExtensionForEveryAllowedContentType(t *testing.T) {
for _, contentType := range allowedContentTypes {
t.Run(contentType, func(t *testing.T) {
result, err := repo.Create(imageDomain.ImageInput{
Content: strings.NewReader("fake-image-data"),
Filename: "upload",
ContentType: contentType,
})
storedName, err := store.Put(contentType, strings.NewReader("fake-image-data"))
if err != nil {
t.Fatalf("Create failed: %v", err)
t.Fatalf("Put failed: %v", err)
}
extension := filepath.Ext(result.Path)
if extension == "" {
t.Errorf("expected a non-empty extension for %q, got path %q", contentType, result.Path)
if filepath.Ext(storedName) == "" {
t.Errorf("expected a non-empty extension for %q, got name %q", contentType, storedName)
}
})
}
+82 -19
View File
@@ -21,6 +21,55 @@ var mockImageExtensions = map[string]string{
"image/svg+xml": ".svg",
}
type mockImageStorage struct {
blobs map[string][]byte
putCalls int
nextName int
lastContent []byte
deleted []string
failPut error
failDelete error
}
func newMockImageStorage() *mockImageStorage {
return &mockImageStorage{blobs: map[string][]byte{}}
}
func (m *mockImageStorage) Put(contentType string, content io.Reader) (string, error) {
m.putCalls++
if m.failPut != nil {
return "", m.failPut
}
data, err := io.ReadAll(content)
if err != nil {
return "", err
}
m.nextName++
name := "stored-" + strconv.Itoa(m.nextName) + mockImageExtensions[contentType]
m.blobs[name] = data
m.lastContent = data
return name, nil
}
func (m *mockImageStorage) Open(name string) (imageDomain.Blob, error) {
data, ok := m.blobs[name]
if !ok {
return imageDomain.Blob{}, imageDomain.ErrBlobNotFound
}
return imageDomain.Blob{Content: io.NopCloser(strings.NewReader(string(data))), Size: int64(len(data))}, nil
}
func (m *mockImageStorage) Delete(name string) error {
m.deleted = append(m.deleted, name)
if m.failDelete != nil {
return m.failDelete
}
delete(m.blobs, name)
return nil
}
var _ imageDomain.Storage = &mockImageStorage{}
type mockImageRepo struct {
images map[uint32]imageDomain.Image
nextID uint32
@@ -28,7 +77,6 @@ type mockImageRepo struct {
failDeleteFor map[uint32]error
deleteCalls []uint32
createCalls int
lastContent []byte
onDelete func(id uint32)
}
@@ -45,34 +93,49 @@ func (m *mockImageRepo) sortedImages() []imageDomain.Image {
return result
}
func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image, error) {
func (m *mockImageRepo) Create(input imageDomain.Image) (imageDomain.Image, error) {
m.createCalls++
if err := m.fail.trigger("Create"); err != nil {
return imageDomain.Image{}, err
}
if input.Content != nil {
m.lastContent, _ = io.ReadAll(input.Content)
}
for m.images[m.nextID+1].ID != 0 {
m.nextID++
}
m.nextID++
img := imageDomain.Image{
ID: m.nextID,
Path: "/static/uploadedImages/" + strconv.Itoa(int(m.nextID)) + mockImageExtensions[input.ContentType],
Filename: input.Filename,
Size: input.Size,
ContentType: input.ContentType,
Width: input.Width,
Height: input.Height,
SHA256: input.SHA256,
UploadedByID: input.UploadedByID,
CreatedAt: mockImageEpoch.Add(time.Duration(m.nextID) * time.Minute),
}
img := input
img.ID = m.nextID
img.CreatedAt = mockImageEpoch.Add(time.Duration(m.nextID) * time.Minute)
m.images[img.ID] = img
return img, nil
}
func (m *mockImageRepo) ListMissingMetadata() ([]imageDomain.Image, error) {
if err := m.fail.trigger("ListMissingMetadata"); err != nil {
return nil, err
}
pending := []imageDomain.Image{}
for _, img := range m.sortedImages() {
if img.Filename == "" {
pending = append(pending, img)
}
}
return pending, nil
}
func (m *mockImageRepo) UpdateMetadata(id uint32, metadata imageDomain.Metadata) error {
if err := m.fail.trigger("UpdateMetadata"); err != nil {
return err
}
img, ok := m.images[id]
if !ok {
return imageDomain.ErrImageNotFound
}
img.Filename, img.Size, img.ContentType = metadata.Filename, metadata.Size, metadata.ContentType
img.Width, img.Height, img.SHA256 = metadata.Width, metadata.Height, metadata.SHA256
m.images[id] = img
return nil
}
func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) {
if err := m.fail.trigger("Get"); err != nil {
return imageDomain.Image{}, err
@@ -186,8 +249,8 @@ var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo)
deleteUc := useCases.NewDeleteMediaUseCase(repo)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage())
t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{
+5 -5
View File
@@ -39,7 +39,7 @@ func TestImageUploadRules(t *testing.T) {
for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} {
t.Run(contentType, func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
result, err := createUc.CreateImage(inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType))
@@ -58,7 +58,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage rejects a disallowed content type before reaching the repository", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := createUc.CreateImage(inputOf([]byte("%PDF"), "application/pdf"))
@@ -72,7 +72,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("CreateImage rejects a file that exceeds the size limit", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png"))
@@ -86,7 +86,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("A file exactly at the size limit is accepted, one byte over is not", func(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png"))
if err != nil {
@@ -108,7 +108,7 @@ func TestImageUploadRules(t *testing.T) {
t.Run("A repository failure unrelated to type or size still propagates unchanged", func(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNextWith("Create", errRepositoryFailure)
createUc := useCases.NewCreateImageUseCase(repo)
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := createUc.CreateImage(inputOf(pngBytes(t, 3, 2), "image/png"))
+1 -1
View File
@@ -60,7 +60,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder)
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage)
if _, err := uc.Merge(2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err)
+5 -3
View File
@@ -32,6 +32,7 @@ const (
type mediaEnv struct {
articles *mockArticleRepo
images *mockImageRepo
storage *mockImageStorage
users *mockUserRepo
purgeSettings *mockPurgeSettingsRepo
clock time.Time
@@ -47,6 +48,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env := &mediaEnv{
articles: newMockArticleRepo(),
images: newMockImageRepo(),
storage: newMockImageStorage(),
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
purgeSettings: newMockPurgeSettingsRepo(),
@@ -58,7 +60,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
@@ -68,10 +70,10 @@ func newMediaEnv(t *testing.T) *mediaEnv {
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
+6 -6
View File
@@ -411,7 +411,7 @@ func TestMediaUploading(t *testing.T) {
if len(env.images.images) != 1 || env.images.images[1].UploadedByID != mediaVerifiedUserId {
t.Errorf("expected one new image with UploadedByID 1, got %+v", env.images.images)
}
if !reflect.DeepEqual(env.images.lastContent, content) {
if !reflect.DeepEqual(env.storage.lastContent, content) {
t.Errorf("expected the repository to receive the uploaded bytes")
}
})
@@ -947,7 +947,7 @@ func TestMediaMerging(t *testing.T) {
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
env := newEnv(t)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
_, err := uc.Merge(2, []uint32{3, 3})
@@ -996,7 +996,7 @@ func TestMediaMerging(t *testing.T) {
func TestMediaSentinels(t *testing.T) {
t.Run("Create reports unreadable bytes as ErrImageUnreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
for _, contentType := range domainImage.AllowedContentTypes() {
_, err := uc.CreateImage(inputOf([]byte("not an image"), contentType))
@@ -1007,7 +1007,7 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("An svg without an svg root is unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
_, err := uc.CreateImage(inputOf([]byte("<html/>"), "image/svg+xml"))
@@ -1017,7 +1017,7 @@ func TestMediaSentinels(t *testing.T) {
})
t.Run("Bytes of another raster format than the declared type are unreadable", func(t *testing.T) {
uc := useCases.NewCreateImageUseCase(newMockImageRepo())
uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage())
_, err := uc.CreateImage(inputOf(jpegBytes(t, 3, 2), "image/png"))
@@ -1042,7 +1042,7 @@ func TestMediaSentinels(t *testing.T) {
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
if _, err := uc.Merge(1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
+17 -35
View File
@@ -5,7 +5,8 @@ import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"bytes"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"os"
"path/filepath"
@@ -21,13 +22,13 @@ func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository,
t.Fatalf("failed to migrate: %v", err)
}
uploadDir := t.TempDir()
return db, persistence.NewImageRepository(db, uploadDir), uploadDir
return db, persistence.NewImageRepository(db), uploadDir
}
func createStoredImage(t *testing.T, repo *persistence.ImageRepository, filename string, content []byte, contentType string) domainImage.Image {
t.Helper()
created, err := repo.Create(domainImage.ImageInput{
Content: bytes.NewReader(content),
created, err := repo.Create(domainImage.Image{
Path: domainImage.PublicPath(filename),
Filename: filename,
Size: int64(len(content)),
ContentType: contentType,
@@ -42,8 +43,8 @@ func createStoredImage(t *testing.T, repo *persistence.ImageRepository, filename
}
func TestMediaImageRepository(t *testing.T) {
t.Run("Create stores the file and the metadata and returns the assigned id", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
t.Run("Create stores the metadata and returns the assigned id", func(t *testing.T) {
_, repo, _ := newMediaPersistence(t)
content := pngBytes(t, 3, 2)
created := createStoredImage(t, repo, "a.png", content, "image/png")
@@ -51,10 +52,6 @@ func TestMediaImageRepository(t *testing.T) {
if created.ID == 0 || created.Filename != "a.png" || created.SHA256 != sha256Hex(content) || created.Width != 3 || created.Size != int64(len(content)) {
t.Errorf("unexpected image %+v", created)
}
onDisk, err := os.ReadFile(filepath.Join(uploadDir, filepath.Base(created.Path)))
if err != nil || !bytes.Equal(onDisk, content) {
t.Errorf("expected the file on disk, got %v", err)
}
got, err := repo.Get(created.ID)
if err != nil || got.SHA256 != created.SHA256 {
t.Errorf("Get returned %+v, %v", got, err)
@@ -69,34 +66,19 @@ func TestMediaImageRepository(t *testing.T) {
}
})
t.Run("Delete removes the file and the row", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
t.Run("Delete removes the row", func(t *testing.T) {
_, repo, _ := newMediaPersistence(t)
created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png")
if err := repo.Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := os.Stat(filepath.Join(uploadDir, filepath.Base(created.Path))); !os.IsNotExist(err) {
t.Errorf("expected the file to be gone")
}
if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected the row to be gone, got %v", err)
}
})
t.Run("A file already missing from disk still deletes the row", func(t *testing.T) {
_, repo, uploadDir := newMediaPersistence(t)
created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png")
if err := os.Remove(filepath.Join(uploadDir, filepath.Base(created.Path))); err != nil {
t.Fatalf("failed to remove the file: %v", err)
}
if err := repo.Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected the row to be gone, got %v", err)
}
if err := repo.Delete(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) {
t.Errorf("expected ErrImageNotFound on a second delete, got %v", err)
}
})
t.Run("List, FindByHash, Rename, ListDuplicates and Counts", func(t *testing.T) {
@@ -183,7 +165,7 @@ func TestMediaImageBackfill(t *testing.T) {
writeFile(t, uploadDir, "two.svg", svg)
pngRow, svgRow := seedLegacyRow(t, db, "one.png"), seedLegacyRow(t, db, "two.svg")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("backfill failed: %v", err)
}
@@ -201,7 +183,7 @@ func TestMediaImageBackfill(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
row := seedLegacyRow(t, db, "gone.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("backfill failed: %v", err)
}
@@ -217,7 +199,7 @@ func TestMediaImageBackfill(t *testing.T) {
writeFile(t, uploadDir, "fine.png", pngBytes(t, 3, 2))
broken, fine := seedLegacyRow(t, db, "broken.png"), seedLegacyRow(t, db, "fine.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("backfill failed: %v", err)
}
@@ -233,7 +215,7 @@ func TestMediaImageBackfill(t *testing.T) {
db, repo, uploadDir := newMediaPersistence(t)
writeFile(t, uploadDir, "one.png", pngBytes(t, 3, 2))
row := seedLegacyRow(t, db, "one.png")
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("backfill failed: %v", err)
}
afterFirst, _ := repo.Get(row.ID)
@@ -241,7 +223,7 @@ func TestMediaImageBackfill(t *testing.T) {
t.Fatal(err)
}
if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil {
if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil {
t.Fatalf("second backfill failed: %v", err)
}
+1 -1
View File
@@ -31,7 +31,7 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper()
env := newPurgeEnv(t)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
now := func() time.Time { return env.clock }
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0))
return &schedulerEnv{
+3 -3
View File
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0))
}
@@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 1, 2)
env.useImages(3, 1)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
result, err := merge.Merge(1, []uint32{2})
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage)
result, err := merge.Merge(1, []uint32{2})
+210
View File
@@ -0,0 +1,210 @@
package test
import (
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"strings"
"testing"
"time"
)
func pngUpload(t *testing.T) domainImage.ImageInput {
t.Helper()
return domainImage.ImageInput{
Content: bytes.NewReader(pngBytes(t, 3, 2)),
Filename: "a.png",
ContentType: "image/png",
}
}
func uploadPng(t *testing.T, uc *useCases.CreateImageUseCase) domainImage.Image {
t.Helper()
created, err := uc.CreateImage(pngUpload(t))
if err != nil {
t.Fatalf("CreateImage failed: %v", err)
}
return created
}
func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) {
t.Run("The row points at the stored name and the blob holds the bytes", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store))
if !strings.HasPrefix(created.Path, domainImage.PublicPathPrefix) {
t.Errorf("unexpected path %q", created.Path)
}
if !bytes.Equal(store.blobs[domainImage.StoredName(created.Path)], pngBytes(t, 3, 2)) {
t.Error("expected the blob to hold the uploaded bytes")
}
})
t.Run("A storage failure creates no row", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
store.failPut = errRepositoryFailure
_, err := useCases.NewCreateImageUseCase(repo, store).CreateImage(pngUpload(t))
if !errors.Is(err, errRepositoryFailure) || repo.createCalls != 0 {
t.Errorf("got %v with %d Create calls", err, repo.createCalls)
}
})
t.Run("A row failure removes the blob that was just stored", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
repo.fail.failNextWith("Create", errRepositoryFailure)
_, err := useCases.NewCreateImageUseCase(repo, store).CreateImage(pngUpload(t))
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the repository failure, got %v", err)
}
if len(store.blobs) != 0 || len(store.deleted) != 1 {
t.Errorf("expected the orphan blob to be deleted, blobs=%d deleted=%v", len(store.blobs), store.deleted)
}
})
}
func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("Both disappear", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store))
if err := useCases.NewDeleteMediaUseCase(repo, store).Delete(created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if len(store.blobs) != 0 || len(repo.images) != 0 {
t.Errorf("expected nothing left, blobs=%d rows=%d", len(store.blobs), len(repo.images))
}
})
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
err := useCases.NewDeleteMediaUseCase(repo, store).Delete(7)
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
t.Errorf("got %v, deleted %v", err, store.deleted)
}
})
t.Run("A blob failure keeps the row so the delete can be retried", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store).Delete(created.ID)
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
}
})
}
func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
repo, articles, store := newMockImageRepo(), newMockArticleRepo(), newMockImageStorage()
create := useCases.NewCreateImageUseCase(repo, store)
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
result, err := useCases.NewMergeMediaUseCase(repo, articles, store).Merge(kept.ID, []uint32{copyOne.ID, copyTwo.ID})
if err != nil || result.Merged != 2 {
t.Fatalf("merge returned %+v, %v", result, err)
}
if len(store.blobs) != 1 || store.blobs[domainImage.StoredName(kept.Path)] == nil {
t.Errorf("expected only the kept blob to remain, got %d blobs", len(store.blobs))
}
}
func TestBackfillImageMetadataUseCase(t *testing.T) {
t.Run("Rows without metadata are filled from the stored blob and unreadable ones are skipped", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
store.blobs["one.png"] = pngBytes(t, 3, 2)
store.blobs["broken.png"] = []byte("nope")
repo.images[1] = domainImage.Image{ID: 1, Path: domainImage.PublicPath("one.png")}
repo.images[2] = domainImage.Image{ID: 2, Path: domainImage.PublicPath("broken.png")}
repo.images[3] = domainImage.Image{ID: 3, Path: domainImage.PublicPath("missing.png")}
repo.images[4] = domainImage.Image{ID: 4, Path: domainImage.PublicPath("notes.txt")}
repo.images[5] = domainImage.Image{ID: 5, Path: domainImage.PublicPath("done.png"), Filename: "done.png"}
if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); err != nil {
t.Fatalf("Run failed: %v", err)
}
got := repo.images[1]
if got.Filename != "one.png" || got.ContentType != "image/png" || got.Width != 3 || got.Height != 2 || got.SHA256 != sha256Hex(store.blobs["one.png"]) {
t.Errorf("unexpected backfilled row %+v", got)
}
for _, id := range []uint32{2, 3, 4} {
if repo.images[id].Filename != "" {
t.Errorf("row %d must be left as is, got %+v", id, repo.images[id])
}
}
})
t.Run("Repository failures stop the run", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
repo.fail.failNextWith("ListMissingMetadata", errRepositoryFailure)
if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the list failure, got %v", err)
}
repo, store = newMockImageRepo(), newMockImageStorage()
store.blobs["one.png"] = pngBytes(t, 1, 1)
repo.images[1] = domainImage.Image{ID: 1, Path: domainImage.PublicPath("one.png")}
repo.fail.failNextWith("UpdateMetadata", errRepositoryFailure)
if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected the update failure, got %v", err)
}
})
}
func TestDriverRegistries(t *testing.T) {
t.Run("An unknown database driver is rejected", func(t *testing.T) {
if _, err := database.Connect(database.Config{Driver: "oracle", Source: "x"}); err == nil || !strings.Contains(err.Error(), "oracle") {
t.Errorf("expected an unsupported driver error, got %v", err)
}
})
t.Run("An unknown storage driver is rejected", func(t *testing.T) {
if _, err := storage.Open(storage.Config{Driver: "floppy"}); err == nil || !strings.Contains(err.Error(), "floppy") {
t.Errorf("expected an unsupported driver error, got %v", err)
}
})
t.Run("The sqlite driver connects, migrates, backs up and closes", func(t *testing.T) {
dir := t.TempDir()
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: dir + "/nested/app.db"})
if err != nil {
t.Fatalf("Connect failed: %v", err)
}
if err := database.Migrate(connection.DB); err != nil {
t.Fatalf("Migrate failed: %v", err)
}
if err := connection.DB.Create(&models.Article{Title: "t"}).Error; err != nil {
t.Fatalf("expected the schema to exist: %v", err)
}
if _, err := connection.NewBackup(dir+"/backups", time.Now).Backup(); err != nil {
t.Errorf("Backup failed: %v", err)
}
if err := connection.Close(); err != nil {
t.Errorf("Close failed: %v", err)
}
})
t.Run("The storage registry returns a working local storage", func(t *testing.T) {
store, err := storage.Open(storage.Config{Driver: storage.DriverLocal, Location: t.TempDir()})
if err != nil {
t.Fatalf("Open failed: %v", err)
}
if _, err := store.Put("image/png", strings.NewReader("x")); err != nil {
t.Errorf("Put failed: %v", err)
}
})
}
+9 -25
View File
@@ -8,8 +8,7 @@ import (
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
@@ -19,8 +18,6 @@ import (
"path/filepath"
"testing"
imageDomain "RenewCMS/internal/domain/image"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
@@ -143,11 +140,11 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage()),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
}
}
@@ -200,34 +197,21 @@ func TestRuntimeSettingsInjectionCors(t *testing.T) {
}
func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) {
db := openInMemoryDb(t)
if err := db.AutoMigrate(&models.Image{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
injectedUploadDir := t.TempDir()
repo := persistence.NewImageRepository(db, injectedUploadDir)
store := storage.NewLocalStorage(injectedUploadDir)
processUploadDir := t.TempDir()
t.Setenv("UPLOAD_DIR", processUploadDir)
result, err := repo.Create(imageDomain.ImageInput{
Content: bytes.NewReader([]byte("fake-image-data")),
Filename: "upload.png",
ContentType: "image/png",
})
storedName, err := store.Put("image/png", bytes.NewReader([]byte("fake-image-data")))
if err != nil {
t.Fatalf("Create failed: %v", err)
t.Fatalf("Put failed: %v", err)
}
if got := result.Path; len(got) < len("/static/uploadedImages/") || got[:len("/static/uploadedImages/")] != "/static/uploadedImages/" {
t.Errorf("expected Path to start with /static/uploadedImages/, got %q", got)
}
fileName := filepath.Base(result.Path)
if _, err := os.Stat(filepath.Join(injectedUploadDir, fileName)); err != nil {
if _, err := os.Stat(filepath.Join(injectedUploadDir, storedName)); err != nil {
t.Errorf("expected the file to exist under the injected upload dir: %v", err)
}
if _, err := os.Stat(filepath.Join(processUploadDir, fileName)); !os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(processUploadDir, storedName)); !os.IsNotExist(err) {
t.Errorf("expected the file to not exist under the process UPLOAD_DIR")
}
}
+3 -1
View File
@@ -3,6 +3,8 @@ package test
import (
"RenewCMS/api/handlers/staticimages"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"net/http/httptest"
"os"
@@ -18,7 +20,7 @@ const spaIndexMarker = "SPA-INDEX-MARKER"
func newServeUploadedImagesRouter(uploadDir string) *chi.Mux {
r := chi.NewRouter()
r.Use(chimiddleware.GetHead)
r.Mount("/static/uploadedImages", staticimages.NewHandler(uploadDir))
r.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(storage.NewLocalStorage(uploadDir))))
r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(spaIndexMarker))
+2 -2
View File
@@ -1,7 +1,7 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
"path/filepath"
"testing"
@@ -16,7 +16,7 @@ func TestProductionSQLiteDSNWaitsForTheWriteLock(t *testing.T) {
t.Run("A second writer waits for the first instead of failing with a locked database", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "busy.db")
open := func() *gorm.DB {
db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatalf("open: %v", err)
}
+160
View File
@@ -0,0 +1,160 @@
package test
import (
"RenewCMS/api/handlers/staticimages"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/storage"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type streamOnlyStorage struct {
data []byte
}
func (s streamOnlyStorage) Put(string, io.Reader) (string, error) { return "", errors.New("read only") }
func (s streamOnlyStorage) Delete(string) error { return nil }
func (s streamOnlyStorage) Open(name string) (domainImage.Blob, error) {
if name != "remote.png" {
return domainImage.Blob{}, domainImage.ErrBlobNotFound
}
return domainImage.Blob{
Content: io.NopCloser(bytes.NewReader(s.data)),
Size: int64(len(s.data)),
ModTime: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}, nil
}
type failingStorage struct{ streamOnlyStorage }
func (failingStorage) Open(string) (domainImage.Blob, error) {
return domainImage.Blob{}, errors.New("boom")
}
func serveFrom(t *testing.T, store domainImage.Storage, method, target string) *httptest.ResponseRecorder {
t.Helper()
handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
return rec
}
func TestLocalStorage(t *testing.T) {
t.Run("Put then Open returns the stored bytes and Delete removes them", func(t *testing.T) {
store := storage.NewLocalStorage(t.TempDir() + "/nested")
name, err := store.Put("image/png", strings.NewReader("hello"))
if err != nil || !strings.HasSuffix(name, ".png") {
t.Fatalf("Put returned %q, %v", name, err)
}
blob, err := store.Open(name)
if err != nil {
t.Fatalf("Open failed: %v", err)
}
got, _ := io.ReadAll(blob.Content)
_ = blob.Content.Close()
if string(got) != "hello" || blob.Size != 5 {
t.Errorf("unexpected blob %q size %d", got, blob.Size)
}
if err := store.Delete(name); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("expected ErrBlobNotFound after delete, got %v", err)
}
if err := store.Delete(name); err != nil {
t.Errorf("deleting a missing file must not fail, got %v", err)
}
})
t.Run("Names that are not plain file names are never opened or deleted", func(t *testing.T) {
dir := t.TempDir()
store := storage.NewLocalStorage(dir)
for _, name := range []string{"", ".", "..", "../x.png", "a/b.png", `a\b.png`} {
if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) {
t.Errorf("Open(%q) = %v, want ErrBlobNotFound", name, err)
}
if err := store.Delete(name); err != nil {
t.Errorf("Delete(%q) = %v, want nil", name, err)
}
}
})
}
func TestStaticImagesWithNonSeekableStorage(t *testing.T) {
store := streamOnlyStorage{data: []byte("remote-bytes")}
t.Run("GET streams the body with length and modification headers", func(t *testing.T) {
rec := serveFrom(t, store, http.MethodGet, "/remote.png")
if rec.Code != http.StatusOK || rec.Body.String() != "remote-bytes" {
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Content-Length") != "12" || rec.Header().Get("Content-Type") != "image/png" {
t.Errorf("unexpected headers %v", rec.Header())
}
if rec.Header().Get("Last-Modified") == "" {
t.Error("expected Last-Modified")
}
})
t.Run("HEAD sends the headers without a body", func(t *testing.T) {
rec := serveFrom(t, store, http.MethodHead, "/remote.png")
if rec.Code != http.StatusOK || rec.Body.Len() != 0 {
t.Errorf("got %d with %d body bytes", rec.Code, rec.Body.Len())
}
})
t.Run("A missing blob answers 404 and any other storage error answers 500", func(t *testing.T) {
if rec := serveFrom(t, store, http.MethodGet, "/other.png"); rec.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", rec.Code)
}
if rec := serveFrom(t, failingStorage{}, http.MethodGet, "/remote.png"); rec.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d", rec.Code)
}
})
}
func TestMediaSearchTreatsWildcardsLiterally(t *testing.T) {
db, repo, _ := newMediaPersistence(t)
if err := db.AutoMigrate(&models.Article{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
percent := createStoredImage(t, repo, "100%_done.png", pngBytes(t, 1, 1), "image/png")
createStoredImage(t, repo, "plain.png", pngBytes(t, 2, 2), "image/png")
for _, search := range []string{"%", "_", "!", "100%_"} {
found, err := repo.List(domainImage.SearchQuery{Search: search})
if err != nil {
t.Fatalf("List(%q) failed: %v", search, err)
}
if search == "!" {
if len(found) != 0 {
t.Errorf("List(%q) matched %d images, want 0", search, len(found))
}
continue
}
if len(found) != 1 || found[0].ID != percent.ID {
t.Errorf("List(%q) = %+v, want only the 100%%_done image", search, found)
}
}
articles := persistence.NewArticleRepository(db)
if _, err := articles.Create(domainArticle.Article{Title: "t", Body: "uses 50%_off.png here"}); err != nil {
t.Fatalf("article create failed: %v", err)
}
if found, _ := articles.FindByBodyContaining("0%_o"); len(found) != 1 {
t.Errorf("expected the literal match, got %d", len(found))
}
if found, _ := articles.FindByBodyContaining("0x_o"); len(found) != 0 {
t.Errorf("underscore must not act as a wildcard, got %d", len(found))
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/persistence/database"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"context"
@@ -551,7 +552,7 @@ func TestUpdateBackupBeforeReadyHTTP(t *testing.T) {
}
backdate(t, path, time.Duration(10-index)*time.Hour)
}
rig.realBackup = infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), backups, time.Now)
rig.realBackup = database.NewSQLiteBackup(newBackupTestDatabase(t), backups, time.Now)
rig.rebuild()
h := newDownloadHTTP(t, rig)
+6 -6
View File
@@ -1,8 +1,8 @@
package test
import (
"RenewCMS/api"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
@@ -768,7 +768,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) {
db := newBackupTestDatabase(t)
backups := filepath.Join(t.TempDir(), "backups")
clock := time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC)
backup := infraUpdate.NewSQLiteDatabaseBackup(db, backups, func() time.Time {
backup := database.NewSQLiteBackup(db, backups, func() time.Time {
clock = clock.Add(time.Second)
return clock
})
@@ -813,7 +813,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) {
t.Run("a failed copy prunes nothing", func(t *testing.T) {
directory := t.TempDir()
fixed := func() time.Time { return clock }
failing := infraUpdate.NewSQLiteDatabaseBackup(db, directory, fixed)
failing := database.NewSQLiteBackup(db, directory, fixed)
if _, err := failing.Backup(); err != nil {
t.Fatal(err)
}
@@ -837,7 +837,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) {
func newBackupTestDatabase(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "live.db")
db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
if err != nil {
t.Fatal(err)
}
@@ -904,7 +904,7 @@ func TestSQLiteDatabaseBackupStaleCopyHeldOpen(t *testing.T) {
t.Fatal(err)
}
defer held.Close()
backup := infraUpdate.NewSQLiteDatabaseBackup(db, directory, time.Now)
backup := database.NewSQLiteBackup(db, directory, time.Now)
if _, err := backup.Backup(); err == nil {
t.Error("a stale copy that cannot be removed must surface as an error")
@@ -1001,7 +1001,7 @@ func TestSQLiteDatabaseBackupKeepsTheNewestNameOnEqualTimes(t *testing.T) {
t.Fatal(err)
}
}
backup := infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), directory, time.Now)
backup := database.NewSQLiteBackup(newBackupTestDatabase(t), directory, time.Now)
if _, err := backup.Backup(); err != nil {
t.Fatal(err)
+9 -2
View File
@@ -6,6 +6,7 @@ import (
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/storage"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
@@ -551,7 +552,10 @@ func TestRestartResultLivesUntilTheNextJob(t *testing.T) {
func TestHealthzIsUnchanged(t *testing.T) {
t.Run("Healthz is unchanged", func(t *testing.T) {
container := &api.UseCases{Token: jwtauth.New("HS256", []byte("test-secret"), nil)}
container := &api.UseCases{
Token: jwtauth.New("HS256", []byte("test-secret"), nil),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
}
router := api.InitRoutes(container, config.Settings{Environment: "production"})
rec := httptest.NewRecorder()
@@ -609,7 +613,10 @@ func TestRunningInfoFileAtStartup(t *testing.T) {
})
t.Run("the file is not served by any route", func(t *testing.T) {
container := &api.UseCases{Token: jwtauth.New("HS256", []byte("test-secret"), nil)}
container := &api.UseCases{
Token: jwtauth.New("HS256", []byte("test-secret"), nil),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())),
}
router := api.InitRoutes(container, config.Settings{Environment: "production"})
for _, target := range []string{"/update-running.json", "/v1/update-running.json", "/static/uploadedImages/update-running.json"} {
+4 -4
View File
@@ -334,7 +334,7 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
for _, tc := range cases {
t.Run(strings.ReplaceAll(tc.contentType, "/", "_"), func(t *testing.T) {
repo := newMockImageRepo()
uc := useCases.NewCreateImageUseCase(repo)
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
content := []byte("fake-image-data")
if tc.valid {
@@ -368,7 +368,7 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) {
func TestCreateImagePropagatesRepositoryFailure(t *testing.T) {
repo := newMockImageRepo()
repo.fail.failNext("Create")
uc := useCases.NewCreateImageUseCase(repo)
uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage())
_, err := uc.CreateImage(imageDomain.ImageInput{
Content: bytes.NewReader(pngBytes(t, 3, 2)),
@@ -385,7 +385,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo)
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage())
err := uc.Delete(1)
if err != nil {
@@ -399,7 +399,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo)
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage())
err := uc.Delete(9)
if !errors.Is(err, imageDomain.ErrImageNotFound) {
+4 -4
View File
@@ -7,8 +7,8 @@ import (
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"net/http"
@@ -451,7 +451,7 @@ func TestBackfillUserRoles(t *testing.T) {
seed(t, db, 5, "")
seed(t, db, 9, "")
if err := persistence.BackfillUserRoles(db); err != nil {
if err := database.BackfillUserRoles(db); err != nil {
t.Fatalf("backfill: %v", err)
}
@@ -466,7 +466,7 @@ func TestBackfillUserRoles(t *testing.T) {
seed(t, db, 2, "admin")
seed(t, db, 4, "owner")
if err := persistence.BackfillUserRoles(db); err != nil {
if err := database.BackfillUserRoles(db); err != nil {
t.Fatalf("backfill: %v", err)
}
@@ -479,7 +479,7 @@ func TestBackfillUserRoles(t *testing.T) {
t.Run("An empty database is left alone", func(t *testing.T) {
db := openDB(t)
if err := persistence.BackfillUserRoles(db); err != nil {
if err := database.BackfillUserRoles(db); err != nil {
t.Fatalf("backfill: %v", err)
}
})
+1 -1
View File
@@ -172,7 +172,7 @@ func TestTheImageRepositoryReportsAMissingImageAsADomainErrorOnDelete(t *testing
if err := db.Create(&models.Image{Path: "/static/uploadedImages/one.png"}).Error; err != nil {
t.Fatalf("failed to seed image: %v", err)
}
repo := persistence.NewImageRepository(db, t.TempDir())
repo := persistence.NewImageRepository(db)
err := repo.Delete(9)
assertWriteResultIsImageNotFound(t, err)