diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index a4b3ed792..d7544d4dc 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -34,6 +34,7 @@ import People from "page/people.vue"; import Library from "page/library.vue"; import Settings from "page/settings.vue"; import Admin from "page/admin.vue"; +import Cluster from "page/cluster.vue"; import Login from "page/auth/login.vue"; import Discover from "page/discover.vue"; import About from "page/about/about.vue"; @@ -113,6 +114,26 @@ export default [ } }, }, + { + name: "cluster", + path: "/cluster/:pathMatch(.*)*", + component: Cluster, + meta: { + title: $gettext("Cluster"), + requiresAuth: true, + settings: false, + background: "background", + }, + beforeEnter: (to, from, next) => { + if ($session.loginRequired()) { + next({ name: loginRoute }); + } else if ($config.deny("cluster", "access_all")) { + next({ name: $session.getDefaultRoute() }); + } else { + next(); + } + }, + }, { name: "upgrade", path: "/upgrade", diff --git a/frontend/src/common/config.js b/frontend/src/common/config.js index ff5373125..a4203e274 100644 --- a/frontend/src/common/config.js +++ b/frontend/src/common/config.js @@ -668,6 +668,10 @@ export default class Config { // getDefaultRoute returns the default route to use after login or in case of routing errors. getDefaultRoute() { + if (this.isPortal()) { + return "cluster"; + } + const albumsRoute = "albums"; const browseRoute = "browse"; const defaultRoute = this.deny("photos", "access_library") ? albumsRoute : browseRoute; diff --git a/frontend/src/model/logs.js b/frontend/src/model/logs.js new file mode 100644 index 000000000..a47aaf176 --- /dev/null +++ b/frontend/src/model/logs.js @@ -0,0 +1,179 @@ +import RestModel from "model/rest"; +import { $gettext } from "common/gettext"; + +const SEGMENT_SPLIT = /\s*[›>]\s*/; +const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/; +const IPV6_PATTERN = /^[0-9a-f:]+$/i; + +function looksLikeIp(value) { + if (typeof value !== "string") { + return false; + } + + const trimmed = value.trim(); + + if (!trimmed) { + return false; + } + + if (IPV4_PATTERN.test(trimmed)) { + return true; + } + + if (trimmed.includes(":") && IPV6_PATTERN.test(trimmed)) { + return true; + } + + return false; +} + +function splitSegments(message) { + if (!message) { + return []; + } + + return String(message) + .split(SEGMENT_SPLIT) + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +export const AuditSeverityNames = Object.freeze([ + "emergency", + "alert", + "critical", + "error", + "warning", + "notice", + "info", + "debug", +]); + +// LogEntry represents an audit log row returned by GET /api/v1/logs/audit. +export class LogEntry extends RestModel { + getDefaults() { + return { + ID: 0, + Time: "", + Severity: 0, + IP: "", + Message: "", + Repeated: 0, + }; + } + + // severityName returns the severity label (e.g., "info") or "info" when unknown. + severityName() { + const value = this.Severity; + + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + + if (AuditSeverityNames.includes(normalized)) { + return normalized; + } + + const numeric = Number(normalized); + + if (Number.isFinite(numeric) && numeric >= 0 && numeric < AuditSeverityNames.length) { + return AuditSeverityNames[numeric]; + } + } else if (Number.isFinite(value)) { + const index = Number(value); + + if (index >= 0 && index < AuditSeverityNames.length) { + return AuditSeverityNames[index]; + } + } + + return "info"; + } + + // severityTag returns the uppercase severity tag for badges and chips. + severityTag() { + return this.severityName().toUpperCase(); + } + + // hasRepeats indicates whether the log entry was repeated. + hasRepeats() { + return Number(this.Repeated) > 0; + } + + // messageParts splits the log message into segments while removing IPs. + messageParts() { + const segments = splitSegments(this.Message); + const explicitIp = (this.IP || "").trim(); + + return segments.filter((segment) => { + if (!segment) { + return false; + } + + if (explicitIp && segment === explicitIp) { + return false; + } + + if (!explicitIp && looksLikeIp(segment)) { + return false; + } + + return true; + }); + } + + // summary returns the first message part or the entire message if no separator is present. + summary() { + const parts = this.messageParts(); + + if (parts.length > 0) { + return parts[0]; + } + + const segments = splitSegments(this.Message); + + if (segments.length > 0) { + return segments[0]; + } + + return this.Message || ""; + } + + // messageChain joins message parts with the provided separator, falling back to the summary. + messageChain(separator = " \u203A ") { + const parts = this.messageParts(); + + if (parts.length > 0) { + return parts.join(separator); + } + + return this.summary(); + } + + // ipAddress returns the explicit IP or derives one from the message segments. + ipAddress() { + const explicitIp = (this.IP || "").trim(); + + if (explicitIp) { + return explicitIp; + } + + const segments = splitSegments(this.Message); + const detected = segments.find((segment) => looksLikeIp(segment)); + + return detected || ""; + } + + static getCollectionResource() { + return "logs/audit"; + } + + static getModelName() { + return $gettext("Audit Log"); + } + + static limit() { + return 1000; + } +} + +export default LogEntry; diff --git a/frontend/src/page/cluster.vue b/frontend/src/page/cluster.vue new file mode 100644 index 000000000..74467e37b --- /dev/null +++ b/frontend/src/page/cluster.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/tests/vitest/model/cluster-node.test.js b/frontend/tests/vitest/model/cluster-node.test.js new file mode 100644 index 000000000..6cb903d6c --- /dev/null +++ b/frontend/tests/vitest/model/cluster-node.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import ClusterNode from "../../../../pro/portal/frontend/model/cluster-node.js"; + +describe("pro/portal/model/cluster-node", () => { + it("derives the node identifier", () => { + const node = new ClusterNode({ UUID: "abc-123", Name: "portal-node" }); + + expect(node.getId()).toBe("abc-123"); + }); + + it("formats role labels", () => { + const admin = new ClusterNode({ Role: "admin" }); + const unknown = new ClusterNode({ Role: "custom" }); + + expect(admin.roleLabel()).toContain("Admin"); + expect(unknown.roleLabel()).toBe("Custom"); + }); + + it("converts labels into sorted key/value pairs", () => { + const node = new ClusterNode({ + Labels: { + beta: "true", + alpha: "42", + }, + }); + + expect(node.labelEntries()).toEqual([ + { key: "alpha", value: "42" }, + { key: "beta", value: "true" }, + ]); + }); + + it("reports database metadata availability", () => { + const node = new ClusterNode({ + Database: { + name: "photoprism", + user: "photoprism", + driver: "mysql", + }, + }); + + expect(node.hasDatabase()).toBe(true); + }); +}); + diff --git a/frontend/tests/vitest/model/logs.test.js b/frontend/tests/vitest/model/logs.test.js new file mode 100644 index 000000000..f5b30dc97 --- /dev/null +++ b/frontend/tests/vitest/model/logs.test.js @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import LogEntry, { AuditSeverityNames } from "model/logs"; + +describe("model/logs", () => { + it("provides default values", () => { + const entry = new LogEntry(); + + expect(entry.ID).toBe(0); + expect(entry.Time).toBe(""); + expect(entry.Message).toBe(""); + expect(entry.hasRepeats()).toBe(false); + expect(LogEntry.limit()).toBe(1000); + }); + + it("maps severity names and tags", () => { + const entry = new LogEntry({ Severity: 4 }); + + expect(entry.severityName()).toBe("warning"); + expect(entry.severityTag()).toBe("WARNING"); + + entry.Severity = 99; + expect(entry.severityName()).toBe("info"); + + entry.Severity = "warning"; + expect(entry.severityName()).toBe("warning"); + expect(entry.severityTag()).toBe("WARNING"); + + entry.Severity = "4"; + expect(entry.severityName()).toBe("warning"); + + entry.Severity = "ALERT"; + expect(entry.severityName()).toBe("alert"); + }); + + it("splits audit messages into parts", () => { + const entry = new LogEntry({ + Message: "172.18.0.1 › manage sessions › denied", + IP: "172.18.0.1", + }); + + expect(entry.messageParts()).toEqual(["manage sessions", "denied"]); + expect(entry.summary()).toBe("manage sessions"); + expect(entry.messageChain(" > ")).toBe("manage sessions > denied"); + expect(entry.ipAddress()).toBe("172.18.0.1"); + + entry.Message = "single message"; + entry.IP = ""; + expect(entry.messageParts()).toEqual(["single message"]); + expect(entry.summary()).toBe("single message"); + expect(entry.messageChain(" > ")).toBe("single message"); + expect(entry.ipAddress()).toBe(""); + }); + + it("derives ip addresses from messages when missing", () => { + const entry = new LogEntry({ + Message: "::1 › session sess123 › granted", + IP: "", + }); + + expect(entry.ipAddress()).toBe("::1"); + expect(entry.messageParts()).toEqual(["session sess123", "granted"]); + }); + + it("exposes the severity catalogue", () => { + expect(Array.isArray(AuditSeverityNames)).toBe(true); + expect(AuditSeverityNames).toContain("info"); + }); +}); diff --git a/internal/api/websocket_topics_test.go b/internal/api/websocket_topics_test.go new file mode 100644 index 000000000..c1bc0fccf --- /dev/null +++ b/internal/api/websocket_topics_test.go @@ -0,0 +1,21 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAppendWebsocketTopics(t *testing.T) { + original := append([]string(nil), WebsocketTopics...) + + t.Cleanup(func() { + WebsocketTopics = original + }) + + AppendWebsocketTopics("audit.log.*", "custom.topic") + + require.Len(t, WebsocketTopics, len(original)+2) + require.Contains(t, WebsocketTopics, "audit.log.*") + require.Contains(t, WebsocketTopics, "custom.topic") +} diff --git a/internal/api/websocket_writer.go b/internal/api/websocket_writer.go index 74c3e063b..234f215e3 100644 --- a/internal/api/websocket_writer.go +++ b/internal/api/websocket_writer.go @@ -13,6 +13,43 @@ import ( "github.com/photoprism/photoprism/internal/event" ) +// WebsocketTopics lists the event topics that are forwarded to connected websocket clients. +// Extensions may append additional topics during package initialization so they are subscribed +// as soon as the server starts accepting websocket connections. +var WebsocketTopics = []string{ + "user.*.*.*", + "session.*.*.*", + "log.fatal", + "log.error", + "log.warning", + "log.warn", + "log.info", + "notify.*", + "index.*", + "upload.*", + "import.*", + "config.*", + "count.*", + "photos.*", + "cameras.*", + "lenses.*", + "countries.*", + "albums.*", + "labels.*", + "subjects.*", + "people.*", + "sync.*", +} + +// AppendWebsocketTopics adds the provided topics to the global websocket topic list. +func AppendWebsocketTopics(topics ...string) { + if len(topics) == 0 { + return + } + + WebsocketTopics = append(WebsocketTopics, topics...) +} + // wsSendMessage sends a message to the WebSocket client. func wsSendMessage(topic string, data interface{}, ws *websocket.Conn, writeMutex *sync.Mutex) { if topic == "" || ws == nil || writeMutex == nil { @@ -34,30 +71,8 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) { pingTicker := time.NewTicker(15 * time.Second) // Subscribe to events. - e := event.Subscribe( - "user.*.*.*", - "session.*.*.*", - "log.fatal", - "log.error", - "log.warning", - "log.warn", - "log.info", - "notify.*", - "index.*", - "upload.*", - "import.*", - "config.*", - "count.*", - "photos.*", - "cameras.*", - "lenses.*", - "countries.*", - "albums.*", - "labels.*", - "subjects.*", - "people.*", - "sync.*", - ) + topics := append([]string(nil), WebsocketTopics...) + e := event.Subscribe(topics...) defer func() { pingTicker.Stop() @@ -104,6 +119,11 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) { // Send the message only to authorized recipients. switch len(ch) { + case 3: + // Handle audit and other three-part topics (e.g. audit.log.info). + if res := acl.Resource(ch[0]); acl.Events.AllowAll(res, user.AclRole(), wsSubscribePerms) { + wsSendMessage(ev, msg.Fields, ws, writeMutex) + } case 2: // Send to everyone who is allowed to subscribe. if res := acl.Resource(ch[0]); acl.Events.AllowAll(res, user.AclRole(), wsSubscribePerms) { diff --git a/internal/auth/acl/events.go b/internal/auth/acl/events.go index b6759a8a9..bb9c6b311 100644 --- a/internal/auth/acl/events.go +++ b/internal/auth/acl/events.go @@ -9,6 +9,10 @@ var Events = ACL{ ResourceDefault: Roles{ RoleAdmin: GrantFullAccess, }, + ChannelAudit: Roles{ + RoleAdmin: GrantFullAccess, + RolePortal: GrantFullAccess, + }, ChannelLog: Roles{ RoleAdmin: GrantFullAccess, }, diff --git a/internal/auth/acl/events_test.go b/internal/auth/acl/events_test.go new file mode 100644 index 000000000..994a1b5f2 --- /dev/null +++ b/internal/auth/acl/events_test.go @@ -0,0 +1,19 @@ +package acl + +import "testing" + +func TestEventsChannelAuditPermissions(t *testing.T) { + perms := Permissions{ActionSubscribe} + + if !Events.AllowAll(ChannelAudit, RoleAdmin, perms) { + t.Fatalf("expected admin to subscribe to audit events") + } + + if !Events.AllowAll(ChannelAudit, RolePortal, perms) { + t.Fatalf("expected portal to subscribe to audit events") + } + + if Events.AllowAll(ChannelAudit, RoleUser, perms) { + t.Fatalf("expected regular users to be denied audit event subscriptions") + } +}