Logs: Add package pkg/log/status to provide generic outcome constants

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-21 14:42:05 +02:00
parent 91e4d72e1b
commit cd7fa6adca
57 changed files with 377 additions and 275 deletions

View File

@@ -1,6 +1,6 @@
# PhotoPrism® Repository Guidelines
**Last Updated:** October 15, 2025
**Last Updated:** October 21, 2025
## Purpose
@@ -335,6 +335,8 @@ Note: Across our public documentation, official images, and in production, the c
- When introducing new metadata sources (e.g., `SrcOllama`, `SrcOpenAI`), define them in both `internal/entity/src.go` and the frontend lookup tables (`frontend/src/common/util.js`) so UI badges and server priorities stay aligned.
- Vision worker scheduling is controlled via `VisionSchedule` / `VisionFilter` and the `Run` property set in `vision.yml`. Utilities like `vision.FilterModels` and `entity.Photo.ShouldGenerateLabels/Caption` help decide when work is required before loading media files.
- Logging: use the shared logger (`event.Log`) via the package-level `log` variable (see `internal/auth/jwt/logger.go`) instead of direct `fmt.Print*` or ad-hoc loggers.
- Audit outcomes: import `github.com/photoprism/photoprism/pkg/log/status` and end every `event.Audit*` slice with a single outcome token such as `status.Succeeded`, `status.Failed`, `status.Denied`, or other constants defined there (no additional segments afterwards).
- Error outcomes: when a sanitized error string should be the outcome, call `status.Error(err)` instead of adding a placeholder and passing `clean.Error(err)` manually.
- Cluster registry tests (`internal/service/cluster/registry`) currently rely on a full test config because they persist `entity.Client` rows. They run migrations and seed the SQLite DB, so they are intentionally slow. If you refactor them, consider sharing a single `config.TestConfig()` across subtests or building a lightweight schema harness; do not swap to the minimal config helper unless the tests stop touching the database.
- Favor explicit CLI flags: check `c.cliCtx.IsSet("<flag>")` before overriding user-supplied values, and follow the `ClusterUUID` pattern (`options.yml` → CLI/env → generated UUIDv4 persisted).
- Database helpers: reuse `conf.Db()` / `conf.Database*()`, avoid GORM `WithContext`, quote MySQL identifiers, and reject unsupported drivers early.

View File

@@ -1,6 +1,6 @@
PhotoPrism — Backend CODEMAP
**Last Updated:** October 14, 2025
**Last Updated:** October 21, 2025
Purpose
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
@@ -38,7 +38,7 @@ High-Level Package Map (Go)
- `internal/workers` — background schedulers (index, vision, sync, meta, backup)
- `internal/auth` — ACL, sessions, OIDC
- `internal/service` — cluster/portal, maps, hub, webdav
- `internal/event` — logging, pub/sub, audit
- `internal/event` — logging, pub/sub, audit; canonical outcome tokens live in `pkg/log/status` (use helpers like `status.Error(err)` when the sanitized message should be the outcome)
- `internal/ffmpeg`, `internal/thumb`, `internal/meta`, `internal/form`, `internal/mutex` — media, thumbs, metadata, forms, coordination
- `pkg/*` — reusable utilities (must never import from `internal/*`), e.g. `pkg/clean`, `pkg/enum`, `pkg/fs`, `pkg/txt`, `pkg/http/header`

View File

@@ -96,6 +96,10 @@ We kindly ask you not to report bugs via GitHub Issues **unless you are certain
- [Contact us](https://www.photoprism.app/contact) or [a community member](https://link.photoprism.app/discussions) if you need help, it could be a local configuration problem, or a misunderstanding in how the software works
- This gives us the opportunity to [improve our documentation](https://docs.photoprism.app/getting-started/troubleshooting/) and provide best-in-class support instead of dealing with unclear/duplicate bug reports or triggering a flood of notifications by replying to comments
## Developer Notes ##
- When emitting audit logs, reuse the helpers in `pkg/log/status`; call `status.Error(err)` whenever the sanitized error message should serve as the outcome token. See `AGENTS.md` and `specs/common/audit-logs.md` for full guidelines.
## Connect with the Community ##
<a href="https://link.photoprism.app/chat"><img align="right" width="144" height="144" src="https://dl.photoprism.app/img/brands/element-logo.svg"></a>

View File

@@ -34,7 +34,7 @@ The API package exposes PhotoPrisms HTTP endpoints via Gin handlers. Each fil
- Emit security events via `event.Audit*` (`AuditInfo`, `AuditWarn`, `AuditErr`, `AuditDebug`) and always build the slice as **Who → What → Outcome**.
- **Who:** `ClientIP(c)` followed by the most specific actor context (`"session %s"`, `"client %s"`, `"user %s"`).
- **What:** Resource constant plus action segments (for example, `string(acl.ResourceCluster)`, `"node %s"`). Place extra context such as counts or error placeholders in separate segments before the outcome.
- **Outcome:** End with a single token like `event.Succeeded`, `event.Failed`, or `authn.Denied`; nothing comes after it.
- **Outcome:** End with a single token such as `status.Succeeded`, `status.Failed`, `status.Denied`, or `status.Error(err)` when the sanitized error message should be the outcome; nothing comes after it.
- Prefer existing helpers (`ClientIP`, `clean.Log`, `clean.LogQuote`, `clean.Error`) instead of formatting values manually, and avoid inline `=` expressions.
- Example patterns:
```go
@@ -43,7 +43,7 @@ The API package exposes PhotoPrisms HTTP endpoints via Gin handlers. Each fil
"session %s",
string(acl.ResourceCluster),
"node %s",
event.Deleted,
status.Deleted,
}, s.RefID, uuid)
event.AuditErr([]string{
@@ -51,9 +51,8 @@ The API package exposes PhotoPrisms HTTP endpoints via Gin handlers. Each fil
"session %s",
string(acl.ResourceCluster),
"download theme",
"%s",
event.Failed,
}, refID, clean.Error(err))
status.Error(err),
}, refID)
```
- See `specs/common/audit-logs.md` for the full conventions and additional examples that agents should follow.

View File

@@ -143,7 +143,7 @@ func CreateAlbum(router *gin.RouterGroup) {
album := entity.NewUserAlbum(frm.AlbumTitle, entity.AlbumManual, conf.Settings().Albums.Order.Album, s.UserUID)
album.AlbumFavorite = frm.AlbumFavorite
status := http.StatusOK
code := http.StatusOK
// Existing album?
if found := album.Find(); found == nil {
@@ -154,7 +154,7 @@ func CreateAlbum(router *gin.RouterGroup) {
AbortUnexpectedError(c)
return
}
status = http.StatusCreated
code = http.StatusCreated
} else {
// Exists, restore if necessary.
album = found
@@ -175,12 +175,12 @@ func CreateAlbum(router *gin.RouterGroup) {
SaveAlbumYaml(*album)
// Add location header if newly created.
if status == http.StatusCreated {
if code == http.StatusCreated {
header.SetLocation(c, c.FullPath(), album.AlbumUID)
}
// Return as JSON.
c.JSON(status, album)
c.JSON(code, album)
})
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -41,7 +42,7 @@ func SearchAlbums(router *gin.RouterGroup) {
// Abort if request params are invalid.
if err = c.MustBindWith(&frm, binding.Form); err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "form invalid", "%s"}, s.RefID, err)
event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "form invalid", status.Error(err)}, s.RefID)
AbortBadRequest(c, err)
return
}
@@ -58,7 +59,7 @@ func SearchAlbums(router *gin.RouterGroup) {
// Ok?
if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "%s"}, s.RefID, err)
event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", status.Error(err)}, s.RefID)
c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())})
return
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
)
// Auth checks if the user is authorized to access a resource with the given permission
@@ -37,7 +38,7 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
if s = authAnyJWT(c, clientIp, authToken, resource, perms); s != nil {
return s
}
event.AuditWarn([]string{clientIp, "%s %s without authentication", authn.Denied}, perms.String(), string(resource))
event.AuditWarn([]string{clientIp, "%s %s without authentication", status.Denied}, perms.String(), string(resource))
return entity.SessionStatusUnauthorized()
}
@@ -55,25 +56,25 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
// Check request authorization against client application ACL rules.
if acl.Rules.DenyAll(resource, s.GetClientRole(), perms) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s", authn.Denied}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource))
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s", status.Denied}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource))
return entity.SessionStatusForbidden()
}
// Also check the request authorization against the user's ACL rules?
if s.NoUser() {
// Allow access based on the ACL defaults for client applications.
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s", authn.Granted}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource))
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s", status.Granted}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource))
} else if u := s.GetUser(); !u.IsDisabled() && !u.IsUnknown() && u.IsRegistered() {
if acl.Rules.DenyAll(resource, u.AclRole(), perms) {
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as %s", authn.Denied}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource), u.String())
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as %s", status.Denied}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource), u.String())
return entity.SessionStatusForbidden()
}
// Allow access based on the user role.
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s as %s", authn.Granted}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource), u.String())
event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s as %s", status.Granted}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource), u.String())
} else {
// Deny access if it is not a regular user account or the account has been disabled.
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as unauthorized user", authn.Denied}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource))
event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as unauthorized user", status.Denied}, clean.Log(s.GetClientInfo()), s.RefID, perms.String(), string(resource))
return entity.SessionStatusForbidden()
}
@@ -82,13 +83,13 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
// Otherwise, perform a regular ACL authorization check based on the user role.
if u := s.GetUser(); u.IsUnknown() || u.IsDisabled() {
event.AuditWarn([]string{clientIp, "session %s", "%s %s as unauthorized user", authn.Denied}, s.RefID, perms.String(), string(resource))
event.AuditWarn([]string{clientIp, "session %s", "%s %s as unauthorized user", status.Denied}, s.RefID, perms.String(), string(resource))
return entity.SessionStatusUnauthorized()
} else if acl.Rules.DenyAll(resource, u.AclRole(), perms) {
event.AuditErr([]string{clientIp, "session %s", "%s %s as %s", authn.Denied}, s.RefID, perms.String(), string(resource), u.AclRole().String())
event.AuditErr([]string{clientIp, "session %s", "%s %s as %s", status.Denied}, s.RefID, perms.String(), string(resource), u.AclRole().String())
return entity.SessionStatusForbidden()
} else {
event.AuditInfo([]string{clientIp, "session %s", "%s %s as %s", authn.Granted}, s.RefID, perms.String(), string(resource), u.AclRole().String())
event.AuditInfo([]string{clientIp, "session %s", "%s %s as %s", status.Granted}, s.RefID, perms.String(), string(resource), u.AclRole().String())
return s
}
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/log/status"
)
// Event represents an api event type.
@@ -28,7 +29,7 @@ func (ev Event) String() string {
// PublishPhotoEvent publishes updated photo data after changes have been made.
func PublishPhotoEvent(ev Event, uid string, c *gin.Context) {
if result, _, err := search.Photos(form.SearchPhotos{UID: uid, Merged: true}); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s photo %s", "%s"}, AuthToken(c), string(ev), uid, err)
event.AuditErr([]string{ClientIP(c), "session %s", "%s photo %s", status.Error(err)}, AuthToken(c), string(ev), uid)
} else {
event.PublishEntities("photos", string(ev), result)
}
@@ -38,7 +39,7 @@ func PublishPhotoEvent(ev Event, uid string, c *gin.Context) {
func PublishAlbumEvent(ev Event, uid string, c *gin.Context) {
f := form.SearchAlbums{UID: uid}
if result, err := search.Albums(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s album %s", "%s"}, AuthToken(c), string(ev), uid, err)
event.AuditErr([]string{ClientIP(c), "session %s", "%s album %s", status.Error(err)}, AuthToken(c), string(ev), uid)
} else {
event.PublishEntities("albums", string(ev), result)
}
@@ -48,7 +49,7 @@ func PublishAlbumEvent(ev Event, uid string, c *gin.Context) {
func PublishLabelEvent(ev Event, uid string, c *gin.Context) {
f := form.SearchLabels{UID: uid}
if result, err := search.Labels(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s label %s", "%s"}, AuthToken(c), string(ev), uid, err)
event.AuditErr([]string{ClientIP(c), "session %s", "%s label %s", status.Error(err)}, AuthToken(c), string(ev), uid)
} else {
event.PublishEntities("labels", string(ev), result)
}
@@ -58,7 +59,7 @@ func PublishLabelEvent(ev Event, uid string, c *gin.Context) {
func PublishSubjectEvent(ev Event, uid string, c *gin.Context) {
f := form.SearchSubjects{UID: uid}
if result, err := search.Subjects(f); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "%s subject %s", "%s"}, AuthToken(c), string(ev), uid, err)
event.AuditErr([]string{ClientIP(c), "session %s", "%s subject %s", status.Error(err)}, AuthToken(c), string(ev), uid)
} else {
event.PublishEntities("subjects", string(ev), result)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -108,14 +109,13 @@ func ClusterListNodes(router *gin.RouterGroup) {
resp := reg.BuildClusterNodes(page, opts)
// Audit list access.
event.AuditDebug([]string{
ClientIP(c),
"session %s",
string(acl.ResourceCluster),
"list nodes",
"count %d offset %d returned %d",
event.Succeeded,
}, s.RefID, count, offset, len(resp))
event.AuditDebug(
[]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "list nodes", "count %d offset %d returned %d", status.Succeeded},
s.RefID,
count,
offset,
len(resp),
)
c.JSON(http.StatusOK, resp)
})
@@ -173,13 +173,11 @@ func ClusterGetNode(router *gin.RouterGroup) {
resp := reg.BuildClusterNode(*n, opts)
// Audit get access.
event.AuditInfo([]string{
ClientIP(c),
"session %s",
string(acl.ResourceCluster),
"get node %s",
event.Succeeded,
}, s.RefID, uuid)
event.AuditInfo(
[]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "get node %s", status.Succeeded},
s.RefID,
uuid,
)
c.JSON(http.StatusOK, resp)
})
@@ -263,13 +261,11 @@ func ClusterUpdateNode(router *gin.RouterGroup) {
return
}
event.AuditInfo([]string{
ClientIP(c),
"session %s",
string(acl.ResourceCluster),
"node %s",
event.Updated,
}, s.RefID, uuid)
event.AuditInfo(
[]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "node %s", status.Updated},
s.RefID,
uuid,
)
c.JSON(http.StatusOK, cluster.StatusResponse{Status: "ok"})
})
@@ -324,13 +320,11 @@ func ClusterDeleteNode(router *gin.RouterGroup) {
return
}
event.AuditWarn([]string{
ClientIP(c),
"session %s",
string(acl.ResourceCluster),
"node %s",
event.Deleted,
}, s.RefID, uuid)
event.AuditWarn(
[]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "node %s", status.Deleted},
s.RefID,
uuid,
)
c.JSON(http.StatusOK, cluster.StatusResponse{Status: "ok"})
})

View File

@@ -20,6 +20,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -53,7 +54,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
r := limiter.Auth.Request(clientIp)
if r.Reject() || limiter.Auth.Reject(clientIp) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "rate limit exceeded", event.Denied})
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "rate limit exceeded", status.Denied})
limiter.AbortJSON(c)
return
}
@@ -63,7 +64,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
token := header.BearerToken(c)
if expected == "" || token == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(token)) != 1 {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid join token", event.Denied})
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid join token", status.Denied})
r.Success() // return reserved tokens; still unauthorized
AbortUnauthorized(c)
return
@@ -73,7 +74,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
var req cluster.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid form", "%s", event.Failed}, clean.Error(err))
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid form", status.Error(err)})
AbortBadRequest(c)
return
}
@@ -85,13 +86,13 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// If an existing ClientID is provided, require the corresponding client secret for verification.
if RegisterRequireClientSecret && req.ClientID != "" {
if !rnd.IsUID(req.ClientID, entity.ClientUID) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid client id", event.Failed})
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid client id", status.Failed})
AbortBadRequest(c)
return
}
pw := entity.FindPassword(req.ClientID)
if pw == nil || req.ClientSecret == "" || !pw.Valid(req.ClientSecret) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid client secret", event.Denied})
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid client secret", status.Denied})
AbortUnauthorized(c)
return
}
@@ -101,14 +102,14 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// Enforce DNS label semantics for node names: lowercase [a-z0-9-], 132, start/end alnum.
if name == "" || len(name) > 32 || name[0] == '-' || name[len(name)-1] == '-' {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid name", event.Failed})
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid name", status.Failed})
AbortBadRequest(c)
return
}
for i := 0; i < len(name); i++ {
b := name[i]
if !(b == '-' || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9')) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid name chars", event.Failed})
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid name chars", status.Failed})
AbortBadRequest(c)
return
}
@@ -117,7 +118,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// Validate advertise URL if provided (https required for non-local domains).
if u := strings.TrimSpace(req.AdvertiseUrl); u != "" {
if !validateAdvertiseURL(u) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid advertise url", event.Failed})
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid advertise url", status.Failed})
AbortBadRequest(c)
return
}
@@ -126,7 +127,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// Validate site URL if provided (https required for non-local domains).
if su := strings.TrimSpace(req.SiteUrl); su != "" {
if !validateSiteURL(su) {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid site url", event.Failed})
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "invalid site url", status.Failed})
AbortBadRequest(c)
return
}
@@ -139,7 +140,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "register", "%s", event.Failed}, clean.Error(err))
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "register", status.Error(err)})
AbortUnexpectedError(c)
return
}
@@ -154,7 +155,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// If caller attempts to change UUID by name without proving client secret, block with 409.
if RegisterRequireClientSecret {
if requestedUUID != "" && n.UUID != "" && requestedUUID != n.UUID && req.ClientID == "" {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "node %s", "invalid client secret", event.Denied}, clean.Log(name))
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "node %s", "invalid client secret", status.Denied}, clean.Log(name))
c.JSON(http.StatusConflict, gin.H{"error": "client secret required to change node uuid"})
return
}
@@ -186,17 +187,17 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
if oldUUID != requestedUUID {
n.UUID = requestedUUID
// Emit audit event for UUID change.
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", "change uuid old %s new %s", event.Updated}, clean.Log(name), clean.Log(oldUUID), clean.Log(requestedUUID))
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", "change uuid old %s new %s", status.Updated}, clean.Log(name), clean.Log(oldUUID), clean.Log(requestedUUID))
}
} else if n.UUID == "" {
// Assign a fresh UUID if missing and none requested.
n.UUID = rnd.UUIDv7()
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", "assign uuid %s", event.Created}, clean.Log(name), clean.Log(n.UUID))
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", "assign uuid %s", status.Created}, clean.Log(name), clean.Log(n.UUID))
}
// Persist metadata changes so UpdatedAt advances.
if putErr := regy.Put(n); putErr != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "node %s", "persist node", "%s", event.Failed}, clean.Log(name), clean.Error(putErr))
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "node %s", "persist node", status.Error(putErr)}, clean.Log(name))
AbortUnexpectedError(c)
return
}
@@ -205,16 +206,16 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
var respSecret *cluster.RegisterSecrets
if req.RotateSecret {
if n, err = regy.RotateSecret(n.UUID); err != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "node %s", "rotate secret", "%s", event.Failed}, clean.Log(name), clean.Error(err))
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "node %s", "rotate secret", status.Error(err)}, clean.Log(name))
AbortUnexpectedError(c)
return
}
respSecret = &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt}
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", "rotate secret", event.Succeeded}, clean.Log(name))
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", "rotate secret", status.Succeeded}, clean.Log(name))
// Extra safety: ensure the updated secret is persisted even if subsequent steps fail.
if putErr := regy.Put(n); putErr != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "node %s", "persist rotated secret", "%s", event.Failed}, clean.Log(name), clean.Error(putErr))
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "node %s", "persist rotated secret", status.Error(putErr)}, clean.Log(name))
AbortUnexpectedError(c)
return
}
@@ -229,7 +230,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
creds, _, credsErr = provisioner.EnsureCredentials(c, conf, n.UUID, name, req.RotateDatabase)
if credsErr != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "node %s", "ensure database", "%s", event.Failed}, clean.Log(name), clean.Error(credsErr))
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "node %s", "ensure database", status.Error(credsErr)}, clean.Log(name))
c.JSON(http.StatusConflict, gin.H{"error": credsErr.Error()})
return
}
@@ -239,11 +240,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
n.Database.RotatedAt = creds.RotatedAt
n.Database.Driver = provisioner.DatabaseDriver
if putErr := regy.Put(n); putErr != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "node %s", "persist node", "%s", event.Failed}, clean.Log(name), clean.Error(putErr))
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "node %s", "persist node", status.Error(putErr)}, clean.Log(name))
AbortUnexpectedError(c)
return
}
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", "rotate database", event.Succeeded}, clean.Log(name))
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", "rotate database", status.Succeeded}, clean.Log(name))
}
}
@@ -277,7 +278,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
resp.Database.RotatedAt = creds.RotatedAt
}
c.Header(header.CacheControl, header.CacheControlNoStore)
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", event.Updated}, clean.Log(name))
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", status.Synced}, clean.Log(name))
c.JSON(http.StatusOK, resp)
return
}
@@ -319,7 +320,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
if shouldProvisionDB {
if creds, _, err = provisioner.EnsureCredentials(c, conf, n.UUID, name, true); err != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "ensure database", "%s", event.Failed}, clean.Error(err))
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "register", "ensure database", status.Error(err)})
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
@@ -333,7 +334,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
}
if err = regy.Put(n); err != nil {
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "register", "persist node", "%s", event.Failed}, clean.Error(err))
event.AuditErr([]string{clientIp, string(acl.ResourceCluster), "register", "persist node", status.Error(err)})
AbortUnexpectedError(c)
return
}
@@ -359,7 +360,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// When DB provisioning is skipped, leave Database fields zero-value.
c.Header(header.CacheControl, header.CacheControlNoStore)
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", event.Joined}, clean.Log(name))
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "node %s", status.Joined}, clean.Log(name))
c.JSON(http.StatusCreated, resp)
})
}

View File

@@ -13,6 +13,7 @@ import (
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
)
// ClusterSummary returns a minimal overview of the cluster/portal.
@@ -61,13 +62,11 @@ func ClusterSummary(router *gin.RouterGroup) {
Time: time.Now().UTC().Format(time.RFC3339),
}
event.AuditDebug([]string{
ClientIP(c),
"session %s",
string(acl.ResourceCluster),
"get summary for cluster uuid %s",
event.Succeeded,
}, s.RefID, conf.ClusterUUID())
event.AuditDebug(
[]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "get summary for cluster uuid %s", status.Succeeded},
s.RefID,
conf.ClusterUUID(),
)
c.JSON(http.StatusOK, resp)
})
@@ -96,12 +95,7 @@ func ClusterHealth(router *gin.RouterGroup) {
return
}
event.AuditDebug([]string{
ClientIP(c),
string(acl.ResourceCluster),
"health check",
event.Succeeded,
})
event.AuditDebug([]string{ClientIP(c), string(acl.ResourceCluster), "health check", status.Succeeded})
c.JSON(http.StatusOK, NewHealthResponse("ok"))
})

View File

@@ -18,6 +18,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
)
// ClusterGetTheme returns custom theme files as zip, if available.
@@ -75,7 +76,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// Resolve symbolic links.
if resolved, err := filepath.EvalSymlinks(themePath); err != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme resolve", "%s", event.Failed}, refID, clean.Error(err))
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme resolve", status.Error(err)}, refID)
AbortNotFound(c)
return
} else {
@@ -84,7 +85,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// Check if theme path exists.
if !fs.PathExists(themePath) {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme path", "not found"}, refID)
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme path", status.NotFound}, refID)
AbortNotFound(c)
return
}
@@ -103,7 +104,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
if version, err := theme.DetectVersion(themePath); err == nil {
updateNodeThemeVersion(conf, session, version, clientIp, refID)
} else {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "detect theme version", "%s", event.Failed}, refID, clean.Error(err))
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "detect theme version", "%s", status.Failed}, refID, clean.Error(err))
}
// Add response headers.
@@ -114,14 +115,14 @@ func ClusterGetTheme(router *gin.RouterGroup) {
zipWriter := zip.NewWriter(c.Writer)
defer func(w *zip.Writer) {
if closeErr := w.Close(); closeErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme close", "%s", event.Failed}, refID, clean.Error(closeErr))
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme close", status.Error(closeErr)}, refID)
}
}(zipWriter)
err := filepath.WalkDir(themePath, func(filePath string, info gofs.DirEntry, walkErr error) error {
// Handle errors.
if walkErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme traverse", "%s", event.Failed}, refID, clean.Error(walkErr))
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme traverse", status.Error(walkErr)}, refID)
// If the error occurs on a directory, skip descending to avoid cascading errors.
if info != nil && info.IsDir() {
@@ -157,11 +158,11 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// Get the relative file name to use as alias in the zip.
alias := filepath.ToSlash(fs.RelName(filePath, themePath))
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme add", "%s", event.Added}, refID, clean.Log(alias))
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme add", "%s", status.Added}, refID, clean.Log(alias))
// Stream zipped file contents.
if zipErr := fs.ZipFile(zipWriter, filePath, alias, false); zipErr != nil {
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme add %s", "%s", event.Failed}, refID, clean.Log(alias), clean.Error(zipErr))
event.AuditWarn([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme add %s", status.Error(zipErr)}, refID, clean.Log(alias))
}
return nil
@@ -169,9 +170,9 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// Log result.
if err != nil {
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme", "%s", event.Failed}, refID, clean.Error(err))
event.AuditErr([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme", status.Error(err)}, refID)
} else {
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme", event.Succeeded}, refID)
event.AuditInfo([]string{clientIp, "session %s", string(acl.ResourceCluster), "download theme", status.Succeeded}, refID)
}
})
}
@@ -198,7 +199,7 @@ func updateNodeThemeVersion(conf *config.Config, session *entity.Session, versio
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata registry", "%s", event.Failed}, refID, clean.Error(err))
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata registry", "%s", status.Failed}, refID, clean.Error(err))
return
}
@@ -228,9 +229,9 @@ func updateNodeThemeVersion(conf *config.Config, session *entity.Session, versio
node.Theme = normalized
if err = regy.Put(node); err != nil {
event.AuditWarn([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata", "%s", event.Failed}, refID, clean.Error(err))
event.AuditWarn([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata", status.Error(err)}, refID)
return
}
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata", event.Updated}, refID)
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme metadata", status.Updated}, refID)
}

View File

@@ -4,8 +4,8 @@ import (
"errors"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -13,19 +13,19 @@ import (
// for download until the cache expires, or the server is restarted.
func Register(fileUuid, fileName string) error {
if !rnd.IsUUID(fileUuid) {
event.AuditWarn([]string{"api", "download", "create temporary token for %s", authn.Failed}, fileName)
event.AuditWarn([]string{"api", "download", "create temporary token for %s", status.Failed}, fileName)
return errors.New("invalid file uuid")
}
if fileName = fs.Abs(fileName); !fs.FileExists(fileName) {
event.AuditWarn([]string{"api", "download", "create temporary token for %s", authn.Failed}, fileName)
event.AuditWarn([]string{"api", "download", "create temporary token for %s", status.Failed}, fileName)
return errors.New("file not found")
} else if Deny(fileName) {
event.AuditErr([]string{"api", "download", "create temporary token for %s", authn.Denied}, fileName)
event.AuditErr([]string{"api", "download", "create temporary token for %s", status.Denied}, fileName)
return errors.New("forbidden file path")
}
event.AuditInfo([]string{"api", "download", "create temporary token for %s", authn.Succeeded}, fileName)
event.AuditInfo([]string{"api", "download", "create temporary token for %s", status.Succeeded}, fileName)
cache.SetDefault(fileUuid, fileName)

View File

@@ -17,10 +17,10 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -86,9 +86,9 @@ func StartImport(router *gin.RouterGroup) {
// To avoid conflicts, uploads are imported from "import_path/upload/session_ref/timestamp".
if token := path.Base(srcFolder); token != "" && path.Dir(srcFolder) == UploadPath {
srcFolder = path.Join(UploadPath, s.RefID+token)
event.AuditInfo([]string{ClientIP(c), "session %s", "import uploads from %s as %s", authn.Granted}, s.RefID, clean.Log(srcFolder), s.GetUserRole().String())
event.AuditInfo([]string{ClientIP(c), "session %s", "import uploads from %s as %s", status.Granted}, s.RefID, clean.Log(srcFolder), s.GetUserRole().String())
} else if acl.Rules.Deny(acl.ResourceFiles, s.GetUserRole(), acl.ActionManage) {
event.AuditErr([]string{ClientIP(c), "session %s", "import files from %s as %s", authn.Denied}, s.RefID, clean.Log(srcFolder), s.GetUserRole().String())
event.AuditErr([]string{ClientIP(c), "session %s", "import files from %s as %s", status.Denied}, s.RefID, clean.Log(srcFolder), s.GetUserRole().String())
AbortForbidden(c)
return
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -81,7 +82,7 @@ func OAuthRevoke(router *gin.RouterGroup) {
// Get the auth token to be revoked from the submitted form values or the request header.
if err = c.ShouldBind(&frm); err != nil && authToken == "" {
event.AuditWarn([]string{clientIp, "oauth2", actor, action, "%s"}, err)
event.AuditWarn([]string{clientIp, "oauth2", actor, action, status.Error(err)})
AbortBadRequest(c, err)
return
} else if frm.Empty() {
@@ -91,7 +92,7 @@ func OAuthRevoke(router *gin.RouterGroup) {
// Validate revocation form values.
if err = frm.Validate(); err != nil {
event.AuditWarn([]string{clientIp, "oauth2", actor, action, "%s"}, err)
event.AuditWarn([]string{clientIp, "oauth2", actor, action, status.Error(err)})
AbortInvalidCredentials(c)
return
}
@@ -133,18 +134,18 @@ func OAuthRevoke(router *gin.RouterGroup) {
// Check revocation request and abort if invalid.
if err != nil {
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", "%s"}, clean.Log(sess.RefID), role.String(), err.Error())
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", status.Error(err)}, clean.Log(sess.RefID), role.String())
AbortInvalidCredentials(c)
return
} else if sess == nil {
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", authn.Denied}, clean.Log(sess.RefID), role.String())
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", status.Denied}, clean.Log(sess.RefID), role.String())
AbortInvalidCredentials(c)
return
} else if sess.Abort(c) {
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", authn.Denied}, clean.Log(sess.RefID), role.String())
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", status.Denied}, clean.Log(sess.RefID), role.String())
return
} else if !sess.IsClient() {
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", authn.Denied}, clean.Log(sess.RefID), role.String())
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", status.Denied}, clean.Log(sess.RefID), role.String())
c.AbortWithStatusJSON(http.StatusForbidden, i18n.NewResponse(http.StatusForbidden, i18n.ErrForbidden))
return
} else if sUserUID != "" && sess.UserUID != sUserUID {
@@ -152,13 +153,13 @@ func OAuthRevoke(router *gin.RouterGroup) {
AbortInvalidCredentials(c)
return
} else {
event.AuditInfo([]string{clientIp, "oauth2", actor, action, "delete %s as %s", authn.Granted}, clean.Log(sess.RefID), role.String())
event.AuditInfo([]string{clientIp, "oauth2", actor, action, "delete %s as %s", status.Granted}, clean.Log(sess.RefID), role.String())
}
// Delete session cache and database record.
if err = sess.Delete(); err != nil {
// Log error.
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", "%s"}, clean.Log(sess.RefID), role.String(), err)
event.AuditErr([]string{clientIp, "oauth2", actor, action, "delete %s as %s", status.Error(err)}, clean.Log(sess.RefID), role.String())
// Return JSON error.
c.AbortWithStatusJSON(http.StatusNotFound, i18n.NewResponse(http.StatusNotFound, i18n.ErrNotFound))

View File

@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
@@ -17,6 +16,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
)
// OAuthToken creates a new access token for clients using OAuth2 grant types.
@@ -65,14 +65,14 @@ func OAuthToken(router *gin.RouterGroup) {
frm.ClientID = clientId
frm.ClientSecret = clientSecret
} else if err = c.ShouldBind(&frm); err != nil {
event.AuditWarn([]string{clientIp, "oauth2", actor, action, "%s"}, err)
event.AuditWarn([]string{clientIp, "oauth2", actor, action, status.Error(err)})
AbortBadRequest(c, err)
return
}
// Check the credentials for completeness and the correct format.
if err = frm.Validate(); err != nil {
event.AuditWarn([]string{clientIp, "oauth2", actor, action, "%s"}, err)
event.AuditWarn([]string{clientIp, "oauth2", actor, action, status.Error(err)})
AbortInvalidCredentials(c)
return
}
@@ -151,13 +151,13 @@ func OAuthToken(router *gin.RouterGroup) {
authUser, authProvider, authMethod, authErr := entity.Auth(loginForm, nil, c)
if authProvider.IsClient() {
event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.Denied})
event.AuditErr([]string{clientIp, "oauth2", actor, action, status.Denied})
AbortInvalidCredentials(c)
return
} else if authMethod.Is(authn.Method2FA) && errors.Is(authErr, authn.ErrPasscodeRequired) {
// Ok.
} else if authErr != nil {
event.AuditErr([]string{clientIp, "oauth2", actor, action, "%s"}, strings.ToLower(clean.Error(authErr)))
event.AuditErr([]string{clientIp, "oauth2", actor, action, status.Error(authErr)})
AbortInvalidCredentials(c)
return
} else if !authUser.Equal(s.GetUser()) {
@@ -183,7 +183,7 @@ func OAuthToken(router *gin.RouterGroup) {
// Save new session.
if sess, err = get.Session().Save(sess); err != nil {
event.AuditErr([]string{clientIp, "oauth2", actor, action, err.Error()})
event.AuditErr([]string{clientIp, "oauth2", actor, action, status.Error(err)})
AbortInvalidCredentials(c)
return
} else if sess == nil {
@@ -191,7 +191,7 @@ func OAuthToken(router *gin.RouterGroup) {
AbortUnexpectedError(c)
return
} else {
event.AuditInfo([]string{clientIp, "oauth2", actor, action, authn.Created})
event.AuditInfo([]string{clientIp, "oauth2", actor, action, status.Created})
}
// Delete any existing client sessions above the configured limit.

View File

@@ -17,6 +17,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/tz"
"github.com/photoprism/photoprism/pkg/time/unix"
@@ -346,11 +347,11 @@ func OIDCRedirect(router *gin.RouterGroup) {
// Save session after successful authentication.
if sess, err = get.Session().Save(sess); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, "%s"}, err)
event.AuditErr([]string{clientIp, "create session", "oidc", userName, status.Error(err)})
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
return
} else if sess == nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.Failed})
event.AuditErr([]string{clientIp, "create session", "oidc", userName, status.Failed})
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrUnexpected)))
return
}
@@ -362,7 +363,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))
// Log session created event.
event.AuditInfo([]string{clientIp, "session %s", "oidc", userName, authn.Created}, sess.RefID)
event.AuditInfo([]string{clientIp, "session %s", "oidc", userName, status.Created}, sess.RefID)
// Log session expiration time.
if expires := sess.ExpiresAt(); !expires.IsZero() {

View File

@@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
)
// SearchPhotos finds pictures and returns them as JSON.
@@ -48,7 +49,7 @@ func SearchPhotos(router *gin.RouterGroup) {
// Abort if request params are invalid.
if err = c.MustBindWith(&frm, binding.Form); err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "form invalid", "%s"}, s.RefID, err)
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "form invalid", status.Error(err)}, s.RefID)
AbortBadRequest(c, err)
return frm, s, err
}
@@ -84,7 +85,7 @@ func SearchPhotos(router *gin.RouterGroup) {
// Ok?
if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "search", "%s"}, s.RefID, err)
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "search", status.Error(err)}, s.RefID)
AbortBadRequest(c, err)
return
}
@@ -113,7 +114,7 @@ func SearchPhotos(router *gin.RouterGroup) {
result, count, err := search.UserPhotosViewerResults(f, s, conf.ContentUri(), conf.ApiUri(), s.PreviewToken, s.DownloadToken)
if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "view", "%s"}, s.RefID, err)
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "view", status.Error(err)}, s.RefID)
AbortBadRequest(c, err)
return
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -48,7 +49,7 @@ func SearchGeo(router *gin.RouterGroup) {
// Abort if request params are invalid.
if err = c.MustBindWith(&frm, binding.Form); err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePlaces), "form invalid", "%s"}, s.RefID, err)
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePlaces), "form invalid", status.Error(err)}, s.RefID)
AbortBadRequest(c, err)
return
}
@@ -73,7 +74,7 @@ func SearchGeo(router *gin.RouterGroup) {
// Ok?
if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePlaces), "search", "%s"}, s.RefID, err)
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePlaces), "search", status.Error(err)}, s.RefID)
AbortBadRequest(c, err)
return
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
)
// CreateSession creates a new client session (login) and returns session data.
@@ -41,7 +42,7 @@ func CreateSession(router *gin.RouterGroup) {
// Assign and validate request form values.
if err := c.BindJSON(&frm); err != nil {
event.AuditWarn([]string{clientIp, "create session", "invalid request", "%s"}, err)
event.AuditWarn([]string{clientIp, "create session", "invalid request", status.Error(err)})
AbortBadRequest(c, err)
return
}
@@ -119,7 +120,7 @@ func CreateSession(router *gin.RouterGroup) {
// Save session after successful authentication.
if sess, err = get.Session().Save(sess); err != nil {
event.AuditErr([]string{clientIp, "%s"}, err)
event.AuditErr([]string{clientIp, status.Error(err)})
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
return
} else if sess == nil {

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/gin-gonic/gin"
@@ -12,7 +13,6 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/rnd"
@@ -58,12 +58,12 @@ func DeleteSession(router *gin.RouterGroup) {
// Only admins may delete other sessions by ref id.
if rnd.IsRefID(id) {
if !acl.Rules.AllowAll(acl.ResourceSessions, s.GetUserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
event.AuditErr([]string{clientIp, "session %s", "delete %s as %s", authn.Denied}, s.RefID, acl.ResourceSessions.String(), s.GetUserRole())
event.AuditErr([]string{clientIp, "session %s", "delete %s as %s", status.Denied}, s.RefID, acl.ResourceSessions.String(), s.GetUserRole())
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
return
}
event.AuditInfo([]string{clientIp, "session %s", "delete %s as %s", authn.Granted}, s.RefID, acl.ResourceSessions.String(), s.GetUserRole())
event.AuditInfo([]string{clientIp, "session %s", "delete %s as %s", status.Granted}, s.RefID, acl.ResourceSessions.String(), s.GetUserRole())
if s = entity.FindSessionByRefID(id); s == nil {
Abort(c, http.StatusNotFound, i18n.ErrNotFound)
@@ -77,7 +77,7 @@ func DeleteSession(router *gin.RouterGroup) {
// Delete session cache and database record.
if err := s.Delete(); err != nil {
event.AuditErr([]string{clientIp, "session %s", "delete session as %s", "%s"}, s.RefID, s.GetUserRole(), err)
event.AuditErr([]string{clientIp, "session %s", "delete session as %s", status.Error(err)}, s.RefID, s.GetUserRole())
} else {
event.AuditDebug([]string{clientIp, "session %s", "deleted"}, s.RefID)
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
)
// UploadUserAvatar updates the avatar image of the specified user.
@@ -60,7 +61,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
f, err := c.MultipartForm()
if err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", status.Error(err)}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
@@ -85,7 +86,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
uploadDir, err := conf.UserUploadPath(uid, "")
if err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to create folder", "%s"}, s.RefID, err)
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "failed to create folder", status.Error(err)}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
}
@@ -99,11 +100,11 @@ func UploadUserAvatar(router *gin.RouterGroup) {
Abort(c, http.StatusBadRequest, i18n.ErrFileTooLarge)
return
} else if fReader, fErr := file.Open(); fErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", status.Error(fErr)}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
} else if mimeType, mimeErr := mimetype.DetectReader(fReader); mimeErr != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", status.Error(mimeErr)}, s.RefID)
Abort(c, http.StatusBadRequest, i18n.ErrUploadFailed)
return
} else {
@@ -133,7 +134,7 @@ func UploadUserAvatar(router *gin.RouterGroup) {
// Set user avatar image.
if err = avatar.SetUserImage(m, filePath, entity.SrcManual, conf.ThumbCachePath()); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", "%s"}, s.RefID, err)
event.AuditErr([]string{ClientIP(c), "session %s", "upload avatar", status.Error(err)}, s.RefID)
}
// Clear session cache to update user details.

View File

@@ -18,6 +18,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -82,7 +83,7 @@ func CreateUserPasscode(router *gin.RouterGroup) {
return
}
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, authn.Created}, s.RefID)
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, status.Created}, s.RefID)
header.SetLocation(c)
c.JSON(http.StatusCreated, passcode)
@@ -134,7 +135,7 @@ func ConfirmUserPasscode(router *gin.RouterGroup) {
// Return the reserved request rate limit tokens after successful authentication.
r.Success()
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, authn.Verified}, s.RefID)
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, status.Verified}, s.RefID)
// Clear session cache.
s.ClearCache()
@@ -172,7 +173,7 @@ func ActivateUserPasscode(router *gin.RouterGroup) {
}
// Log event.
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, authn.Activated}, s.RefID)
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, status.Activated}, s.RefID)
// Invalidate any other user sessions to protect the account:
// https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
@@ -235,7 +236,7 @@ func DeactivateUserPasscode(router *gin.RouterGroup) {
return
}
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, authn.Deactivated}, s.RefID)
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, status.Deactivated}, s.RefID)
// Clear session cache.
s.ClearCache()

View File

@@ -276,38 +276,38 @@ func VerifyTokenWithKeys(tokenString string, expected ExpectedClaims, keys []Pub
// Status returns diagnostic information about the verifier's current JWKS cache.
func (v *Verifier) Status(ttl time.Duration) VerifierStatus {
status := VerifierStatus{}
result := VerifierStatus{}
if ttl > 0 {
status.CacheTTLSeconds = int(ttl / time.Second)
result.CacheTTLSeconds = int(ttl / time.Second)
}
v.mu.Lock()
defer v.mu.Unlock()
status.CacheURL = v.cache.URL
status.CacheETag = v.cache.ETag
status.JWKSURL = v.cache.URL
status.KeyCount = len(v.cache.Keys)
status.KeyIDs = make([]string, 0, len(v.cache.Keys))
result.CacheURL = v.cache.URL
result.CacheETag = v.cache.ETag
result.JWKSURL = v.cache.URL
result.KeyCount = len(v.cache.Keys)
result.KeyIDs = make([]string, 0, len(v.cache.Keys))
for _, key := range v.cache.Keys {
status.KeyIDs = append(status.KeyIDs, key.Kid)
result.KeyIDs = append(result.KeyIDs, key.Kid)
}
status.CachePath = v.cachePath
result.CachePath = v.cachePath
if v.cache.FetchedAt > 0 {
fetched := time.Unix(v.cache.FetchedAt, 0).UTC()
status.CacheFetchedAt = fetched
result.CacheFetchedAt = fetched
age := time.Since(fetched)
status.CacheAgeSeconds = int64(age.Seconds())
result.CacheAgeSeconds = int64(age.Seconds())
if ttl > 0 && age > ttl {
status.CacheStale = true
result.CacheStale = true
}
}
return status
return result
}
// publicKeyForKid resolves the public key for the given key ID, fetching JWKS data if needed.

View File

@@ -18,6 +18,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -32,11 +33,11 @@ type Client struct {
func NewClient(issuerUri *url.URL, oidcClient, oidcSecret, oidcScopes, siteUrl string, insecure bool) (result *Client, err error) {
if issuerUri == nil {
err = errors.New("issuer uri required")
event.AuditErr([]string{"oidc", "provider", "%s"}, err)
event.AuditErr([]string{"oidc", "provider", status.Error(err)})
return nil, errors.New("issuer uri required")
} else if insecure == false && issuerUri.Scheme != "https" {
err = errors.New("issuer uri must use https")
event.AuditErr([]string{"oidc", "provider", "%s"}, err)
event.AuditErr([]string{"oidc", "provider", status.Error(err)})
return nil, err
}
@@ -44,20 +45,20 @@ func NewClient(issuerUri *url.URL, oidcClient, oidcSecret, oidcScopes, siteUrl s
redirectUrl, urlErr := RedirectURL(siteUrl)
if urlErr != nil {
event.AuditErr([]string{"oidc", "redirect url", "%s"}, err)
return nil, err
event.AuditErr([]string{"oidc", "redirect url", status.Error(urlErr)})
return nil, urlErr
}
// Generate cryptographic keys.
var hashKey, encryptKey []byte
if hashKey, err = rnd.RandomBytes(16); err != nil {
event.AuditErr([]string{"oidc", "hash key", "%s"}, err)
event.AuditErr([]string{"oidc", "hash key", status.Error(err)})
return nil, err
}
if encryptKey, err = rnd.RandomBytes(16); err != nil {
event.AuditErr([]string{"oidc", "encrypt key", "%s"}, err)
event.AuditErr([]string{"oidc", "encrypt key", status.Error(err)})
return nil, err
}
@@ -85,7 +86,7 @@ func NewClient(issuerUri *url.URL, oidcClient, oidcSecret, oidcScopes, siteUrl s
discover, err := client.Discover(context.TODO(), issuerUri.String(), httpClient)
if err != nil {
event.AuditErr([]string{"oidc", "provider", "service discovery", "%s"}, err)
event.AuditErr([]string{"oidc", "provider", "service discovery", status.Error(err)})
return nil, err
}
@@ -110,7 +111,7 @@ func NewClient(issuerUri *url.URL, oidcClient, oidcSecret, oidcScopes, siteUrl s
provider, err := rp.NewRelyingPartyOIDC(context.TODO(), issuerUri.String(), oidcClient, oidcSecret, redirectUrl, scopes, clientOpt...)
if err != nil {
event.AuditErr([]string{"oidc", "provider", "%s"}, err)
event.AuditErr([]string{"oidc", "provider", status.Error(err)})
return nil, err
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/log/status"
)
// HttpClient returns an HTTP client tailored for OIDC requests. When debug is true, it wraps the
@@ -32,7 +33,7 @@ func (lrt LoggingRoundTripper) RoundTrip(req *http.Request) (res *http.Response,
// Log the request method, URL and error, if any.
if err != nil {
event.AuditErr([]string{"oidc", "provider", "request", "%s %s", "%s"}, req.Method, req.URL.String(), err)
event.AuditErr([]string{"oidc", "provider", "request", "%s %s", status.Error(err)}, req.Method, req.URL.String())
} else {
event.AuditDebug([]string{"oidc", "provider", "request", "%s %s", "%s"}, req.Method, req.URL.String(), res.Status)
}

View File

@@ -88,11 +88,11 @@ func authJWTKeysListAction(ctx *cli.Context) error {
fmt.Println()
fmt.Println("JWT signing keys:")
for _, row := range rows {
status := ""
stat := ""
if row.Active {
status = " (active)"
stat = " (active)"
}
parts := []string{fmt.Sprintf("KID: %s%s", row.Kid, status)}
parts := []string{fmt.Sprintf("KID: %s%s", row.Kid, stat)}
if row.CreatedAt != "" {
parts = append(parts, fmt.Sprintf("created %s", row.CreatedAt))
}

View File

@@ -31,32 +31,32 @@ func authJWTStatusAction(ctx *cli.Context) error {
}
ttl := time.Duration(conf.JWKSCacheTTL()) * time.Second
status := verifier.Status(ttl)
status.JWKSURL = strings.TrimSpace(conf.JWKSUrl())
s := verifier.Status(ttl)
s.JWKSURL = strings.TrimSpace(conf.JWKSUrl())
if ctx.Bool("json") {
return printJSON(status)
return printJSON(s)
}
fmt.Println()
fmt.Printf("JWKS URL: %s\n", status.JWKSURL)
fmt.Printf("Cache Path: %s\n", status.CachePath)
fmt.Printf("Cache URL: %s\n", status.CacheURL)
fmt.Printf("Cache ETag: %s\n", status.CacheETag)
fmt.Printf("Cached Keys: %d\n", status.KeyCount)
if len(status.KeyIDs) > 0 {
fmt.Printf("Key IDs: %s\n", strings.Join(status.KeyIDs, ", "))
fmt.Printf("JWKS URL: %s\n", s.JWKSURL)
fmt.Printf("Cache Path: %s\n", s.CachePath)
fmt.Printf("Cache URL: %s\n", s.CacheURL)
fmt.Printf("Cache ETag: %s\n", s.CacheETag)
fmt.Printf("Cached Keys: %d\n", s.KeyCount)
if len(s.KeyIDs) > 0 {
fmt.Printf("Key IDs: %s\n", strings.Join(s.KeyIDs, ", "))
}
if !status.CacheFetchedAt.IsZero() {
fmt.Printf("Last Fetch: %s\n", status.CacheFetchedAt.Format(time.RFC3339))
if !s.CacheFetchedAt.IsZero() {
fmt.Printf("Last Fetch: %s\n", s.CacheFetchedAt.Format(time.RFC3339))
} else {
fmt.Println("Last Fetch: never")
}
fmt.Printf("Cache Age: %ds\n", status.CacheAgeSeconds)
if status.CacheTTLSeconds > 0 {
fmt.Printf("Cache TTL: %ds\n", status.CacheTTLSeconds)
fmt.Printf("Cache Age: %ds\n", s.CacheAgeSeconds)
if s.CacheTTLSeconds > 0 {
fmt.Printf("Cache TTL: %ds\n", s.CacheTTLSeconds)
}
if status.CacheStale {
if s.CacheStale {
fmt.Println("Cache Status: STALE")
} else {
fmt.Println("Cache Status: fresh")

View File

@@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/txt/report"
)
@@ -80,7 +81,7 @@ func clusterNodesListAction(ctx *cli.Context) error {
event.AuditInfo(append(who,
string(acl.ResourceCluster),
"list nodes count %d",
event.Succeeded,
status.Succeeded,
), len(out))
if ctx.Bool("json") {

View File

@@ -12,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
)
// flags for nodes mod
@@ -133,7 +134,7 @@ func clusterNodesModAction(ctx *cli.Context) error {
args = append(args, clean.Log(changeSummary))
}
segments = append(segments, event.Updated)
segments = append(segments, status.Updated)
event.AuditInfo(append(who, segments...), args...)

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster/provisioner"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
)
// ClusterNodesRemoveCommand deletes a node from the registry.
@@ -115,7 +116,7 @@ func clusterNodesRemoveAction(ctx *cli.Context) error {
event.AuditInfo(append(who,
string(acl.ResourceCluster),
"node %s",
event.Deleted,
status.Deleted,
), clean.Log(uuid))
loggedDeletion := false
@@ -134,7 +135,7 @@ func clusterNodesRemoveAction(ctx *cli.Context) error {
event.AuditInfo(append(who,
string(acl.ResourceCluster),
"drop database %s user %s",
event.Succeeded,
status.Succeeded,
), clean.Log(dbName), clean.Log(dbUser))
}
}

View File

@@ -17,6 +17,7 @@ import (
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/pkg/txt/report"
)
@@ -198,7 +199,7 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
segments = append(segments, "%s")
args = append(args, clean.Log(detail))
}
segments = append(segments, event.Succeeded)
segments = append(segments, status.Succeeded)
event.AuditInfo(append(who, segments...), args...)

View File

@@ -11,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/txt/report"
)
@@ -63,7 +64,7 @@ func clusterNodesShowAction(ctx *cli.Context) error {
event.AuditInfo(append(who,
string(acl.ResourceCluster),
"show node %s",
event.Succeeded,
status.Succeeded,
), clean.Log(dto.UUID))
if ctx.Bool("json") {

View File

@@ -25,6 +25,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt/report"
)
@@ -304,7 +305,7 @@ func clusterRegisterAction(ctx *cli.Context) error {
event.AuditInfo(append(who,
string(acl.ResourceCluster),
"register node %s",
event.Succeeded,
status.Succeeded,
), clean.Log(nodeID))
// Optional persistence

View File

@@ -52,7 +52,7 @@ func statusAction(ctx *cli.Context) error {
return err
}
var status string
var response string
if resp, reqErr := client.Do(req); reqErr != nil {
return fmt.Errorf("cannot connect to %s:%d", conf.HttpHost(), conf.HttpPort())
@@ -61,10 +61,10 @@ func statusAction(ctx *cli.Context) error {
} else if body, readErr := io.ReadAll(resp.Body); readErr != nil {
return readErr
} else {
status = string(body)
response = string(body)
}
message := gjson.Get(status, "status").String()
message := gjson.Get(response, "status").String()
if message != "" {
fmt.Println(message)

View File

@@ -1,6 +1,9 @@
package entity
import "github.com/photoprism/photoprism/internal/event"
import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/log/status"
)
// AlbumUser maps an album to a user or team and stores the associated permissions.
type AlbumUser struct {
@@ -44,7 +47,7 @@ func FirstOrCreateAlbumUser(m *AlbumUser) *AlbumUser {
if err := Db().Where("uid = ?", m.UID).First(&found).Error; err == nil {
return &found
} else if err = m.Create(); err != nil {
event.AuditErr([]string{"album %s", "failed to set owner and permissions", "%s"}, m.UID, err)
event.AuditErr([]string{"album %s", "failed to set owner and permissions", status.Error(err)}, m.UID)
return nil
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/unix"
"github.com/photoprism/photoprism/pkg/txt/report"
@@ -479,7 +480,7 @@ func (m *Client) UpdateLastActive(save bool) *Client {
if !save {
return m
} else if err := Db().Model(m).UpdateColumn("last_active", m.LastActive).Error; err != nil {
event.AuditWarn([]string{"client %s", "failed to update activity timestamp", "%s"}, m.ClientUID, err)
event.AuditWarn([]string{"client %s", "failed to update activity timestamp", status.Error(err)}, m.ClientUID)
}
return m

View File

@@ -18,6 +18,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/unix"
"github.com/photoprism/photoprism/pkg/txt"
@@ -144,7 +145,7 @@ func (m *Session) Regenerate() *Session {
// Skip deleting existing session if session ID is not set (or invalid).
} else if err := m.Delete(); err != nil {
// Failed to delete existing session.
event.AuditErr([]string{m.IP(), "session %s", "failed to delete", "%s"}, m.RefID, err)
event.AuditErr([]string{m.IP(), "session %s", "failed to delete", status.Error(err)}, m.RefID)
} else {
// Successfully deleted existing session.
event.AuditErr([]string{m.IP(), "session %s", "deleted"}, m.RefID)
@@ -856,14 +857,14 @@ func (m *Session) UpdateLastActive(save bool) *Session {
if !save {
return m
} else if err := Db().Model(m).UpdateColumn("last_active", m.LastActive).Error; err != nil {
event.AuditWarn([]string{m.IP(), "session %s", "failed to update activity timestamp", "%s"}, m.RefID, err)
event.AuditWarn([]string{m.IP(), "session %s", "failed to update activity timestamp", status.Error(err)}, m.RefID)
}
// Update the activity timestamp of the parent session, if any.
if m.GetMethod().IsNot(authn.MethodSession) || m.AuthID == "" || m.AuthID == m.ID {
return m
} else if err := Db().Table(Session{}.TableName()).Where("id = ?", m.AuthID).UpdateColumn("last_active", m.LastActive).Error; err != nil {
event.AuditWarn([]string{m.IP(), "session %s", "failed to update activity timestamp of parent session", "%s"}, m.RefID, err)
event.AuditWarn([]string{m.IP(), "session %s", "failed to update activity timestamp of parent session", status.Error(err)}, m.RefID)
}
return m

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -35,7 +36,7 @@ func FindSession(id string) (*Session, error) {
cached.UpdateLastActive(cached.LastActive <= 0)
return cached, nil
} else if err := cached.Delete(); err != nil {
event.AuditErr([]string{cached.IP(), "session %s", "failed to delete after expiration", "%s"}, cached.RefID, err)
event.AuditErr([]string{cached.IP(), "session %s", "failed to delete after expiration", status.Error(err)}, cached.RefID)
}
} else if res := Db().First(&found, "id = ?", id); res.RecordNotFound() {
return found, fmt.Errorf("invalid session")
@@ -49,7 +50,7 @@ func FindSession(id string) (*Session, error) {
CacheSession(found, SessionCacheDuration)
return found, nil
} else if err := found.Delete(); err != nil {
event.AuditErr([]string{found.IP(), "session %s", "failed to delete after expiration", "%s"}, found.RefID, err)
event.AuditErr([]string{found.IP(), "session %s", "failed to delete after expiration", status.Error(err)}, found.RefID)
}
return found, fmt.Errorf("session expired")

View File

@@ -7,6 +7,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/time/unix"
)
@@ -49,13 +50,13 @@ func DeleteChildSessions(s *Session) (deleted int) {
found := Sessions{}
if err := Db().Where("auth_id = ? AND auth_method = ?", s.ID, authn.MethodSession.String()).Find(&found).Error; err != nil {
event.AuditErr([]string{"failed to find child sessions", "%s"}, err)
event.AuditErr([]string{"failed to find child sessions", status.Error(err)})
return deleted
}
for _, sess := range found {
if err := sess.Delete(); err != nil {
event.AuditErr([]string{sess.IP(), "session %s", "failed to delete child session %s", "%s"}, s.RefID, sess.RefID, err)
event.AuditErr([]string{sess.IP(), "session %s", "failed to delete child session %s", status.Error(err)}, s.RefID, sess.RefID)
} else {
deleted++
}
@@ -95,13 +96,13 @@ func DeleteClientSessions(client *Client, authMethod authn.MethodType, limit int
found := Sessions{}
if err := q.Find(&found).Error; err != nil {
event.AuditErr([]string{"failed to fetch client sessions", "%s"}, err)
event.AuditErr([]string{"failed to fetch client sessions", status.Error(err)})
return deleted
}
for _, sess := range found {
if err := sess.Delete(); err != nil {
event.AuditErr([]string{sess.IP(), "session %s", "failed to delete", "%s"}, sess.RefID, err)
event.AuditErr([]string{sess.IP(), "session %s", "failed to delete", status.Error(err)}, sess.RefID)
} else {
deleted++
}
@@ -115,13 +116,13 @@ func DeleteExpiredSessions() (deleted int) {
found := Sessions{}
if err := Db().Where("sess_expires > 0 AND sess_expires < ?", unix.Now()).Find(&found).Error; err != nil {
event.AuditErr([]string{"failed to fetch expired sessions", "%s"}, err)
event.AuditErr([]string{"failed to fetch expired sessions", status.Error(err)})
return deleted
}
for _, sess := range found {
if err := sess.Delete(); err != nil {
event.AuditErr([]string{sess.IP(), "session %s", "failed to delete", "%s"}, sess.RefID, err)
event.AuditErr([]string{sess.IP(), "session %s", "failed to delete", status.Error(err)}, sess.RefID)
} else {
deleted++
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/i18n"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -155,7 +156,7 @@ func AuthLocal(user *User, frm form.Login, s *Session, c *gin.Context) (provider
s.SessExpires = authSess.SessExpires
}
event.AuditInfo([]string{clientIp, "session %s", "login as %s", "app password", authn.Succeeded}, s.RefID, clean.LogQuote(username))
event.AuditInfo([]string{clientIp, "session %s", "login as %s", "app password", status.Succeeded}, s.RefID, clean.LogQuote(username))
event.LoginInfo(clientIp, "api", username, s.UserAgent)
}
@@ -226,7 +227,7 @@ func AuthLocal(user *User, frm form.Login, s *Session, c *gin.Context) (provider
}
if s != nil {
event.AuditInfo([]string{clientIp, "session %s", "login as %s", authn.Succeeded}, s.RefID, clean.LogQuote(username))
event.AuditInfo([]string{clientIp, "session %s", "login as %s", status.Succeeded}, s.RefID, clean.LogQuote(username))
event.LoginInfo(clientIp, "api", username, s.UserAgent)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/list"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -180,7 +181,7 @@ func FirstOrCreateUser(m *User) *User {
if found := FindUser(*m); found != nil {
return found
} else if err := m.Create(); err != nil {
event.AuditErr([]string{"user", "failed to create", "%s"}, err)
event.AuditErr([]string{"user", "failed to create", status.Error(err)})
return nil
} else {
return m
@@ -281,14 +282,14 @@ func (m *User) InitAccount(initName, initPasswd string) (updated bool) {
// Save password.
if err := initialPasswd.Save(); err != nil {
event.AuditErr([]string{"user %s", "failed to change password", "%s"}, m.RefID, err)
event.AuditErr([]string{"user %s", "failed to change password", status.Error(err)}, m.RefID)
return false
}
// Change username if needed.
if initName != "" && initName != m.UserName {
if err := m.UpdateUsername(initName); err != nil {
event.AuditErr([]string{"user %s", "failed to change username to %s", "%s"}, m.RefID, clean.Log(initName), err)
event.AuditErr([]string{"user %s", "failed to change username to %s", status.Error(err)}, m.RefID, clean.Log(initName))
}
}
@@ -328,7 +329,7 @@ func (m *User) Delete() (err error) {
}
if err = UnscopedDb().Delete(Session{}, "user_uid = ?", m.UserUID).Error; err != nil {
event.AuditErr([]string{"user %s", "delete", "failed to remove sessions", "%s"}, m.RefID, err)
event.AuditErr([]string{"user %s", "delete", "failed to remove sessions", status.Error(err)}, m.RefID)
}
err = Db().Delete(m).Error
@@ -359,10 +360,10 @@ func (m *User) LoadRelated() *User {
// SaveRelated saves related settings and details.
func (m *User) SaveRelated() *User {
if err := m.Settings().Save(); err != nil {
event.AuditErr([]string{"user %s", "failed to save settings", "%s"}, m.RefID, err)
event.AuditErr([]string{"user %s", "failed to save settings", status.Error(err)}, m.RefID)
}
if err := m.Details().Save(); err != nil {
event.AuditErr([]string{"user %s", "failed to save details", "%s"}, m.RefID, err)
event.AuditErr([]string{"user %s", "failed to save details", status.Error(err)}, m.RefID)
}
return m
@@ -630,7 +631,7 @@ func (m *User) SetAuthID(id, issuer string) *User {
if err := UnscopedDb().Model(&User{}).
Where("user_uid <> ? AND auth_provider = ? AND auth_id = ? AND super_admin = 0", m.UserUID, m.AuthProvider, m.AuthID).
Updates(Values{"auth_id": "", "auth_provider": authn.ProviderNone}).Error; err != nil {
event.AuditErr([]string{"user %s", "failed to resolve auth id conflicts", "%s"}, m.RefID, err)
event.AuditErr([]string{"user %s", "failed to resolve auth id conflicts", status.Error(err)}, m.RefID)
}
}
@@ -902,14 +903,14 @@ func (m *User) DeleteSessions(omit []string) (deleted int) {
sess := Sessions{}
if err := stmt.Find(&sess).Error; err != nil {
event.AuditErr([]string{"user %s", "failed to invalidate sessions", "%s"}, m.RefID, err)
event.AuditErr([]string{"user %s", "failed to invalidate sessions", status.Error(err)}, m.RefID)
return 0
}
// Delete sessions from cache and database.
for _, s := range sess {
if err := s.Delete(); err != nil {
event.AuditWarn([]string{"user %s", "failed to invalidate session %s", "%s"}, m.RefID, clean.Log(s.RefID), err)
event.AuditWarn([]string{"user %s", "failed to invalidate session %s", status.Error(err)}, m.RefID, clean.Log(s.RefID))
} else {
deleted++
}
@@ -957,7 +958,7 @@ func (m *User) DeletePassword() (err error) {
// Remove local account password.
if err = pw.Delete(); err != nil {
event.AuditErr([]string{"user %s", "failed to remove password", "%s"}, m.RefID, err)
event.AuditErr([]string{"user %s", "failed to remove password", status.Error(err)}, m.RefID)
} else {
event.AuditWarn([]string{"user %s", "password has been removed"}, m.RefID)
}
@@ -1239,12 +1240,12 @@ func (m *User) RedeemToken(token string) (n int) {
share.Comment = link.Comment
if err := share.Save(); err != nil {
event.AuditErr([]string{"user %s", "token %s", "failed to redeem shares", "%s"}, m.RefID, clean.Log(token), err)
event.AuditErr([]string{"user %s", "token %s", "failed to redeem shares", status.Error(err)}, m.RefID, clean.Log(token))
} else {
link.Redeem()
}
} else if err := found.UpdateLink(link); err != nil {
event.AuditErr([]string{"user %s", "token %s", "failed to update shares", "%s"}, m.RefID, clean.Log(token), err)
event.AuditErr([]string{"user %s", "token %s", "failed to update shares", status.Error(err)}, m.RefID, clean.Log(token))
}
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/log/status"
)
// Role defaults referenced when creating built-in users.
@@ -97,7 +98,7 @@ func CreateDefaultUsers() {
// Add initial admin account.
if err := Admin.Create(); err != nil {
event.AuditErr([]string{"user", "failed to create", "%s"}, err)
event.AuditErr([]string{"user", "failed to create", status.Error(err)})
}
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -120,7 +121,7 @@ func FindUserShares(userUid string) UserShares {
// Find matching record.
if err := UnscopedDb().Find(&found, "user_uid = ? AND (expires_at IS NULL OR expires_at > ?)", userUid, Now()).Error; err != nil {
event.AuditWarn([]string{"user %s", "find shares", "%s"}, clean.Log(userUid), err)
event.AuditWarn([]string{"user %s", "find shares", status.Error(err)}, clean.Log(userUid))
return nil
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -83,7 +84,7 @@ func (m *Link) Redeem() *Link {
m.LinkViews += 1
if err := Db().Model(m).UpdateColumn("link_views", gorm.Expr("link_views + 1")).Error; err != nil {
event.AuditWarn([]string{"link %s", "update views", "%s"}, clean.Log(m.RefID), clean.Error(err))
event.AuditWarn([]string{"link %s", "update views", status.Error(err)}, clean.Log(m.RefID))
}
return m
@@ -172,7 +173,7 @@ func (m *Link) Delete() error {
// Remove related user shares.
if err := UnscopedDb().Delete(UserShare{}, "link_uid = ?", m.LinkUID).Error; err != nil {
event.AuditErr([]string{"link %s", "failed to remove related user shares", "%s"}, clean.Log(m.RefID), err)
event.AuditErr([]string{"link %s", "failed to remove related user shares", status.Error(err)}, clean.Log(m.RefID))
}
return Db().Delete(m).Error
@@ -186,7 +187,7 @@ func DeleteShareLinks(shareUid string) error {
// Remove related user shares.
if err := UnscopedDb().Delete(UserShare{}, "share_uid = ?", shareUid).Error; err != nil {
event.AuditErr([]string{"share %s", "failed to remove related user shares", "%s"}, clean.Log(shareUid), err)
event.AuditErr([]string{"share %s", "failed to remove related user shares", status.Error(err)}, clean.Log(shareUid))
}
return Db().Delete(&Link{}, "share_uid = ?", shareUid).Error
@@ -232,7 +233,7 @@ func FindLinks(token, shared string) (found Links) {
}
if err := q.Order("modified_at DESC").Find(&found).Error; err != nil {
event.AuditErr([]string{"token %s", "%s"}, clean.Log(token), err)
event.AuditErr([]string{"token %s", status.Error(err)}, clean.Log(token))
}
return found

View File

@@ -10,18 +10,18 @@ import (
)
// Status returns the current status of schema migrations.
func Status(db *gorm.DB, ids []string) (status Migrations, err error) {
status = Migrations{}
func Status(db *gorm.DB, ids []string) (result Migrations, err error) {
result = Migrations{}
if db == nil {
return status, fmt.Errorf("migrate: no database connection")
return result, fmt.Errorf("migrate: no database connection")
}
// Get SQL dialect name.
name := db.Dialect().GetName()
if name == "" {
return status, fmt.Errorf("migrate: failed to determine sql dialect")
return result, fmt.Errorf("migrate: failed to determine sql dialect")
}
// Make sure a "migrations" table exists.
@@ -30,13 +30,13 @@ func Status(db *gorm.DB, ids []string) (status Migrations, err error) {
})
if err != nil {
return status, fmt.Errorf("migrate: %s (create migrations table)", err)
return result, fmt.Errorf("migrate: %s (create migrations table)", err)
}
migrations, ok := Dialects[name]
if !ok && len(migrations) == 0 {
return status, fmt.Errorf("migrate: no migrations found for %s", name)
return result, fmt.Errorf("migrate: no migrations found for %s", name)
}
// Find previously executed migrations.
@@ -62,12 +62,12 @@ func Status(db *gorm.DB, ids []string) (status Migrations, err error) {
migration.Source = done.Source
migration.StartedAt = done.StartedAt
migration.FinishedAt = done.FinishedAt
status = append(status, migration)
result = append(result, migration)
} else {
// Should not happen.
status = append(status, migration)
result = append(result, migration)
}
}
return status, nil
return result, nil
}

View File

@@ -1,6 +1,9 @@
package entity
import "github.com/photoprism/photoprism/internal/event"
import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/log/status"
)
// PhotoUser represents the user and group ownership of a Photo and the corresponding permissions.
type PhotoUser struct {
@@ -44,7 +47,7 @@ func FirstOrCreatePhotoUser(m *PhotoUser) *PhotoUser {
if err := Db().Where("uid = ?", m.UID).First(&found).Error; err == nil {
return &found
} else if err = m.Create(); err != nil {
event.AuditErr([]string{"photo %s", "failed to set owner and permissions", "%s"}, m.UID, err)
event.AuditErr([]string{"photo %s", "failed to set owner and permissions", status.Error(err)}, m.UID)
return nil
}

View File

@@ -14,13 +14,13 @@ import (
"github.com/photoprism/photoprism/internal/entity/sortby"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/enum"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/geo"
"github.com/photoprism/photoprism/pkg/geo/pluscode"
"github.com/photoprism/photoprism/pkg/geo/s2"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
@@ -159,7 +159,7 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Visitors and other restricted users can only access shared content.
if frm.Scope != "" && album.CreatedBy != user.UserUID && !sess.HasShare(frm.Scope) && (sess.GetUser().HasSharedAccessOnly(acl.ResourcePhotos) || sess.NotRegistered()) ||
frm.Scope == "" && acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) {
event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", authn.Denied}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePhotos), aclRole)
event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", status.Denied}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePhotos), aclRole)
return PhotoResults{}, 0, ErrForbidden
}

View File

@@ -13,13 +13,13 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/enum"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/geo"
"github.com/photoprism/photoprism/pkg/geo/pluscode"
"github.com/photoprism/photoprism/pkg/geo/s2"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
@@ -137,7 +137,7 @@ func UserPhotosGeo(frm form.SearchPhotosGeo, sess *entity.Session) (results GeoR
// Visitors and other restricted users can only access shared content.
if frm.Scope != "" && album.CreatedBy != user.UserUID && !sess.HasShare(frm.Scope) && (sess.GetUser().HasSharedAccessOnly(acl.ResourcePlaces) || sess.NotRegistered()) ||
frm.Scope == "" && acl.Rules.Deny(acl.ResourcePlaces, aclRole, acl.ActionSearch) {
event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", authn.Denied}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePlaces), aclRole)
event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", status.Denied}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePlaces), aclRole)
return GeoResults{}, ErrForbidden
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -103,14 +104,14 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
}
limiter.Auth.Reserve(clientIp)
event.AuditErr([]string{clientIp, "webdav", "access as %s with authorization granted to %s", authn.Denied}, clean.Log(username), clean.Log(user.Username()))
event.AuditErr([]string{clientIp, "webdav", "access as %s with authorization granted to %s", status.Denied}, clean.Log(username), clean.Log(user.Username()))
WebDAVAbortUnauthorized(c)
return
} else if sess == nil {
// Ignore and try basic auth next.
} else if !sess.HasUser() || user == nil {
// Log error if session does not belong to an authorized user account.
event.AuditErr([]string{clientIp, "webdav", "client %s", "session %s", "access without user account", authn.Denied}, clean.Log(sess.GetClientInfo()), sess.RefID)
event.AuditErr([]string{clientIp, "webdav", "client %s", "session %s", "access without user account", status.Denied}, clean.Log(sess.GetClientInfo()), sess.RefID)
WebDAVAbortUnauthorized(c)
return
} else if sess.IsClient() && sess.InsufficientScope(acl.ResourceWebDAV, nil) {
@@ -143,7 +144,7 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
sess.UpdateLastActive(true)
// Log successful authentication.
event.AuditInfo([]string{clientIp, "webdav", "client %s", "session %s", "access as %s", authn.Succeeded}, clean.Log(sess.GetClientInfo()), sess.RefID, clean.LogQuote(user.Username()))
event.AuditInfo([]string{clientIp, "webdav", "client %s", "session %s", "access as %s", status.Succeeded}, clean.Log(sess.GetClientInfo()), sess.RefID, clean.LogQuote(user.Username()))
event.LoginInfo(clientIp, "webdav", user.Username(), api.UserAgent(c))
// Cache authentication to improve performance.
@@ -213,7 +214,7 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc {
r.Success()
// Log successful authentication.
event.AuditInfo([]string{clientIp, "webdav", "login as %s", authn.Succeeded}, clean.LogQuote(username))
event.AuditInfo([]string{clientIp, "webdav", "login as %s", status.Succeeded}, clean.LogQuote(username))
event.LoginInfo(clientIp, "webdav", username, api.UserAgent(c))
// Cache authentication to improve performance.

View File

@@ -6,8 +6,8 @@ import (
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/http/header"
"github.com/photoprism/photoprism/pkg/log/status"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -48,7 +48,7 @@ func WebDAVAuthSession(c *gin.Context, authToken string) (sess *entity.Session,
// Count error towards failure rate limit, emits audit event, and returns nil?
if sess == nil || err != nil {
limiter.Auth.Reserve(clientIp)
event.AuditErr([]string{header.ClientIP(c), "webdav", "access with invalid auth token", authn.Denied})
event.AuditErr([]string{header.ClientIP(c), "webdav", "access with invalid auth token", status.Denied})
return nil, nil, sid, false
}

View File

@@ -2,16 +2,8 @@ package authn
// Generic status messages for authentication and authorization:
const (
Failed = "failed"
Denied = "denied"
Granted = "granted"
Created = "created"
Succeeded = "succeeded"
Verified = "verified"
Activated = "activated"
Deactivated = "deactivated"
Passcode = "passcode"
Session = "session"
Sessions = "sessions"
Users = "users"
Passcode = "passcode"
Session = "session"
Sessions = "sessions"
Users = "users"
)

View File

@@ -23,7 +23,7 @@ func Error(err error) string {
switch r {
case '`', '"':
return '\''
case '\\', '$', '<', '>', '{', '}':
case '%', '\\', '$', '<', '>', '{', '}':
return '?'
default:
return r

View File

@@ -1,18 +1,19 @@
package event
package status
// Generic outcomes for use in system and audit logs.
const (
Succeeded = "succeeded"
Failed = "failed"
Denied = "denied"
Granted = "granted"
Created = "created"
Added = "added"
Updated = "updated"
Created = "created"
Deleted = "deleted"
Removed = "removed"
Succeeded = "succeeded"
NotFound = "not found"
Verified = "verified"
Activated = "activated"
Deactivated = "deactivated"
Joined = "joined"
Confirmed = "confirmed"
Synced = "synced"
)

12
pkg/log/status/error.go Normal file
View File

@@ -0,0 +1,12 @@
package status
import (
"github.com/photoprism/photoprism/pkg/clean"
)
// Error returns a sanitized string representation of err for use in audit and
// system logs, for example when an error message should be the final outcome
// token in an `event.Audit*` slice.
func Error(err error) string {
return clean.Error(err)
}

View File

@@ -0,0 +1,42 @@
package status
import (
"errors"
"testing"
"github.com/photoprism/photoprism/pkg/clean"
)
func TestError(t *testing.T) {
t.Helper()
tests := []struct {
name string
err error
}{
{
name: "Nil",
err: nil,
},
{
name: "SanitizeSpecialCharacters",
err: errors.New("permission denied { DROP TABLE users; }\n"),
},
{
name: "WhitespaceOnly",
err: errors.New(" "),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got, want := Error(tt.err), clean.Error(tt.err); got != want {
t.Fatalf("Error(%v) = %q, want %q", tt.err, got, want)
}
})
}
}

26
pkg/log/status/status.go Normal file
View File

@@ -0,0 +1,26 @@
/*
Package status defines canonical outcome tokens shared by audit and system
logging helpers so events stay consistent across packages.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package status