This commit is contained in:
Vedant Madane 2026-01-21 21:22:19 -05:00 committed by GitHub
commit c17442cad1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 516 additions and 64 deletions

View file

@ -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.

View file

@ -1,6 +1,6 @@
module caos-test-op
go 1.24.0
go 1.25.0
require (
github.com/gorilla/mux v1.8.1

View 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)
}
}

View file

@ -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"])
}
}

4
go.mod
View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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)