mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Portal: Add cluster admin UI #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
179
frontend/src/model/logs.js
Normal file
179
frontend/src/model/logs.js
Normal file
@@ -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;
|
||||
24
frontend/src/page/cluster.vue
Normal file
24
frontend/src/page/cluster.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<p-page-about></p-page-about>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PPageAbout from "./about/about.vue";
|
||||
|
||||
export default {
|
||||
name: "PPageCluster",
|
||||
components: { PPageAbout },
|
||||
data() {
|
||||
return {
|
||||
rtl: this.$isRtl,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$view.enter(this);
|
||||
},
|
||||
unmounted() {
|
||||
this.$view.leave(this);
|
||||
},
|
||||
methods: {},
|
||||
};
|
||||
</script>
|
||||
45
frontend/tests/vitest/model/cluster-node.test.js
Normal file
45
frontend/tests/vitest/model/cluster-node.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
68
frontend/tests/vitest/model/logs.test.js
Normal file
68
frontend/tests/vitest/model/logs.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
21
internal/api/websocket_topics_test.go
Normal file
21
internal/api/websocket_topics_test.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -13,28 +13,10 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
|
||||
writeMutex.Lock()
|
||||
defer writeMutex.Unlock()
|
||||
|
||||
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||
return
|
||||
} else if err := ws.WriteJSON(gin.H{"event": topic, "data": data}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// wsWriter initializes a WebSocket writer for sending messages.
|
||||
func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
|
||||
pingTicker := time.NewTicker(15 * time.Second)
|
||||
|
||||
// Subscribe to events.
|
||||
e := event.Subscribe(
|
||||
// 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",
|
||||
@@ -57,7 +39,40 @@ func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
|
||||
writeMutex.Lock()
|
||||
defer writeMutex.Unlock()
|
||||
|
||||
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||
return
|
||||
} else if err := ws.WriteJSON(gin.H{"event": topic, "data": data}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// wsWriter initializes a WebSocket writer for sending messages.
|
||||
func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
|
||||
pingTicker := time.NewTicker(15 * time.Second)
|
||||
|
||||
// Subscribe to events.
|
||||
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) {
|
||||
|
||||
@@ -9,6 +9,10 @@ var Events = ACL{
|
||||
ResourceDefault: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
ChannelAudit: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
RolePortal: GrantFullAccess,
|
||||
},
|
||||
ChannelLog: Roles{
|
||||
RoleAdmin: GrantFullAccess,
|
||||
},
|
||||
|
||||
19
internal/auth/acl/events_test.go
Normal file
19
internal/auth/acl/events_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user