diff --git a/docker/ddns/Dockerfile b/docker/ddns/Dockerfile index 43977a310..bf6b6a449 100644 --- a/docker/ddns/Dockerfile +++ b/docker/ddns/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine +FROM golang:1.25-alpine # Q: What is this? # A: DDNS is a personal DynDNS client for DigitalOcean, see https://github.com/skibish/ddns. diff --git a/docker/dummy/oidc/app/go.mod b/docker/dummy/oidc/app/go.mod index 5c8b312ac..ed858a38a 100644 --- a/docker/dummy/oidc/app/go.mod +++ b/docker/dummy/oidc/app/go.mod @@ -1,6 +1,6 @@ module caos-test-op -go 1.24.0 +go 1.25.0 require ( github.com/gorilla/mux v1.8.1 diff --git a/docker/dummy/oidc/app/mock/client_test.go b/docker/dummy/oidc/app/mock/client_test.go new file mode 100644 index 000000000..860499667 --- /dev/null +++ b/docker/dummy/oidc/app/mock/client_test.go @@ -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) + } +} diff --git a/docker/dummy/oidc/app/mock/storage_test.go b/docker/dummy/oidc/app/mock/storage_test.go index 006a4d3bc..45f5db990 100644 --- a/docker/dummy/oidc/app/mock/storage_test.go +++ b/docker/dummy/oidc/app/mock/storage_test.go @@ -14,6 +14,93 @@ 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 := &AuthStorage{} if err := s.RevokeToken( @@ -25,3 +112,96 @@ func TestRevokeTokenNoError(t *testing.T) { t.Fatalf("expected nil error from RevokeToken, got %v", err) } } + +func TestAuthStorageGetKeySet(t *testing.T) { + storage := NewAuthStorage().(*AuthStorage) + keySet, err := storage.GetKeySet(context.Background()) + if err != nil { + t.Fatalf("unexpected error from GetKeySet: %v", err) + } + if keySet == nil { + t.Fatal("expected non-nil key set") + } + if len(keySet.Keys) != 2 { + t.Fatalf("expected 2 keys, got %d", len(keySet.Keys)) + } +} + +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 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"]) + } +} diff --git a/go.mod b/go.mod index 5602642b6..43f2aa48e 100644 --- a/go.mod +++ b/go.mod @@ -195,6 +195,6 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect ) -go 1.24.0 +go 1.25.0 -toolchain go1.24.1 +toolchain go1.25.5 diff --git a/internal/photoprism/mediafile_vision.go b/internal/photoprism/mediafile_vision.go index 4f487cdb4..2ff3a2653 100644 --- a/internal/photoprism/mediafile_vision.go +++ b/internal/photoprism/mediafile_vision.go @@ -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 } diff --git a/internal/server/gzip.go b/internal/server/gzip.go index 95b92e4da..d8157a8a3 100644 --- a/internal/server/gzip.go +++ b/internal/server/gzip.go @@ -9,9 +9,9 @@ import ( "github.com/photoprism/photoprism/internal/config" ) -// gzipExcludedExtensions contains file extensions that should never be gzip-compressed. +// 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{}{ +var GzipExcludedExtensions = map[string]struct{}{ ".png": {}, ".gif": {}, ".jpeg": {}, @@ -23,6 +23,12 @@ var gzipExcludedExtensions = map[string]struct{}{ ".gz": {}, } +// 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. @@ -61,58 +67,90 @@ func NewGzipShouldCompressFn(conf *config.Config) func(c *gin.Context) bool { } return func(c *gin.Context) bool { - if c == nil || c.Request == nil { - return false - } - - // Only compress when the client explicitly accepts gzip and the connection is not upgraded. - if !strings.Contains(strings.ToLower(c.GetHeader("Accept-Encoding")), "gzip") { - return false - } - if strings.Contains(strings.ToLower(c.GetHeader("Connection")), "upgrade") { - 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 := gzipExcludedExtensions[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 +} diff --git a/internal/server/gzip_test.go b/internal/server/gzip_test.go index 9b071e82b..8f3c6f2d6 100644 --- a/internal/server/gzip_test.go +++ b/internal/server/gzip_test.go @@ -16,6 +16,72 @@ import ( "github.com/photoprism/photoprism/internal/config" ) +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)