mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Merge 5350d6b70c into 0cecb5287f
This commit is contained in:
commit
81459969a4
5 changed files with 527 additions and 121 deletions
168
docker/dummy/oidc/app/mock/client_test.go
Normal file
168
docker/dummy/oidc/app/mock/client_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package mock
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/op"
|
||||
)
|
||||
|
||||
func TestConfClientGetID(t *testing.T) {
|
||||
c := &ConfClient{ID: "test-client"}
|
||||
if got := c.GetID(); got != "test-client" {
|
||||
t.Fatalf("expected ID 'test-client', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientRedirectURIs(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
uris := c.RedirectURIs()
|
||||
if len(uris) == 0 {
|
||||
t.Fatal("expected non-empty redirect URIs")
|
||||
}
|
||||
// Check that localhost:2342 PhotoPrism callback is included
|
||||
found := false
|
||||
for _, uri := range uris {
|
||||
if uri == "http://localhost:2342/api/v1/oidc/redirect" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected PhotoPrism OIDC redirect URI in list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientPostLogoutRedirectURIs(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
uris := c.PostLogoutRedirectURIs()
|
||||
if len(uris) != 0 {
|
||||
t.Fatalf("expected empty post-logout redirect URIs, got %v", uris)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientLoginURL(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
url := c.LoginURL("abc123")
|
||||
expected := "login?id=abc123"
|
||||
if url != expected {
|
||||
t.Fatalf("expected login URL %q, got %q", expected, url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientApplicationType(t *testing.T) {
|
||||
tests := []struct {
|
||||
appType op.ApplicationType
|
||||
expected op.ApplicationType
|
||||
}{
|
||||
{op.ApplicationTypeWeb, op.ApplicationTypeWeb},
|
||||
{op.ApplicationTypeNative, op.ApplicationTypeNative},
|
||||
{op.ApplicationTypeUserAgent, op.ApplicationTypeUserAgent},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
c := &ConfClient{applicationType: tt.appType}
|
||||
if got := c.ApplicationType(); got != tt.expected {
|
||||
t.Fatalf("expected ApplicationType %v, got %v", tt.expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientAuthMethod(t *testing.T) {
|
||||
c := &ConfClient{authMethod: oidc.AuthMethodBasic}
|
||||
if got := c.AuthMethod(); got != oidc.AuthMethodBasic {
|
||||
t.Fatalf("expected AuthMethod %v, got %v", oidc.AuthMethodBasic, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientIDTokenLifetime(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
expected := 60 * time.Minute
|
||||
if got := c.IDTokenLifetime(); got != expected {
|
||||
t.Fatalf("expected IDTokenLifetime %v, got %v", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientAccessTokenType(t *testing.T) {
|
||||
c := &ConfClient{accessTokenType: op.AccessTokenTypeJWT}
|
||||
if got := c.AccessTokenType(); got != op.AccessTokenTypeJWT {
|
||||
t.Fatalf("expected AccessTokenType %v, got %v", op.AccessTokenTypeJWT, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientResponseTypes(t *testing.T) {
|
||||
expected := []oidc.ResponseType{oidc.ResponseTypeCode}
|
||||
c := &ConfClient{responseTypes: expected}
|
||||
got := c.ResponseTypes()
|
||||
if len(got) != len(expected) {
|
||||
t.Fatalf("expected %d response types, got %d", len(expected), len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientGrantTypes(t *testing.T) {
|
||||
expected := []oidc.GrantType{oidc.GrantTypeCode}
|
||||
c := &ConfClient{grantTypes: expected}
|
||||
got := c.GrantTypes()
|
||||
if len(got) != len(expected) {
|
||||
t.Fatalf("expected %d grant types, got %d", len(expected), len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientDevMode(t *testing.T) {
|
||||
c := &ConfClient{devMode: true}
|
||||
if !c.DevMode() {
|
||||
t.Fatal("expected DevMode to be true")
|
||||
}
|
||||
c.devMode = false
|
||||
if c.DevMode() {
|
||||
t.Fatal("expected DevMode to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientAllowedScopes(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
if got := c.AllowedScopes(); got != nil {
|
||||
t.Fatalf("expected nil AllowedScopes, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientRestrictAdditionalIdTokenScopes(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
fn := c.RestrictAdditionalIdTokenScopes()
|
||||
scopes := []string{"openid", "profile"}
|
||||
got := fn(scopes)
|
||||
if len(got) != len(scopes) {
|
||||
t.Fatalf("expected same scopes returned, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientRestrictAdditionalAccessTokenScopes(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
fn := c.RestrictAdditionalAccessTokenScopes()
|
||||
scopes := []string{"openid", "profile"}
|
||||
got := fn(scopes)
|
||||
if len(got) != len(scopes) {
|
||||
t.Fatalf("expected same scopes returned, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientIsScopeAllowed(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
if c.IsScopeAllowed("openid") {
|
||||
t.Fatal("expected IsScopeAllowed to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientIDTokenUserinfoClaimsAssertion(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
if c.IDTokenUserinfoClaimsAssertion() {
|
||||
t.Fatal("expected IDTokenUserinfoClaimsAssertion to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfClientClockSkew(t *testing.T) {
|
||||
c := &ConfClient{}
|
||||
if got := c.ClockSkew(); got != 0 {
|
||||
t.Fatalf("expected ClockSkew 0, got %v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/oidc/pkg/oidc"
|
||||
)
|
||||
|
||||
func TestAuthRequestResponseModeDefault(t *testing.T) {
|
||||
|
|
@ -14,50 +14,194 @@ func TestAuthRequestResponseModeDefault(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAuthRequestResponseModeCustom(t *testing.T) {
|
||||
req := &AuthRequest{ResponseMode: oidc.ResponseModeFragment}
|
||||
if got := req.GetResponseMode(); got != oidc.ResponseModeFragment {
|
||||
t.Fatalf("expected response mode %q, got %q", oidc.ResponseModeFragment, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequestGetters(t *testing.T) {
|
||||
req := &AuthRequest{
|
||||
ID: "test-id",
|
||||
ClientID: "test-client",
|
||||
Nonce: "test-nonce",
|
||||
RedirectURI: "https://example.com/callback",
|
||||
}
|
||||
|
||||
if got := req.GetID(); got != "test-id" {
|
||||
t.Fatalf("expected ID %q, got %q", "test-id", got)
|
||||
}
|
||||
if got := req.GetClientID(); got != "test-client" {
|
||||
t.Fatalf("expected ClientID %q, got %q", "test-client", got)
|
||||
}
|
||||
if got := req.GetNonce(); got != "test-nonce" {
|
||||
t.Fatalf("expected Nonce %q, got %q", "test-nonce", got)
|
||||
}
|
||||
if got := req.GetRedirectURI(); got != "https://example.com/callback" {
|
||||
t.Fatalf("expected RedirectURI %q, got %q", "https://example.com/callback", got)
|
||||
}
|
||||
if got := req.GetSubject(); got != "sub00000001" {
|
||||
t.Fatalf("expected Subject %q, got %q", "sub00000001", got)
|
||||
}
|
||||
if got := req.GetACR(); got != "" {
|
||||
t.Fatalf("expected empty ACR, got %q", got)
|
||||
}
|
||||
if len(req.GetAMR()) != 0 {
|
||||
t.Fatalf("expected empty AMR, got %v", req.GetAMR())
|
||||
}
|
||||
if !req.Done() {
|
||||
t.Fatal("expected Done() to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequestAudience(t *testing.T) {
|
||||
req := &AuthRequest{ClientID: "my-client"}
|
||||
aud := req.GetAudience()
|
||||
if len(aud) != 1 || aud[0] != "my-client" {
|
||||
t.Fatalf("expected audience [my-client], got %v", aud)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequestScopes(t *testing.T) {
|
||||
req := &AuthRequest{}
|
||||
scopes := req.GetScopes()
|
||||
expected := []string{"openid", "profile", "email"}
|
||||
if len(scopes) != len(expected) {
|
||||
t.Fatalf("expected %d scopes, got %d", len(expected), len(scopes))
|
||||
}
|
||||
for i, s := range expected {
|
||||
if scopes[i] != s {
|
||||
t.Fatalf("expected scope %q at index %d, got %q", s, i, scopes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthStorage(t *testing.T) {
|
||||
storage := NewAuthStorage()
|
||||
if storage == nil {
|
||||
t.Fatal("expected non-nil storage")
|
||||
}
|
||||
as, ok := storage.(*AuthStorage)
|
||||
if !ok {
|
||||
t.Fatal("expected *AuthStorage type")
|
||||
}
|
||||
if as.key == nil {
|
||||
t.Fatal("expected non-nil RSA key")
|
||||
}
|
||||
if as.kid == "" {
|
||||
t.Fatal("expected non-empty kid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageHealth(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
if err := storage.Health(context.Background()); err != nil {
|
||||
t.Fatalf("expected nil from Health(), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeTokenNoError(t *testing.T) {
|
||||
s := NewAuthStorage()
|
||||
if err := s.RevokeToken(context.Background(), "unknown-token", "user", "client"); err != nil {
|
||||
s := &AuthStorage{}
|
||||
if err := s.RevokeToken(
|
||||
context.TODO(),
|
||||
"token",
|
||||
"user",
|
||||
"client",
|
||||
); err != nil {
|
||||
t.Fatalf("expected nil error from RevokeToken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndFetchAuthRequest(t *testing.T) {
|
||||
s := NewAuthStorage()
|
||||
req, err := s.CreateAuthRequest(context.Background(), &oidc.AuthRequest{
|
||||
ClientID: "web",
|
||||
RedirectURI: "https://app.localssl.dev/api/v1/oidc/redirect",
|
||||
ResponseType: oidc.ResponseTypeCode,
|
||||
Scopes: []string{"openid", "email"},
|
||||
}, "")
|
||||
func TestAuthStorageGetKeySet(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
keySet, err := storage.GetKeySet(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAuthRequest: %v", err)
|
||||
t.Fatalf("unexpected error from GetKeySet: %v", err)
|
||||
}
|
||||
if req.GetID() == "" {
|
||||
t.Fatalf("expected non-empty auth request id")
|
||||
if keySet == nil {
|
||||
t.Fatal("expected non-nil key set")
|
||||
}
|
||||
|
||||
if err := s.MarkRequestDone(req.GetID()); err != nil {
|
||||
t.Fatalf("MarkRequestDone: %v", err)
|
||||
}
|
||||
|
||||
got, err := s.AuthRequestByID(context.Background(), req.GetID())
|
||||
if err != nil {
|
||||
t.Fatalf("AuthRequestByID: %v", err)
|
||||
}
|
||||
if !got.Done() {
|
||||
t.Fatalf("expected auth request to be marked done")
|
||||
if len(keySet.Keys) != 2 {
|
||||
t.Fatalf("expected 2 keys, got %d", len(keySet.Keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientDefaults(t *testing.T) {
|
||||
c := NewClient("anything")
|
||||
if c.GetID() != "anything" {
|
||||
t.Fatalf("unexpected id: %q", c.GetID())
|
||||
func TestAuthStorageGetKey(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
key, err := storage.GetKey(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error from GetKey: %v", err)
|
||||
}
|
||||
if !c.DevMode() {
|
||||
t.Fatalf("expected dev mode for permissive dummy clients")
|
||||
}
|
||||
if uris := c.RedirectURIs(); len(uris) == 0 {
|
||||
t.Fatalf("expected default redirect URIs")
|
||||
if key == nil {
|
||||
t.Fatal("expected non-nil key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageCreateAccessToken(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
tokenID, expiration, err := storage.CreateAccessToken(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tokenID != "loginId" {
|
||||
t.Fatalf("expected tokenID 'loginId', got %q", tokenID)
|
||||
}
|
||||
if expiration.IsZero() {
|
||||
t.Fatal("expected non-zero expiration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageCreateAccessAndRefreshTokens(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
accessID, refresh, expiration, err := storage.CreateAccessAndRefreshTokens(context.Background(), nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if accessID != "loginId" {
|
||||
t.Fatalf("expected accessID 'loginId', got %q", accessID)
|
||||
}
|
||||
if refresh != "refreshToken" {
|
||||
t.Fatalf("expected refresh 'refreshToken', got %q", refresh)
|
||||
}
|
||||
if expiration.IsZero() {
|
||||
t.Fatal("expected non-zero expiration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageTerminateSession(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
if err := storage.TerminateSession(context.Background(), "user", "client"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageAuthorizeClientIDSecret(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
if err := storage.AuthorizeClientIDSecret(context.Background(), "client", "secret"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageValidateJWTProfileScopes(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
scopes := []string{"openid", "profile"}
|
||||
result, err := storage.ValidateJWTProfileScopes(context.Background(), "user", scopes)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) != len(scopes) {
|
||||
t.Fatalf("expected %d scopes, got %d", len(scopes), len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStorageGetPrivateClaimsFromScopes(t *testing.T) {
|
||||
storage := NewAuthStorage().(*AuthStorage)
|
||||
claims, err := storage.GetPrivateClaimsFromScopes(context.Background(), "", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if claims["private_claim"] != "test" {
|
||||
t.Fatalf("expected private_claim 'test', got %v", claims["private_claim"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,16 +35,16 @@ func (m *MediaFile) GenerateCaption(captionSrc entity.Src) (caption *vision.Capt
|
|||
size := vision.Thumb(vision.ModelTypeCaption)
|
||||
|
||||
// Get thumbnail filenames for the selected sizes.
|
||||
fileName, fileErr := m.Thumbnail(Config().ThumbCachePath(), size.Name)
|
||||
fileName, err := m.Thumbnail(Config().ThumbCachePath(), size.Name)
|
||||
|
||||
if fileErr != nil {
|
||||
return caption, fileErr
|
||||
if err != nil {
|
||||
return caption, err
|
||||
}
|
||||
|
||||
// Get matching labels from computer vision model.
|
||||
// Generate a caption using the configured vision model.
|
||||
if caption, _, err = vision.GenerateCaption(vision.Files{fileName}, media.SrcLocal); err != nil {
|
||||
// Failed.
|
||||
log.Debugf("vision: %s in %s (generate caption)", err, clean.Log(m.RootRelName()))
|
||||
} else if caption.Text != "" {
|
||||
if captionSrc != entity.SrcAuto {
|
||||
caption.Source = captionSrc
|
||||
|
|
@ -128,7 +128,7 @@ func (m *MediaFile) DetectNSFW() bool {
|
|||
filename, err := m.Thumbnail(Config().ThumbCachePath(), thumb.Fit720)
|
||||
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
log.Errorf("vision: %s in %s (thumbnail for nsfw)", err, clean.Log(m.RootRelName()))
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/http/proxy"
|
||||
)
|
||||
|
||||
// excludedExtensions lists file extensions whose responses should never be
|
||||
// HTTP-compressed. These formats are already compressed or typically served
|
||||
// as large binary payloads where gzip/zstd add CPU cost without saving bytes.
|
||||
var excludedExtensions = map[string]struct{}{
|
||||
// GzipExcludedExtensions contains file extensions that should never be gzip-compressed.
|
||||
// These formats are already compressed or typically served as large binary payloads.
|
||||
var GzipExcludedExtensions = map[string]struct{}{
|
||||
".png": {},
|
||||
".gif": {},
|
||||
".jpeg": {},
|
||||
|
|
@ -25,13 +24,16 @@ var excludedExtensions = map[string]struct{}{
|
|||
".gz": {},
|
||||
}
|
||||
|
||||
// NewShouldCompressFn returns an encoding-agnostic predicate that decides
|
||||
// whether a given request is eligible for HTTP response compression. The
|
||||
// predicate inspects the request URL path, file extension, and matched Gin
|
||||
// route pattern; it deliberately does not look at Accept-Encoding or
|
||||
// Connection headers (the middleware owns those checks). When conf is nil it
|
||||
// returns a predicate that always declines.
|
||||
func NewShouldCompressFn(conf *config.Config) func(c *gin.Context) bool {
|
||||
// ShouldExcludeGzipExt returns true if the given file extension should not be gzip-compressed.
|
||||
func ShouldExcludeGzipExt(ext string) bool {
|
||||
_, ok := GzipExcludedExtensions[strings.ToLower(ext)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// NewGzipShouldCompressFn returns a high-performance gzip decision function for PhotoPrism.
|
||||
// It mirrors the legacy exclusion rules (extensions and path prefixes) and adds targeted
|
||||
// route exclusions for binary/streaming endpoints that must not be compressed.
|
||||
func NewGzipShouldCompressFn(conf *config.Config) func(c *gin.Context) bool {
|
||||
if conf == nil {
|
||||
return func(*gin.Context) bool { return false }
|
||||
}
|
||||
|
|
@ -43,16 +45,16 @@ func NewShouldCompressFn(conf *config.Config) func(c *gin.Context) bool {
|
|||
photoDlPrefix := apiBase + "/photos/"
|
||||
clusterThemePath := apiBase + "/cluster/theme"
|
||||
|
||||
// FullPath patterns (exact match) for dynamic routes that should bypass compression.
|
||||
// FullPath patterns (exact match) for dynamic routes that should bypass gzip.
|
||||
excludedFullPaths := map[string]struct{}{
|
||||
apiBase + "/photos/:uid/dl": {},
|
||||
apiBase + "/cluster/theme": {},
|
||||
conf.BaseUri("/s/:token/:shared/preview"): {},
|
||||
}
|
||||
|
||||
// Path prefixes that should bypass compression (prefix match on raw URL path).
|
||||
// Path prefixes that should bypass gzip (prefix match on raw URL path).
|
||||
excludedPrefixes := []string{
|
||||
// Health endpoints are small and frequently polled; compression would add overhead.
|
||||
// Health endpoints are small and frequently polled; gzip would add overhead.
|
||||
conf.BaseUri("/livez"),
|
||||
conf.BaseUri("/health"),
|
||||
conf.BaseUri("/readyz"),
|
||||
|
|
@ -64,60 +66,93 @@ func NewShouldCompressFn(conf *config.Config) func(c *gin.Context) bool {
|
|||
conf.BaseUri(config.ApiUri + "/labels"),
|
||||
conf.BaseUri(config.ApiUri + "/videos"),
|
||||
conf.BaseUri(proxy.PathPrefix),
|
||||
// Bundled and custom static assets are served with precompressed
|
||||
// .zst / .gz siblings via PrecompressedStatic; bypass the runtime
|
||||
// encoder so it never re-encodes an already-encoded body and so
|
||||
// PHOTOPRISM_HTTP_COMPRESSION=none consistently disables every
|
||||
// encoded code path on these routes.
|
||||
conf.BaseUri(config.StaticUri),
|
||||
conf.BaseUri(config.CustomStaticUri),
|
||||
}
|
||||
|
||||
return func(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
path := c.Request.URL.Path
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Exclude known already-compressed/binary extensions.
|
||||
if ext := strings.ToLower(filepath.Ext(path)); ext != "" {
|
||||
if _, ok := excludedExtensions[ext]; ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude configured prefix groups.
|
||||
for _, prefix := range excludedPrefixes {
|
||||
if prefix != "" && strings.HasPrefix(path, prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude matched route patterns for dynamic endpoints.
|
||||
if full := c.FullPath(); full != "" {
|
||||
if _, ok := excludedFullPaths[full]; ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback exclusions using raw path checks for robustness.
|
||||
// Note: Keep the prefix guard here (not just HasSuffix), as the frontend SPA
|
||||
// wildcard route may include paths ending in "/preview" (HTML) that should
|
||||
// remain compressible (e.g., "/library/.../preview").
|
||||
if path == clusterThemePath {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(path, photoDlPrefix) && strings.HasSuffix(path, "/dl") {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(path, sharePrefix) && strings.HasSuffix(path, "/preview") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return shouldCompressGzip(c, excludedFullPaths, excludedPrefixes, clusterThemePath, photoDlPrefix, sharePrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldCompressGzip is the core decision logic for gzip compression.
|
||||
// It is separated from NewGzipShouldCompressFn to enable unit testing.
|
||||
func shouldCompressGzip(c *gin.Context, excludedFullPaths map[string]struct{}, excludedPrefixes []string, clusterThemePath, photoDlPrefix, sharePrefix string) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only compress when the client explicitly accepts gzip and the connection is not upgraded.
|
||||
if !clientAcceptsGzip(c) {
|
||||
return false
|
||||
}
|
||||
if isConnectionUpgrade(c) {
|
||||
return false
|
||||
}
|
||||
|
||||
path := c.Request.URL.Path
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Exclude known already-compressed/binary extensions.
|
||||
if ext := strings.ToLower(filepath.Ext(path)); ext != "" {
|
||||
if ShouldExcludeGzipExt(ext) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude configured prefix groups.
|
||||
if matchesPrefixExclusion(path, excludedPrefixes) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Exclude matched route patterns for dynamic endpoints.
|
||||
if full := c.FullPath(); full != "" {
|
||||
if _, ok := excludedFullPaths[full]; ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback exclusions using raw path checks for robustness.
|
||||
if matchesFallbackExclusion(path, clusterThemePath, photoDlPrefix, sharePrefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// clientAcceptsGzip checks if the client accepts gzip encoding.
|
||||
func clientAcceptsGzip(c *gin.Context) bool {
|
||||
return strings.Contains(strings.ToLower(c.GetHeader("Accept-Encoding")), "gzip")
|
||||
}
|
||||
|
||||
// isConnectionUpgrade checks if the connection is being upgraded (e.g., WebSocket).
|
||||
func isConnectionUpgrade(c *gin.Context) bool {
|
||||
return strings.Contains(strings.ToLower(c.GetHeader("Connection")), "upgrade")
|
||||
}
|
||||
|
||||
// matchesPrefixExclusion checks if the path matches any excluded prefix.
|
||||
func matchesPrefixExclusion(path string, excludedPrefixes []string) bool {
|
||||
for _, prefix := range excludedPrefixes {
|
||||
if prefix != "" && strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesFallbackExclusion checks for fallback exclusions using raw path checks.
|
||||
// Note: Keep the prefix guard here (not just HasSuffix), as the frontend SPA
|
||||
// wildcard route may include paths ending in "/preview" (HTML) that should
|
||||
// remain compressible (e.g., "/library/.../preview").
|
||||
func matchesFallbackExclusion(path, clusterThemePath, photoDlPrefix, sharePrefix string) bool {
|
||||
if path == clusterThemePath {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, photoDlPrefix) && strings.HasSuffix(path, "/dl") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, sharePrefix) && strings.HasSuffix(path, "/preview") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -16,14 +17,84 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/http/proxy"
|
||||
)
|
||||
|
||||
func TestNewCompressMiddleware_Gzip(t *testing.T) {
|
||||
func TestShouldExcludeGzipExt(t *testing.T) {
|
||||
t.Run("ExcludesCompressedFormats", func(t *testing.T) {
|
||||
excludedExts := []string{".png", ".gif", ".jpeg", ".jpg", ".webp", ".mp3", ".mp4", ".zip", ".gz"}
|
||||
for _, ext := range excludedExts {
|
||||
assert.True(t, ShouldExcludeGzipExt(ext), "extension %s should be excluded", ext)
|
||||
}
|
||||
})
|
||||
t.Run("ExcludesCaseInsensitive", func(t *testing.T) {
|
||||
assert.True(t, ShouldExcludeGzipExt(".PNG"))
|
||||
assert.True(t, ShouldExcludeGzipExt(".Jpg"))
|
||||
assert.True(t, ShouldExcludeGzipExt(".GZ"))
|
||||
})
|
||||
t.Run("DoesNotExcludeTextFormats", func(t *testing.T) {
|
||||
allowedExts := []string{".html", ".css", ".js", ".json", ".xml", ".txt", ".svg"}
|
||||
for _, ext := range allowedExts {
|
||||
assert.False(t, ShouldExcludeGzipExt(ext), "extension %s should not be excluded", ext)
|
||||
}
|
||||
})
|
||||
t.Run("EmptyExtension", func(t *testing.T) {
|
||||
assert.False(t, ShouldExcludeGzipExt(""))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchesPrefixExclusion(t *testing.T) {
|
||||
prefixes := []string{"/api/v1/dl", "/health", "/livez"}
|
||||
|
||||
t.Run("MatchesPrefix", func(t *testing.T) {
|
||||
assert.True(t, matchesPrefixExclusion("/api/v1/dl/file", prefixes))
|
||||
assert.True(t, matchesPrefixExclusion("/health", prefixes))
|
||||
assert.True(t, matchesPrefixExclusion("/healthz", prefixes))
|
||||
assert.True(t, matchesPrefixExclusion("/livez", prefixes))
|
||||
})
|
||||
t.Run("DoesNotMatchNonPrefixes", func(t *testing.T) {
|
||||
assert.False(t, matchesPrefixExclusion("/api/v1/photos", prefixes))
|
||||
assert.False(t, matchesPrefixExclusion("/other", prefixes))
|
||||
})
|
||||
t.Run("EmptyPrefixes", func(t *testing.T) {
|
||||
assert.False(t, matchesPrefixExclusion("/anything", nil))
|
||||
assert.False(t, matchesPrefixExclusion("/anything", []string{}))
|
||||
})
|
||||
t.Run("EmptyPath", func(t *testing.T) {
|
||||
assert.False(t, matchesPrefixExclusion("", prefixes))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMatchesFallbackExclusion(t *testing.T) {
|
||||
clusterTheme := "/api/v1/cluster/theme"
|
||||
photoDl := "/api/v1/photos/"
|
||||
share := "/s/"
|
||||
|
||||
t.Run("MatchesClusterTheme", func(t *testing.T) {
|
||||
assert.True(t, matchesFallbackExclusion(clusterTheme, clusterTheme, photoDl, share))
|
||||
})
|
||||
t.Run("MatchesPhotoDl", func(t *testing.T) {
|
||||
assert.True(t, matchesFallbackExclusion("/api/v1/photos/abc123/dl", clusterTheme, photoDl, share))
|
||||
})
|
||||
t.Run("MatchesSharePreview", func(t *testing.T) {
|
||||
assert.True(t, matchesFallbackExclusion("/s/token/shared/preview", clusterTheme, photoDl, share))
|
||||
})
|
||||
t.Run("DoesNotMatchOtherPaths", func(t *testing.T) {
|
||||
assert.False(t, matchesFallbackExclusion("/api/v1/photos/abc123", clusterTheme, photoDl, share))
|
||||
assert.False(t, matchesFallbackExclusion("/s/token/shared/view", clusterTheme, photoDl, share))
|
||||
assert.False(t, matchesFallbackExclusion("/library/something/preview", clusterTheme, photoDl, share))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGzipMiddleware(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Enable gzip for this test router.
|
||||
conf := config.TestConfig()
|
||||
conf.Options().HttpCompression = "gzip"
|
||||
|
||||
r := gin.New()
|
||||
r.Use(NewCompressMiddleware(conf))
|
||||
r.Use(gzip.Gzip(
|
||||
gzip.DefaultCompression,
|
||||
gzip.WithCustomShouldCompressFn(NewGzipShouldCompressFn(conf)),
|
||||
))
|
||||
|
||||
r.GET("/ok", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "hello world")
|
||||
|
|
@ -64,10 +135,6 @@ func TestNewCompressMiddleware_Gzip(t *testing.T) {
|
|||
c.String(http.StatusOK, "theme")
|
||||
})
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.String(http.StatusNotFound, "404 page not found")
|
||||
})
|
||||
|
||||
sharePreviewRoute := conf.BaseUri("/s/:token/:shared/preview")
|
||||
r.GET(sharePreviewRoute, func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "preview")
|
||||
|
|
@ -88,7 +155,6 @@ func TestNewCompressMiddleware_Gzip(t *testing.T) {
|
|||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "gzip", w.Header().Get("Content-Encoding"))
|
||||
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
|
||||
|
||||
zr, err := stdgzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
||||
require.NoError(t, err)
|
||||
|
|
@ -105,7 +171,6 @@ func TestNewCompressMiddleware_Gzip(t *testing.T) {
|
|||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
||||
assert.Empty(t, w.Header().Get("Vary"))
|
||||
assert.Equal(t, "download", w.Body.String())
|
||||
})
|
||||
t.Run("DoesNotCompressExcludedExtensions", func(t *testing.T) {
|
||||
|
|
@ -145,7 +210,7 @@ func TestNewCompressMiddleware_Gzip(t *testing.T) {
|
|||
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
||||
assert.Equal(t, "preview", w.Body.String())
|
||||
})
|
||||
t.Run("DoesNotCompressPortalProxyURI", func(t *testing.T) {
|
||||
t.Run("DoesNotCompressPortalProxyPrefix", func(t *testing.T) {
|
||||
proxyPath := conf.BaseUri(proxy.PathPrefix + "test/ok")
|
||||
r.GET(proxyPath, func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "proxy")
|
||||
|
|
@ -162,19 +227,13 @@ func TestNewCompressMiddleware_Gzip(t *testing.T) {
|
|||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
||||
// Vary still set so caches don't serve compressed bytes to clients that won't accept them.
|
||||
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
|
||||
assert.Equal(t, "hello world", w.Body.String())
|
||||
})
|
||||
t.Run("BypassesCompressionFor404", func(t *testing.T) {
|
||||
t.Run("DoesNotCompressNotFound", func(t *testing.T) {
|
||||
w := doRequest("/missing", true)
|
||||
|
||||
// 404 falls through NoRoute; error responses bypass the encoder so the
|
||||
// raw body lands on the wire, even though the predicate would have
|
||||
// allowed compression for this path.
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Empty(t, w.Header().Get("Content-Encoding"))
|
||||
assert.Contains(t, w.Header().Get("Vary"), "Accept-Encoding")
|
||||
assert.Contains(t, w.Body.String(), "404")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue