精简后的项目:仅保留 api/ui 源码、文档、构建依赖

This commit is contained in:
gallery
2026-09-11 16:11:09 +08:00
commit 490e85634e
457 changed files with 86862 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
package routes
import (
"fmt"
"net/http"
"time"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
// "github.com/photoview/photoview/api/log"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
const internalServerError = "internal server error"
func authenticateMedia(media *models.Media, db *gorm.DB, r *http.Request) (success bool, responseMessage string, responseStatus int, errorMessage error) {
user := auth.UserFromContext(r.Context())
if user != nil {
var album models.Album
if err := db.First(&album, media.AlbumID).Error; err != nil {
// log.Debug(nil, "Failed to find album for media %d: %v", media.ID, err)
return false, internalServerError, http.StatusInternalServerError, err
}
ownsAlbum, err := user.OwnsAlbum(db, &album)
if err != nil {
// log.Debug(nil, "Failed to check if user owns album %d for media %d: %v", media.AlbumID, media.ID, err)
return false, internalServerError, http.StatusInternalServerError, err
}
if !ownsAlbum {
// log.Debug(nil, "User does not own album %d for media %d", media.AlbumID, media.ID)
return false, "invalid credentials", http.StatusForbidden, nil
}
} else {
if success, respMsg, respStatus, err := shareTokenFromRequest(db, r, &media.ID, &media.AlbumID); !success {
return success, respMsg, respStatus, err
}
}
return true, "success", http.StatusAccepted, nil
}
func authenticateAlbum(album *models.Album, db *gorm.DB, r *http.Request) (success bool, responseMessage string, responseStatus int, errorMessage error) {
user := auth.UserFromContext(r.Context())
if user != nil {
ownsAlbum, err := user.OwnsAlbum(db, album)
if err != nil {
// log.Debug(nil, "Failed to check if user owns album %d: %v", album.ID, err)
return false, internalServerError, http.StatusInternalServerError, err
}
if !ownsAlbum {
// log.Debug(nil, "User does not own album %d", album.ID)
return false, "invalid credentials", http.StatusForbidden, nil
}
} else {
if success, respMsg, respStatus, err := shareTokenFromRequest(db, r, nil, &album.ID); !success {
return success, respMsg, respStatus, err
}
}
return true, "success", http.StatusAccepted, nil
}
func shareTokenFromRequest(db *gorm.DB, r *http.Request, mediaID *int, albumID *int) (success bool, responseMessage string, responseStatus int, errorMessage error) {
// Check if photo is authorized with a share token
token := r.URL.Query().Get("token")
if token == "" {
return false, "unauthorized", http.StatusForbidden, errors.New("share token not provided")
}
var shareToken models.ShareToken
if err := db.Where("value = ?", token).First(&shareToken).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// log.Debug(nil, "Share token not found: %s", token)
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
}
// log.Debug(nil, "Error fetching share token: %s, error: %v", token, err)
return false, internalServerError, http.StatusInternalServerError, err
}
if shareToken.Expire != nil && time.Now().UTC().After(shareToken.Expire.UTC()) {
// log.Debug(nil, "Share token expired: %s", token)
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
}
// Validate share token password, if set
if shareToken.Password != nil {
tokenPasswordCookie, err := r.Cookie(fmt.Sprintf("share-token-pw-%s", shareToken.Value))
if err != nil {
// log.Debug(nil, "Error getting share token password cookie: %v", err)
return false, "unauthorized", http.StatusForbidden, errors.Wrap(err, "share token password invalid")
}
// tokenPassword := r.Header.Get("TokenPassword")
tokenPassword := tokenPasswordCookie.Value
if err := bcrypt.CompareHashAndPassword([]byte(*shareToken.Password), []byte(tokenPassword)); err != nil {
if err == bcrypt.ErrMismatchedHashAndPassword {
// log.Debug(nil, "Incorrect password for share token: %s", token)
return false, "unauthorized", http.StatusForbidden, errors.New("share token password invalid")
} else {
// log.Debug(nil, "Error comparing share token password: %s, error: %v", token, err)
return false, internalServerError, http.StatusInternalServerError, err
}
}
}
if shareToken.AlbumID != nil && albumID == nil {
// log.Debug(nil, "Share token is of type album, but no albumID was provided to function")
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
}
if shareToken.MediaID != nil && mediaID == nil {
// log.Debug(nil, "Share token is of type media, but no mediaID was provided to function")
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
}
if shareToken.AlbumID != nil && *albumID != *shareToken.AlbumID {
// Check child albums
var count int
err := db.Raw(`
WITH recursive child_albums AS (
SELECT * FROM albums WHERE parent_album_id = ?
UNION ALL
SELECT child.* FROM albums child JOIN child_albums parent ON parent.id = child.parent_album_id
)
SELECT COUNT(id) FROM child_albums WHERE id = ?
`, *shareToken.AlbumID, albumID).Find(&count).Error
if err != nil {
// log.Debug(nil, "Error checking child albums for share token: %s, error: %v", token, err)
return false, internalServerError, http.StatusInternalServerError, err
}
if count == 0 {
// log.Debug(nil, "No child albums found for share token: %s", token)
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
}
}
if shareToken.MediaID != nil && *mediaID != *shareToken.MediaID {
// log.Debug(nil, "Media share token does not match mediaID: %d != %d", *mediaID, *shareToken.MediaID)
return false, "unauthorized", http.StatusForbidden, errors.New("invalid share token")
}
return true, "", 0, nil
}
+276
View File
@@ -0,0 +1,276 @@
package routes
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
"github.com/photoview/photoview/api/test_utils"
"github.com/stretchr/testify/assert"
)
func TestAuthenticateRoute(t *testing.T) {
const imageData = "IMAGE DATA"
const albumData = "ALBUM DATA"
db := test_utils.DatabaseTest(t)
user, err := models.RegisterUser(db, "username", nil, false)
if !assert.NoError(t, err) {
return
}
album := models.Album{
Title: "my_album",
Path: "/photos",
}
if !assert.NoError(t, db.Model(&user).Association("Albums").Append(&album)) {
return
}
media := models.Media{
Title: "my_media",
Path: "/photos/image.jpg",
AlbumID: album.ID,
}
if !assert.NoError(t, db.Save(&media).Error) {
return
}
t.Run("Authenticate Media", func(t *testing.T) {
t.Run("Authorized request", func(t *testing.T) {
req := httptest.NewRequest("GET", "/photo/image.jpg", strings.NewReader(imageData))
ctx := auth.AddUserToContext(req.Context(), user)
req = req.WithContext(ctx)
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
assert.NoError(t, err)
assert.True(t, success)
assert.Equal(t, responseMessage, "success")
assert.Equal(t, responseStatus, http.StatusAccepted)
})
t.Run("Request without access token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/photo/image.jpg", strings.NewReader(imageData))
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, responseMessage, "unauthorized")
assert.Equal(t, responseStatus, http.StatusForbidden)
})
expire := time.Now().Add(time.Hour * 24 * 30)
tokenPassword := "token-password-123"
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expire, &tokenPassword)
if !assert.NoError(t, err) {
return
}
t.Run("Request with share token", func(t *testing.T) {
url := fmt.Sprintf("/photo/image.jpg?token=%s", shareToken.Value)
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
Value: tokenPassword,
}
req.AddCookie(&cookie)
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
assert.NoError(t, err)
assert.True(t, success)
assert.Equal(t, "success", responseMessage)
assert.Equal(t, http.StatusAccepted, responseStatus)
})
t.Run("Request with invalid share token", func(t *testing.T) {
url := fmt.Sprintf("/photo/image.jpg?token=%s", "invalid-token")
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
// Even if a cookie is sent, the token is invalid
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", "invalid-token"),
Value: "whatever",
}
req.AddCookie(&cookie)
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
t.Run("Request with share token but no password cookie", func(t *testing.T) {
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expire, &tokenPassword)
assert.NoError(t, err)
url := fmt.Sprintf("/photo/image.jpg?token=%s", shareToken.Value)
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
// No cookie provided
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
t.Run("Request with share token and wrong password", func(t *testing.T) {
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expire, &tokenPassword)
assert.NoError(t, err)
url := fmt.Sprintf("/photo/image.jpg?token=%s", shareToken.Value)
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
Value: "incorrect-password",
}
req.AddCookie(&cookie)
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
t.Run("Request with expired share token", func(t *testing.T) {
expired := time.Now().Add(-time.Hour)
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expired, &tokenPassword)
assert.NoError(t, err)
url := fmt.Sprintf("/photo/image.jpg?token=%s", shareToken.Value)
req := httptest.NewRequest("GET", url, strings.NewReader(imageData))
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
Value: tokenPassword,
}
req.AddCookie(&cookie)
success, responseMessage, responseStatus, err := authenticateMedia(&media, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
})
t.Run("Authenticate Album", func(t *testing.T) {
t.Run("Authorized request", func(t *testing.T) {
req := httptest.NewRequest("GET", "/download/album/1", strings.NewReader(albumData))
ctx := auth.AddUserToContext(req.Context(), user)
req = req.WithContext(ctx)
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
assert.NoError(t, err)
assert.True(t, success)
assert.Equal(t, "success", responseMessage)
assert.Equal(t, http.StatusAccepted, responseStatus)
})
t.Run("Request without access token", func(t *testing.T) {
req := httptest.NewRequest("GET", "/download/album/1", strings.NewReader(albumData))
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
expire := time.Now().Add(time.Hour * 24 * 30)
tokenPassword := "token-password-123"
shareToken, err := actions.AddAlbumShare(db, user, album.ID, &expire, &tokenPassword)
if !assert.NoError(t, err) {
return
}
t.Run("Request with share token", func(t *testing.T) {
url := fmt.Sprintf("/download/album/1?token=%s", shareToken.Value)
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
Value: tokenPassword,
}
req.AddCookie(&cookie)
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
assert.NoError(t, err)
assert.True(t, success)
assert.Equal(t, "success", responseMessage)
assert.Equal(t, http.StatusAccepted, responseStatus)
})
t.Run("Request with invalid album share token", func(t *testing.T) {
url := fmt.Sprintf("/download/album/1?token=%s", "invalid-token")
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", "invalid-token"),
Value: "whatever",
}
req.AddCookie(&cookie)
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
t.Run("Request with album share token but no password cookie", func(t *testing.T) {
shareToken, err := actions.AddAlbumShare(db, user, album.ID, &expire, &tokenPassword)
assert.NoError(t, err)
url := fmt.Sprintf("/download/album/1?token=%s", shareToken.Value)
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
// No cookie provided
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
t.Run("Request with album share token and wrong password", func(t *testing.T) {
shareToken, err := actions.AddAlbumShare(db, user, album.ID, &expire, &tokenPassword)
assert.NoError(t, err)
url := fmt.Sprintf("/download/album/1?token=%s", shareToken.Value)
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
Value: "incorrect-password",
}
req.AddCookie(&cookie)
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
t.Run("Request with expired album share token", func(t *testing.T) {
expired := time.Now().Add(-time.Hour)
shareToken, err := actions.AddAlbumShare(db, user, album.ID, &expired, &tokenPassword)
assert.NoError(t, err)
url := fmt.Sprintf("/download/album/1?token=%s", shareToken.Value)
req := httptest.NewRequest("GET", url, strings.NewReader(albumData))
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", shareToken.Value),
Value: tokenPassword,
}
req.AddCookie(&cookie)
success, responseMessage, responseStatus, err := authenticateAlbum(&album, db, req)
assert.Error(t, err)
assert.False(t, success)
assert.Equal(t, "unauthorized", responseMessage)
assert.Equal(t, http.StatusForbidden, responseStatus)
})
})
}
+112
View File
@@ -0,0 +1,112 @@
package routes
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/gorilla/mux"
"github.com/photoview/photoview/api/database/drivers"
"github.com/photoview/photoview/api/graphql/models"
"gorm.io/gorm"
)
func RegisterDownloadRoutes(db *gorm.DB, router *mux.Router) {
router.HandleFunc("/album/{album_id}/{media_purpose}", func(w http.ResponseWriter, r *http.Request) {
albumID := mux.Vars(r)["album_id"]
mediaPurpose := mux.Vars(r)["media_purpose"]
mediaPurposeList := strings.SplitN(mediaPurpose, ",", 10)
var album models.Album
if err := db.Find(&album, albumID).Error; err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404"))
return
}
if success, response, status, err := authenticateAlbum(&album, db, r); !success {
if err != nil {
log.Printf("WARN: error authenticating album for download: %v\n", err)
}
w.WriteHeader(status)
w.Write([]byte(response))
return
}
var mediaWhereQuery string
if drivers.POSTGRES.MatchDatabase(db) {
mediaWhereQuery = "\"Media\".album_id = ?"
} else {
mediaWhereQuery = "Media.album_id = ?"
}
var mediaURLs []*models.MediaURL
if err := db.Joins("Media").Where(mediaWhereQuery, album.ID).Where("media_urls.purpose IN (?)", mediaPurposeList).Find(&mediaURLs).Error; err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
if len(mediaURLs) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("no media found"))
return
}
// Do not allow caching
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", album.Title))
zipWriter := zip.NewWriter(w)
for _, media := range mediaURLs {
zipFile, err := zipWriter.Create(fmt.Sprintf("%s/%s", album.Title, media.MediaName))
if err != nil {
log.Printf("ERROR: Failed to create a file in zip, when downloading album (%d): %v\n", album.ID, err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
filePath, err := media.CachedPath()
if err != nil {
log.Printf("ERROR: Failed to get mediaURL cache path, when downloading album (%d): %v\n", album.ID, err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
fileData, err := os.Open(filePath)
if err != nil {
log.Printf("ERROR: Failed to open file to include in zip, when downloading album (%d): %v\n", album.ID, err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
_, err = io.Copy(zipFile, fileData)
if err != nil {
log.Printf("ERROR: Failed to copy file data, when downloading album (%d): %v\n", album.ID, err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
if err := fileData.Close(); err != nil {
log.Printf("ERROR: Failed to close file, when downloading album (%d): %v\n", album.ID, err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
}
// close the zip Writer to flush the contents to the ResponseWriter
zipWriter.Close()
})
}
+81
View File
@@ -0,0 +1,81 @@
package routes
import (
"net/http"
"os"
"github.com/gorilla/mux"
"gorm.io/gorm"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner"
)
func RegisterPhotoRoutes(db *gorm.DB, router *mux.Router) {
router.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) {
mediaName := mux.Vars(r)["name"]
var mediaURL models.MediaURL
result := db.Model(&models.MediaURL{}).Joins("Media").Select("media_urls.*").Where("media_urls.media_name = ?", mediaName).Scan(&mediaURL)
if err := result.Error; err != nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404"))
return
}
media := mediaURL.Media
if media == nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("404 - Media not found"))
return
}
if success, response, status, err := authenticateMedia(media, db, r); !success {
if err != nil {
log.Warn(r.Context(), "Unauthorized access to photo", "reason", err)
}
w.WriteHeader(status)
w.Write([]byte(response))
return
}
cachedPath, err := mediaURL.CachedPath()
if err != nil {
log.Error(r.Context(), "error getting cached path for media URL", "error", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
if _, err := os.Stat(cachedPath); os.IsNotExist((err)) {
// err := db.Transaction(func(tx *gorm.DB) error {
if err = scanner.ProcessSingleMediaFunc(r.Context(), db, media); err != nil {
log.Error(r.Context(), "processing image not found in cache",
"media_cache_path", cachedPath,
"error", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
if _, err = os.Stat(cachedPath); err != nil {
log.Error(r.Context(), "after reprocessing image not found in cache",
"media_cache_path", cachedPath,
"error", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
}
// Allow caching the resource
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
if mediaURL.ContentType != "" {
w.Header().Set("Content-Type", mediaURL.ContentType)
}
http.ServeFile(w, r, cachedPath)
})
}
+124
View File
@@ -0,0 +1,124 @@
package routes
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/photoview/photoview/api/graphql/auth"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/scanner"
"github.com/photoview/photoview/api/test_utils"
"github.com/photoview/photoview/api/utils"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
)
func TestPhotoRoutes(t *testing.T) {
db := test_utils.DatabaseTest(t)
user, err := models.RegisterUser(db, "testuser", nil, false)
assert.NoError(t, err)
album := models.Album{Title: "test_album", Path: "/photos"}
assert.NoError(t, db.Model(&user).Association("Albums").Append(&album))
media := models.Media{
Title: "test_media",
Path: "/photos/test_image.jpg",
AlbumID: album.ID,
DateShot: time.Now(),
Type: models.MediaTypePhoto,
}
assert.NoError(t, db.Save(&media).Error)
mediaURL := models.MediaURL{
MediaID: media.ID,
Media: &media,
MediaName: "test_image.jpg",
Width: 1000,
Height: 800,
Purpose: models.PhotoThumbnail,
ContentType: "image/jpeg",
FileSize: 1024,
}
assert.NoError(t, db.Save(&mediaURL).Error)
tempDir := t.TempDir()
orig := utils.MediaCachePath()
utils.ConfigureTestCache(tempDir)
defer utils.ConfigureTestCache(orig)
router := mux.NewRouter()
RegisterPhotoRoutes(db, router)
// -- Test cases --
// Non-existent media_name => 404 (no auth required)
t.Run("media not found", func(t *testing.T) {
req := httptest.NewRequest("GET", "/does_not_exist.jpg", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Equal(t, "404 - Media not found", rec.Body.String())
})
// Missing auth => 403
t.Run("auth failure", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test_image.jpg", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Contains(t, rec.Body.String(), "unauthorized")
})
// Cache miss + scan error => 500
t.Run("scan failure yields 500", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test_image.jpg", nil)
ctx := auth.AddUserToContext(req.Context(), user)
req = req.WithContext(ctx)
// ensure no cached file
cachedPath, err := mediaURL.CachedPath()
assert.NoError(t, err)
os.Remove(cachedPath)
// mock scan to fail
origScan := scanner.ProcessSingleMediaFunc
scanner.ProcessSingleMediaFunc = func(ctx context.Context, db *gorm.DB, m *models.Media) error {
return fmt.Errorf("scan error")
}
defer func() { scanner.ProcessSingleMediaFunc = origScan }()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Contains(t, rec.Body.String(), "server error")
})
// Cache hit => 200 with correct body and headers
t.Run("cache hit serves file", func(t *testing.T) {
req := httptest.NewRequest("GET", "/test_image.jpg", nil)
ctx := auth.AddUserToContext(req.Context(), user)
req = req.WithContext(ctx)
// pre-create cached file
cachedPath, err := mediaURL.CachedPath()
assert.NoError(t, err)
assert.NoError(t, os.MkdirAll(path.Dir(cachedPath), 0755))
content := []byte("cached-binary")
assert.NoError(t, os.WriteFile(cachedPath, content, 0644))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "cached-binary", rec.Body.String())
assert.Equal(t, "private, max-age=31536000, immutable", rec.Header().Get("Cache-Control"))
assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type"))
})
}
+11
View File
@@ -0,0 +1,11 @@
package routes_test
import (
"testing"
"github.com/photoview/photoview/api/test_utils"
)
func TestMain(m *testing.M) {
test_utils.IntegrationTestRun(m)
}
+274
View File
@@ -0,0 +1,274 @@
package routes
import (
"fmt"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/photoview/photoview/api/log"
)
// SpaHandler implements the http.Handler interface, so we can use it
// to respond to HTTP requests. The path to the static directory and
// path to the index file within that static directory are used to
// serve the SPA in the given static directory.
type SpaHandler struct {
staticPath string
indexPath string
}
func NewSpaHandler(staticPath string, indexPath string) (SpaHandler, error) {
// Keeping this validation here just because we need the absolute static path anyway
staticPathAbs, err := filepath.Abs(staticPath)
if err != nil {
return SpaHandler{}, fmt.Errorf("static path %s is not valid: %w", staticPath, err)
}
if err := validPath(staticPathAbs, true); err != nil {
return SpaHandler{}, fmt.Errorf("static path %s validation error: %w", staticPath, err)
}
if err := validPath(filepath.Join(staticPathAbs, indexPath), false); err != nil {
return SpaHandler{}, fmt.Errorf("index path %s validation error: %w", indexPath, err)
}
return SpaHandler{
indexPath: indexPath,
staticPath: staticPathAbs,
}, nil
}
// ServeHTTP inspects the URL path to locate a file within the static dir
// on the SPA handler. If a file is found, it will be served. If not, the
// file located at the index path on the SPA handler will be served. This
// is suitable behavior for serving an SPA (single page application).
// Pre-compressed files (.br, .zst, .gz) are served if the client supports
// them, otherwise the original file is served.
func (h SpaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
relPath := strings.TrimPrefix(filepath.Clean(r.URL.Path), "/")
fullPath := filepath.Join(h.staticPath, relPath)
r = r.WithContext(log.WithAttrs(r.Context(), "static_path", h.staticPath, "requested_path", r.URL.Path))
// Special case: root path should serve index.html
if relPath == "" {
h.serveIndexHTML(w, r)
return
}
absPath, err := filepath.Abs(fullPath)
if err != nil {
log.Error(r.Context(), "error building absolute path", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
rel, err := filepath.Rel(h.staticPath, absPath)
if err != nil || strings.Contains(rel, "..") {
log.Error(r.Context(), "requested path is outside of static path", "error", err)
http.Error(w, "Invalid request URI", http.StatusBadRequest)
return
}
// Check if the original file exists
if _, err := os.Stat(absPath); os.IsNotExist(err) {
// File doesn't exist - let serveOriginal handle SPA routing
h.serveOriginal(w, r, fullPath, relPath)
return
}
// Don't compress already-compressed files
if isCompressedFormat(filepath.Ext(absPath)) {
h.serveOriginal(w, r, fullPath, relPath)
return
}
// Try to serve pre-compressed file
if h.servePrecompressedFile(w, r, fullPath, relPath) {
return
}
// Fallback to original SPA handler logic
h.serveOriginal(w, r, fullPath, relPath)
}
func (h SpaHandler) serveOriginal(w http.ResponseWriter, r *http.Request, fullPath string, relPath string) {
// Check whether a file exists at the given path
_, err := os.Stat(fullPath)
if os.IsNotExist(err) {
// File does not exist, serve index.html (SPA routing)
h.serveIndexHTML(w, r)
return
}
if err != nil {
// If we got an error (that wasn't that the file doesn't exist) stating the file,
// return a 500 internal server error and stop
log.Error(r.Context(), "Error stating file, requested by client", "file_path", fullPath, "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Set cache headers
h.setCacheHeaders(w, relPath)
// Use http.FileServer to serve the static file with no compression
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
}
// servePrecompressedFile attempts to serve a pre-compressed variant of the file
// Returns true if a pre-compressed file was served, false otherwise
func (h SpaHandler) servePrecompressedFile(w http.ResponseWriter, r *http.Request, fullPath string, relPath string) bool {
// Parse Accept-Encoding header
acceptEncoding := filterZeroQuality(strings.ToLower(r.Header.Get("Accept-Encoding")))
if acceptEncoding == "" {
return false
}
// Check for pre-compressed variants in order of preference (br > zstd > gzip)
encodings := []struct {
name string
extension string
}{
{"br", ".br"},
{"zstd", ".zst"},
{"gzip", ".gz"},
}
for _, enc := range encodings {
if !strings.Contains(acceptEncoding, enc.name) {
continue
}
precompressedPath := fullPath + enc.extension
if stat, err := os.Stat(precompressedPath); err != nil || stat.IsDir() {
continue
}
// Detect Content-Type from the ORIGINAL file extension, not the compressed one
contentType := mime.TypeByExtension(filepath.Ext(fullPath))
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Content-Encoding", enc.name)
w.Header().Add("Vary", "Accept-Encoding")
// Set cache headers based on request path
h.setCacheHeaders(w, relPath)
// Serve pre-compressed file
http.ServeFile(w, r, precompressedPath)
return true
}
return false
}
// serveIndexHTML serves index.html with pre-compressed priority
func (h SpaHandler) serveIndexHTML(w http.ResponseWriter, r *http.Request) {
indexPath := filepath.Join(h.staticPath, h.indexPath)
// Try to serve pre-compressed index.html first
// Use full and relative paths to index.html instead of originally requested paths
if h.servePrecompressedFile(w, r, indexPath, h.indexPath) {
return
}
// Fallback to uncompressed index.html
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
// Index file doesn't exist - this is a serious configuration error, not a regular 404
log.Error(r.Context(), "Error: index.html not found", "index_path", indexPath)
http.Error(w, "Application index file not found", http.StatusInternalServerError)
return
}
h.setCacheHeaders(w, h.indexPath)
http.ServeFile(w, r, indexPath)
}
// setCacheHeaders sets appropriate cache headers based on the request path
func (h SpaHandler) setCacheHeaders(w http.ResponseWriter, relPath string) {
if strings.HasPrefix(relPath, "assets/") {
// Long-term cache for fingerprinted assets
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// Short cache with revalidation for other files
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
}
}
// validPath validates the given path string.
//
// Parameters:
// - pathStr: The path to validate.
// - isDir: A boolean indicating whether the path is expected to be a directory (true) or a file (false).
//
// Returns:
// - error: An error if the path is invalid, or nil if the path is valid.
func validPath(pathStr string, isDir bool) error {
pathAbs, err := filepath.Abs(pathStr)
if err != nil {
return fmt.Errorf("path %s is not valid: %w", pathStr, err)
}
stat, err := os.Stat(pathAbs)
if os.IsNotExist(err) {
return fmt.Errorf("path %s does not exist", pathAbs)
}
if os.IsPermission(err) {
return fmt.Errorf("no permission to access path %s", pathAbs)
}
if err != nil {
return fmt.Errorf("error accessing path %s: %w", pathAbs, err)
}
if isDir && !stat.IsDir() {
return fmt.Errorf("path %s is not a directory", pathAbs)
}
if !isDir && stat.IsDir() {
return fmt.Errorf("path %s is a directory, must be a file", pathAbs)
}
return nil
}
func isCompressedFormat(ext string) bool {
ext = strings.ToLower(ext)
compressedExts := []string{
".png", ".ico", ".jpg", ".jpeg", ".gif", ".webp",
".zip", ".gz", ".br", ".zst",
".woff", ".woff2",
}
for _, compExt := range compressedExts {
if ext == compExt {
return true
}
}
return false
}
// filterZeroQuality removes encodings with q=0 or q=0.0 from Accept-Encoding header
func filterZeroQuality(acceptEncoding string) string {
// If no q params, return unchanged (fast path for most requests)
if !strings.Contains(acceptEncoding, "q=") {
return acceptEncoding
}
var filtered []string
parts := strings.Split(acceptEncoding, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Check if this encoding has q=0 or q=0.0
normalized := strings.ReplaceAll(part, " ", "")
if strings.HasSuffix(normalized, ";q=0") || strings.HasSuffix(normalized, ";q=0.0") {
continue
}
filtered = append(filtered, part)
}
return strings.Join(filtered, ",")
}
+554
View File
@@ -0,0 +1,554 @@
package routes
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSpaHandler(t *testing.T) {
t.Run("valid paths", func(t *testing.T) {
tempDir := t.TempDir()
indexPath := filepath.Join(tempDir, "index.html")
require.NoError(t, os.WriteFile(indexPath, []byte("index content"), 0644))
handler, err := NewSpaHandler(tempDir, "index.html")
assert.NoError(t, err)
assert.NotEmpty(t, handler.staticPath)
assert.NotEmpty(t, handler.indexPath)
})
t.Run("invalid static path", func(t *testing.T) {
handler, err := NewSpaHandler("/nonexistent/path", "index.html")
assert.Error(t, err)
assert.Empty(t, handler.staticPath)
assert.Empty(t, handler.indexPath)
})
t.Run("static path is not a directory", func(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "file.txt")
require.NoError(t, os.WriteFile(filePath, []byte("content"), 0644))
handler, err := NewSpaHandler(filePath, "index.html")
assert.Error(t, err)
assert.Empty(t, handler.staticPath)
assert.Empty(t, handler.indexPath)
})
t.Run("index path does not exist", func(t *testing.T) {
tempDir := t.TempDir()
handler, err := NewSpaHandler(tempDir, "nonexistent.html")
assert.Error(t, err)
assert.Empty(t, handler.staticPath)
assert.Empty(t, handler.indexPath)
})
t.Run("index path is a directory", func(t *testing.T) {
tempDir := t.TempDir()
indexDir := filepath.Join(tempDir, "indexdir")
require.NoError(t, os.Mkdir(indexDir, 0755))
handler, err := NewSpaHandler(tempDir, "indexdir")
assert.Error(t, err)
assert.Empty(t, handler.staticPath)
assert.Empty(t, handler.indexPath)
})
}
func TestSpaHandler_ServeHTTP(t *testing.T) {
// Setup test directory structure
tempDir := t.TempDir()
// Create index.html
indexPath := filepath.Join(tempDir, "index.html")
require.NoError(t, os.WriteFile(indexPath, []byte("<!DOCTYPE html><html>index</html>"), 0644))
// Create assets directory
assetsDir := filepath.Join(tempDir, "assets")
require.NoError(t, os.Mkdir(assetsDir, 0755))
// Create test files
jsFile := filepath.Join(assetsDir, "app.js")
require.NoError(t, os.WriteFile(jsFile, []byte("console.log('app');"), 0644))
// Create pre-compressed variants
require.NoError(t, os.WriteFile(jsFile+".br", []byte("compressed-br"), 0644))
require.NoError(t, os.WriteFile(jsFile+".zst", []byte("compressed-zst"), 0644))
require.NoError(t, os.WriteFile(jsFile+".gz", []byte("compressed-gz"), 0644))
// Create an image file (should not be compressed)
imgFile := filepath.Join(assetsDir, "logo.png")
require.NoError(t, os.WriteFile(imgFile, []byte("PNG data"), 0644))
handler, err := NewSpaHandler(tempDir, "index.html")
require.NoError(t, err)
require.NotEmpty(t, handler.staticPath)
t.Run("serve root returns index.html", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "index")
})
t.Run("serve existing file", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "console.log")
})
t.Run("serve non-existent file returns index.html (SPA routing)", func(t *testing.T) {
req := httptest.NewRequest("GET", "/some/spa/route", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "index")
})
t.Run("path traversal attack prevented", func(t *testing.T) {
req := httptest.NewRequest("GET", "/../../../etc/passwd", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
t.Run("in-tree traversal redirected", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/../index.html", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusMovedPermanently, rec.Code)
})
t.Run("empty handler configuration returns 500", func(t *testing.T) {
emptyHandler := SpaHandler{}
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
emptyHandler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
})
}
func TestSpaHandler_PrecompressedFiles(t *testing.T) {
tempDir := t.TempDir()
// Create index.html
indexPath := filepath.Join(tempDir, "index.html")
require.NoError(t, os.WriteFile(indexPath, []byte("index"), 0644))
// Create assets directory
assetsDir := filepath.Join(tempDir, "assets")
require.NoError(t, os.Mkdir(assetsDir, 0755))
// Create test file with all compression variants
jsFile := filepath.Join(assetsDir, "app.js")
jsContent := []byte("console.log('original');")
require.NoError(t, os.WriteFile(jsFile, jsContent, 0644))
require.NoError(t, os.WriteFile(jsFile+".br", []byte("br-compressed"), 0644))
require.NoError(t, os.WriteFile(jsFile+".zst", []byte("zst-compressed"), 0644))
require.NoError(t, os.WriteFile(jsFile+".gz", []byte("gz-compressed"), 0644))
handler, err := NewSpaHandler(tempDir, "index.html")
require.NoError(t, err)
t.Run("serve brotli when accepted", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "br", rec.Header().Get("Content-Encoding"))
assert.Contains(t, rec.Header().Get("Vary"), "Accept-Encoding")
assert.Contains(t, rec.Header().Get("Content-Type"), "javascript")
assert.Equal(t, "br-compressed", rec.Body.String())
})
t.Run("serve zstd when br not accepted", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", "gzip, zstd")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "zstd", rec.Header().Get("Content-Encoding"))
assert.Contains(t, rec.Header().Get("Vary"), "Accept-Encoding")
assert.Contains(t, rec.Header().Get("Content-Type"), "javascript")
assert.Equal(t, "zst-compressed", rec.Body.String())
})
t.Run("serve gzip when only gzip accepted", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
assert.Contains(t, rec.Header().Get("Vary"), "Accept-Encoding")
assert.Contains(t, rec.Header().Get("Content-Type"), "javascript")
assert.Equal(t, "gz-compressed", rec.Body.String())
})
t.Run("serve uncompressed when no encoding accepted", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Contains(t, rec.Body.String(), "original")
})
t.Run("serve original when pre-compressed file missing", func(t *testing.T) {
// Create file without pre-compressed variants
cssFile := filepath.Join(assetsDir, "style.css")
require.NoError(t, os.WriteFile(cssFile, []byte("body { margin: 0; }"), 0644))
req := httptest.NewRequest("GET", "/assets/style.css", nil)
req.Header.Set("Accept-Encoding", "gzip, br")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Contains(t, rec.Body.String(), "margin")
})
t.Run("do not compress already-compressed formats", func(t *testing.T) {
pngFile := filepath.Join(assetsDir, "logo.png")
require.NoError(t, os.WriteFile(pngFile, []byte("PNG data"), 0644))
require.NoError(t, os.WriteFile(pngFile+".br", []byte("should-not-serve"), 0644))
req := httptest.NewRequest("GET", "/assets/logo.png", nil)
req.Header.Set("Accept-Encoding", "br")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Equal(t, "PNG data", rec.Body.String())
})
}
func TestSpaHandler_CacheHeaders(t *testing.T) {
tempDir := t.TempDir()
indexPath := filepath.Join(tempDir, "index.html")
require.NoError(t, os.WriteFile(indexPath, []byte("index"), 0644))
assetsDir := filepath.Join(tempDir, "assets")
require.NoError(t, os.Mkdir(assetsDir, 0755))
jsFile := filepath.Join(assetsDir, "app.js")
require.NoError(t, os.WriteFile(jsFile, []byte("console.log('app');"), 0644))
otherFile := filepath.Join(tempDir, "manifest.json")
require.NoError(t, os.WriteFile(otherFile, []byte("{}"), 0644))
handler, err := NewSpaHandler(tempDir, "index.html")
require.NoError(t, err)
t.Run("long-term cache for assets", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "public, max-age=31536000, immutable", rec.Header().Get("Cache-Control"))
})
t.Run("short cache with revalidation for non-assets", func(t *testing.T) {
req := httptest.NewRequest("GET", "/manifest.json", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "public, max-age=3600, must-revalidate", rec.Header().Get("Cache-Control"))
})
t.Run("cache headers for index.html", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "public, max-age=3600, must-revalidate", rec.Header().Get("Cache-Control"))
})
}
func TestSpaHandler_IndexHTML(t *testing.T) {
tempDir := t.TempDir()
indexPath := filepath.Join(tempDir, "index.html")
indexContent := []byte("<!DOCTYPE html><html>index</html>")
require.NoError(t, os.WriteFile(indexPath, indexContent, 0644))
// Create pre-compressed index.html
require.NoError(t, os.WriteFile(indexPath+".br", []byte("index-br-compressed"), 0644))
handler, err := NewSpaHandler(tempDir, "index.html")
require.NoError(t, err)
t.Run("serve pre-compressed index.html when accepted", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("Accept-Encoding", "br")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "br", rec.Header().Get("Content-Encoding"))
assert.Equal(t, "index-br-compressed", rec.Body.String())
})
t.Run("serve uncompressed index.html when encoding not accepted", func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Content-Encoding"))
assert.Contains(t, rec.Body.String(), "index")
})
t.Run("SPA routing serves index.html for non-existent paths", func(t *testing.T) {
req := httptest.NewRequest("GET", "/app/users/123", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "index")
})
}
func TestIsCompressedFormat(t *testing.T) {
tests := []struct {
name string
ext string
expected bool
}{
{"png", ".png", true},
{"jpg", ".jpg", true},
{"jpeg", ".jpeg", true},
{"gif", ".gif", true},
{"webp", ".webp", true},
{"ico", ".ico", true},
{"zip", ".zip", true},
{"gz", ".gz", true},
{"br", ".br", true},
{"zst", ".zst", true},
{"woff", ".woff", true},
{"woff2", ".woff2", true},
{"js", ".js", false},
{"css", ".css", false},
{"html", ".html", false},
{"json", ".json", false},
{"txt", ".txt", false},
{"uppercase PNG", ".PNG", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isCompressedFormat(tt.ext)
assert.Equal(t, tt.expected, result)
})
}
}
func TestFilterZeroQuality(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "no q params (fast path)",
input: "gzip, deflate, br",
expected: "gzip, deflate, br",
},
{
name: "filter q=0",
input: "gzip;q=1.0, deflate;q=0, br;q=0.8",
expected: "gzip;q=1.0,br;q=0.8",
},
{
name: "filter q=0.0",
input: "gzip;q=0.0, br;q=1",
expected: "br;q=1",
},
{
name: "filter with spaces",
input: "gzip; q=0.0, deflate; q=1.0, br",
expected: "deflate; q=1.0,br",
},
{
name: "all encodings filtered",
input: "gzip;q=0, deflate;q=0.0",
expected: "",
},
{
name: "empty input",
input: "",
expected: "",
},
{
name: "q=0 but others present",
input: "identity;q=0, gzip, br",
expected: "gzip,br",
},
{
name: "multiple spaces",
input: "gzip; q=0, br; q=1",
expected: "br; q=1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := filterZeroQuality(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestSpaHandler_AcceptEncodingEdgeCases(t *testing.T) {
tempDir := t.TempDir()
indexPath := filepath.Join(tempDir, "index.html")
require.NoError(t, os.WriteFile(indexPath, []byte("index"), 0644))
assetsDir := filepath.Join(tempDir, "assets")
require.NoError(t, os.Mkdir(assetsDir, 0755))
jsFile := filepath.Join(assetsDir, "app.js")
require.NoError(t, os.WriteFile(jsFile, []byte("original"), 0644))
require.NoError(t, os.WriteFile(jsFile+".br", []byte("br-compressed"), 0644))
require.NoError(t, os.WriteFile(jsFile+".gz", []byte("gz-compressed"), 0644))
handler, err := NewSpaHandler(tempDir, "index.html")
require.NoError(t, err)
t.Run("reject encoding with q=0", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", "br;q=0, gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Should serve gzip since br is rejected with q=0
assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"))
})
t.Run("case insensitive encoding names", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", "BR, GZIP")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "br", rec.Header().Get("Content-Encoding"))
})
t.Run("wildcard encoding", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", "*")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// Wildcard doesn't match our specific encodings
assert.Empty(t, rec.Header().Get("Content-Encoding"))
})
t.Run("complex Accept-Encoding with quality values", func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/app.js", nil)
req.Header.Set("Accept-Encoding", "gzip;q=0.8, br;q=1.0, deflate;q=0.5")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// br has highest quality and should be preferred
assert.Equal(t, "br", rec.Header().Get("Content-Encoding"))
})
}
func TestSpaHandler_ContentType(t *testing.T) {
tempDir := t.TempDir()
indexPath := filepath.Join(tempDir, "index.html")
require.NoError(t, os.WriteFile(indexPath, []byte("index"), 0644))
assetsDir := filepath.Join(tempDir, "assets")
require.NoError(t, os.Mkdir(assetsDir, 0755))
// Create files with various extensions
files := map[string]string{
"app.js": "text/javascript",
"style.css": "text/css",
"data.json": "application/json",
"image.svg": "image/svg+xml",
"unknown.xyz": "", // Unknown extension
}
for filename := range files {
filePath := filepath.Join(assetsDir, filename)
require.NoError(t, os.WriteFile(filePath, []byte("content"), 0644))
require.NoError(t, os.WriteFile(filePath+".br", []byte("compressed"), 0644))
}
handler, err := NewSpaHandler(tempDir, "index.html")
require.NoError(t, err)
for filename, expectedType := range files {
t.Run(filename, func(t *testing.T) {
req := httptest.NewRequest("GET", "/assets/"+filename, nil)
req.Header.Set("Accept-Encoding", "br")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "br", rec.Header().Get("Content-Encoding"))
if expectedType != "" {
assert.Contains(t, rec.Header().Get("Content-Type"), expectedType)
}
})
}
}
+137
View File
@@ -0,0 +1,137 @@
package routes
import (
"context"
"net/http"
"os"
"path"
"strconv"
"github.com/gorilla/mux"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/log"
"github.com/photoview/photoview/api/scanner"
"github.com/photoview/photoview/api/utils"
"github.com/pkg/errors"
"gorm.io/gorm"
)
var processSingleMediaFn = func(ctx context.Context, db *gorm.DB, media *models.Media) error {
return scanner.ProcessSingleMedia(ctx, db, media)
}
func handleVideoRequest(
w http.ResponseWriter,
r *http.Request,
db *gorm.DB,
mediaName string,
authenticateFn func(*models.Media, *gorm.DB, *http.Request) (bool, string, int, error),
getCachePathFn func(albumID, mediaID int, filename string) string,
) {
var mediaURLs []models.MediaURL
if err := db.Model(&models.MediaURL{}).
Preload("Media").
Where("media_urls.media_name = ? AND media_urls.purpose = ?", mediaName, models.VideoWeb).
Order("created_at DESC").
Find(&mediaURLs).
Error; err != nil || len(mediaURLs) == 0 || mediaURLs[0].Media == nil {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("not found"))
return
}
if len(mediaURLs) > 1 {
log.Warn(r.Context(), "Multiple video web URLs found",
"name", mediaName,
"count", len(mediaURLs),
"using", mediaURLs[0],
)
}
mediaURL := mediaURLs[0]
var media = mediaURL.Media
if success, response, status, err := authenticateFn(media, db, r); !success {
if err != nil {
log.Warn(r.Context(), "got error authenticating video",
"error", err,
"media ID", media.ID,
"media path", media.Path)
}
w.WriteHeader(status)
w.Write([]byte(response))
return
}
var cachedPath string
if mediaURL.Purpose == models.VideoWeb {
// Use the provided cache path function
cachedPath = getCachePathFn(int(media.AlbumID), int(mediaURL.MediaID), mediaURL.MediaName)
} else {
log.Error(r.Context(), "Can not handle media_purpose for video",
"purpose", mediaURL.Purpose,
"expected", models.VideoWeb)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
if _, err := os.Stat(cachedPath); err != nil {
if !os.IsNotExist(err) {
log.Error(r.Context(), "cached video access error",
"error", err,
"media ID", media.ID,
"media path", media.Path)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
if err := processSingleMediaFn(r.Context(), db, media); err != nil {
// Check if error was due to context cancellation
if r.Context().Err() != nil && errors.Is(r.Context().Err(), context.Canceled) {
log.Warn(r.Context(), "video processing cancelled due to client disconnect",
"mediaID", media.ID,
"reason", r.Context().Err())
return // Don't send response if client disconnected
}
log.Error(r.Context(), "processing video not found in cache",
"error", err,
"media ID", media.ID,
"media path", media.Path)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
if _, err := os.Stat(cachedPath); err != nil {
log.Error(r.Context(), "video not found in cache after reprocessing",
"error", err,
"media ID", media.ID,
"media path", media.Path)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(internalServerError))
return
}
}
// Allow caching the resource
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("Content-Type", mediaURL.ContentType)
http.ServeFile(w, r, cachedPath)
}
func generateCacheFilename(albumID, mediaID int, filename string) string {
return path.Join(utils.MediaCachePath(), strconv.Itoa(albumID), strconv.Itoa(mediaID), filename)
}
func RegisterVideoRoutes(db *gorm.DB, router *mux.Router) {
router.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) {
mediaName := mux.Vars(r)["name"]
handleVideoRequest(w, r, db, mediaName, authenticateMedia, generateCacheFilename)
})
}
+471
View File
@@ -0,0 +1,471 @@
package routes
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/photoview/photoview/api/graphql/models"
"github.com/photoview/photoview/api/graphql/models/actions"
"github.com/photoview/photoview/api/test_utils"
"github.com/photoview/photoview/api/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
// setTestCachePath temporarily sets a different media cache path for testing
// and returns a function to restore the original state
func setTestCachePath(tempPath string) func() {
original := utils.GetTestCachePath()
utils.ConfigureTestCache(tempPath)
return func() {
utils.ConfigureTestCache(original)
}
}
// mockProcessSingleMedia replaces scanner.ProcessSingleMedia with a mock function during tests
// and returns a function to restore the original implementation
var originalProcessSingleMedia = processSingleMediaFn
func mockProcessSingleMedia(t *testing.T, shouldSucceed bool, mediaID int, albumID int) func() {
// Save original implementation
savedFn := processSingleMediaFn
// Replace with mock implementation
processSingleMediaFn = func(ctx context.Context, db *gorm.DB, media *models.Media) error {
// Check if context is already cancelled before starting work
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if shouldSucceed {
// On success: create the expected video file in cache
var mediaURLs []models.MediaURL
if err := db.Where("media_id = ? AND purpose = ?", media.ID, models.VideoWeb).
Find(&mediaURLs).Error; err != nil {
return err
}
if len(mediaURLs) == 0 {
return fmt.Errorf("no media URLs found")
}
// Get the cache path
tempCachePath := utils.GetTestCachePath()
albumDir := filepath.Join(tempCachePath, strconv.Itoa(albumID))
mediaDir := filepath.Join(albumDir, strconv.Itoa(mediaID))
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return err
}
videoPath := filepath.Join(mediaDir, mediaURLs[0].MediaName)
if err := os.WriteFile(videoPath, []byte("mocked processed video content"), 0644); err != nil {
return fmt.Errorf("failed to write mock video file: %w", err)
}
return nil
}
// On failure: return an error
return fmt.Errorf("mock processing error")
}
// Return cleanup function
return func() {
processSingleMediaFn = savedFn
}
}
func registerMockVideoRoutesForTesting(db *gorm.DB, router *mux.Router, tempCachePath string) {
router.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) {
mediaName := mux.Vars(r)["name"]
// Use no-op auth and test cache path
handleVideoRequest(
w, r, db, mediaName,
// Skip authentication for tests
func(media *models.Media, db *gorm.DB, r *http.Request) (bool, string, int, error) {
return true, "success", http.StatusOK, nil
},
// Use test cache path
func(albumID, mediaID int, filename string) string {
return path.Join(tempCachePath, strconv.Itoa(albumID), strconv.Itoa(mediaID), filename)
},
)
})
}
// createTestResources creates all the necessary test resources for a single test case
// and returns cleanup functions to be called with t.Cleanup()
func createTestResources(t *testing.T, db *gorm.DB, testID string) (
*models.User,
*models.Album,
*models.Media,
*models.MediaURL,
string, // mediaName
string, // cachePath
string, // shareToken
string, // tokenPassword
) {
// Create test user with unique username
user := &models.User{
Username: fmt.Sprintf("testuser-%s", testID),
}
require.NoError(t, db.Create(user).Error)
t.Cleanup(func() {
db.Unscoped().Delete(user)
})
// Create test album with unique title and path
album := &models.Album{
Title: fmt.Sprintf("Test Album %s", testID),
Path: fmt.Sprintf("/test/album/path/%s", testID),
}
require.NoError(t, db.Create(album).Error)
t.Cleanup(func() {
db.Unscoped().Delete(album)
})
// Establish ownership via many-to-many relationship
require.NoError(t, db.Model(album).Association("Owners").Append(user))
t.Cleanup(func() {
db.Model(album).Association("Owners").Clear()
})
// Create unique media name for this test
mediaName := fmt.Sprintf("video-%s.mp4", testID)
// Create media with VideoWeb purpose
media := &models.Media{
Title: fmt.Sprintf("Test Video %s", testID),
Path: filepath.Join(t.TempDir(), mediaName),
PathHash: fmt.Sprintf("testhash-%s", testID),
AlbumID: album.ID,
Album: *album,
DateShot: time.Now(),
Type: "video",
}
require.NoError(t, db.Create(media).Error)
t.Cleanup(func() {
db.Unscoped().Delete(media)
})
// Create media URL entry
mediaURL := &models.MediaURL{
MediaID: media.ID,
Media: media,
MediaName: mediaName,
Width: 1920,
Height: 1080,
Purpose: models.VideoWeb,
ContentType: "video/mp4",
FileSize: 1024,
}
require.NoError(t, db.Create(mediaURL).Error)
t.Cleanup(func() {
db.Unscoped().Delete(mediaURL)
})
// Create a unique cache path for this test
cachePath := filepath.Join(t.TempDir(), fmt.Sprintf("cache-%s", testID))
require.NoError(t, os.MkdirAll(cachePath, 0755))
t.Cleanup(func() {
os.RemoveAll(cachePath)
})
// Prepare share token for auth tests
tokenPassword := fmt.Sprintf("secret-password-%s", testID)
expiry := time.Now().Add(24 * time.Hour)
shareToken, err := actions.AddMediaShare(db, user, media.ID, &expiry, &tokenPassword)
require.NoError(t, err)
t.Cleanup(func() {
db.Unscoped().Delete(shareToken)
})
return user, album, media, mediaURL, mediaName, cachePath, shareToken.Value, tokenPassword
}
func TestVideoRoutes(t *testing.T) {
// Ensure original function is always restored
defer func() {
processSingleMediaFn = originalProcessSingleMedia
}()
// Setup test database
db := test_utils.DatabaseTest(t)
// Define test cases
testCases := []struct {
name string
testFunc func(*testing.T, *gorm.DB)
}{
{
name: "Valid video retrieval",
testFunc: func(t *testing.T, db *gorm.DB) {
// Create unique resources for this test
_, album, media, _, mediaName, cachePath, _, _ := createTestResources(t, db, "valid")
// Setup cache path for this test
restorePath := setTestCachePath(cachePath)
t.Cleanup(restorePath)
// Create cache directory and file
albumDir := filepath.Join(cachePath, strconv.Itoa(int(album.ID)))
mediaDir := filepath.Join(albumDir, strconv.Itoa(int(media.ID)))
require.NoError(t, os.MkdirAll(mediaDir, 0755))
videoPath := filepath.Join(mediaDir, mediaName)
require.NoError(t, os.WriteFile(videoPath, []byte("test video content"), 0644))
// Create mock router without auth for this test
router := mux.NewRouter()
registerMockVideoRoutesForTesting(db, router, cachePath)
// Make request
req := httptest.NewRequest("GET", "/"+mediaName, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
// Validate response
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "test video content", rr.Body.String())
},
},
{
name: "Video not found",
testFunc: func(t *testing.T, db *gorm.DB) {
// Create unique resources for this test
_, _, _, _, _, cachePath, _, _ := createTestResources(t, db, "notfound")
// Setup cache path for this test
restorePath := setTestCachePath(cachePath)
t.Cleanup(restorePath)
// Create mock router without auth for this test
router := mux.NewRouter()
registerMockVideoRoutesForTesting(db, router, cachePath)
// Make request with nonexistent video name
req := httptest.NewRequest("GET", "/nonexistent.mp4", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
// Validate response
assert.Equal(t, http.StatusNotFound, rr.Code)
assert.Equal(t, "not found", rr.Body.String())
},
},
{
name: "Authentication with share token",
testFunc: func(t *testing.T, db *gorm.DB) {
// Create unique resources for this test
_, album, media, _, mediaName, cachePath, tokenValue, tokenPassword := createTestResources(t, db, "auth")
// Setup cache path for this test
restorePath := setTestCachePath(cachePath)
t.Cleanup(restorePath)
// Create the file in cache
albumDir := filepath.Join(cachePath, strconv.Itoa(int(album.ID)))
mediaDir := filepath.Join(albumDir, strconv.Itoa(int(media.ID)))
require.NoError(t, os.MkdirAll(mediaDir, 0755))
videoPath := filepath.Join(mediaDir, mediaName)
require.NoError(t, os.WriteFile(videoPath, []byte("test video content"), 0644))
// Create real router with auth for this test
router := mux.NewRouter()
RegisterVideoRoutes(db, router)
// Make request with token
req := httptest.NewRequest("GET", "/"+mediaName+"?token="+tokenValue, nil)
cookie := http.Cookie{
Name: fmt.Sprintf("share-token-pw-%s", tokenValue),
Value: tokenPassword,
}
req.AddCookie(&cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
// Validate response
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "test video content", rr.Body.String())
},
},
{
name: "Multiple media URLs with same name",
testFunc: func(t *testing.T, db *gorm.DB) {
// Create unique resources for this test
_, album, media, _, mediaName, cachePath, _, _ := createTestResources(t, db, "multiple")
// Setup cache path for this test
restorePath := setTestCachePath(cachePath)
t.Cleanup(restorePath)
// Create second mediaURL with same name
mediaURL2 := &models.MediaURL{
MediaID: media.ID,
Media: media,
MediaName: mediaName, // Same name
Width: 1280,
Height: 720,
Purpose: models.VideoWeb,
ContentType: "video/mp4",
FileSize: 512,
}
require.NoError(t, db.Create(mediaURL2).Error)
t.Cleanup(func() {
db.Unscoped().Delete(mediaURL2)
})
// Create cache directory and file
albumDir := filepath.Join(cachePath, strconv.Itoa(int(album.ID)))
mediaDir := filepath.Join(albumDir, strconv.Itoa(int(media.ID)))
require.NoError(t, os.MkdirAll(mediaDir, 0755))
videoPath := filepath.Join(mediaDir, mediaName)
require.NoError(t, os.WriteFile(videoPath, []byte("test video content"), 0644))
// Create mock router without auth for this test
router := mux.NewRouter()
registerMockVideoRoutesForTesting(db, router, cachePath)
// Make request
req := httptest.NewRequest("GET", "/"+mediaName, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
// Validate response
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "test video content", rr.Body.String())
},
},
{
name: "Video file not in cache, processing succeeds",
testFunc: func(t *testing.T, db *gorm.DB) {
// Create unique resources for this test
_, album, media, _, mediaName, cachePath, _, _ := createTestResources(t, db, "process-success")
// Setup cache path for this test
restorePath := setTestCachePath(cachePath)
t.Cleanup(restorePath)
// Ensure cache directory exists but file doesn't exist
albumDir := filepath.Join(cachePath, strconv.Itoa(int(album.ID)))
mediaDir := filepath.Join(albumDir, strconv.Itoa(int(media.ID)))
require.NoError(t, os.MkdirAll(mediaDir, 0755))
// Mock processing to succeed
restoreProcessingFn := mockProcessSingleMedia(t, true, int(media.ID), int(album.ID))
t.Cleanup(restoreProcessingFn)
// Create mock router without auth for this test
router := mux.NewRouter()
registerMockVideoRoutesForTesting(db, router, cachePath)
// Make request
req := httptest.NewRequest("GET", "/"+mediaName, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
// Validate response
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "mocked processed video content", rr.Body.String())
},
},
{
name: "Video file not in cache, processing fails",
testFunc: func(t *testing.T, db *gorm.DB) {
// Create unique resources for this test
_, album, media, _, mediaName, cachePath, _, _ := createTestResources(t, db, "process-fail")
// Setup cache path for this test
restorePath := setTestCachePath(cachePath)
t.Cleanup(restorePath)
// Ensure cache directory exists but file doesn't exist
albumDir := filepath.Join(cachePath, strconv.Itoa(int(album.ID)))
mediaDir := filepath.Join(albumDir, strconv.Itoa(int(media.ID)))
require.NoError(t, os.MkdirAll(mediaDir, 0755))
// Mock processing to fail
restoreProcessingFn := mockProcessSingleMedia(t, false, int(media.ID), int(album.ID))
t.Cleanup(restoreProcessingFn)
// Create mock router without auth for this test
router := mux.NewRouter()
registerMockVideoRoutesForTesting(db, router, cachePath)
// Make request
req := httptest.NewRequest("GET", "/"+mediaName, nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
// Validate response
assert.Equal(t, http.StatusInternalServerError, rr.Code)
},
},
{
name: "Context cancellation during processing",
testFunc: func(t *testing.T, db *gorm.DB) {
_, album, media, _, mediaName, cachePath, _, _ := createTestResources(t, db, "cancellation")
// Setup cache path for this test
restorePath := setTestCachePath(cachePath)
t.Cleanup(restorePath)
// Ensure cache directory exists but file doesn't exist to trigger processing
albumDir := filepath.Join(cachePath, strconv.Itoa(int(album.ID)))
mediaDir := filepath.Join(albumDir, strconv.Itoa(int(media.ID)))
require.NoError(t, os.MkdirAll(mediaDir, 0755))
// Create cancellable context
ctx, cancel := context.WithCancel(context.Background())
// Mock processing that simulates context cancellation
savedFn := processSingleMediaFn
processSingleMediaFn = func(reqCtx context.Context, db *gorm.DB, media *models.Media) error {
cancel()
return fmt.Errorf("processing interrupted by cancellation")
}
t.Cleanup(func() { processSingleMediaFn = savedFn })
// Create request with cancelled context
req := httptest.NewRequest("GET", "/video/"+mediaName, nil)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
// Use testing router without auth
mockRouter := mux.NewRouter().PathPrefix("/video").Subrouter()
registerMockVideoRoutesForTesting(db, mockRouter, cachePath)
mockRouter.ServeHTTP(w, req)
// When context is cancelled, processing should be cancelled
// 1. Status remains default 200 (no explicit status written)
assert.Equal(t, http.StatusOK, w.Code, "Status should remain default when context cancelled")
// 2. Response body should be empty (no video content served)
assert.Empty(t, w.Body.String(), "Response body should be empty when context cancelled")
},
},
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.testFunc(t, db)
})
}
}