refac: decouple persistence and image storage behind driver registries
This commit is contained in:
1 parent
421cfafec3
commit
81e299f09a
46 files changed
+1035
-373
No files matched your search
+30
-45
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+7
-7
@@ -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
-1
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user