refactor: make sse events isolated per org
This commit is contained in:
parent
17776606ee
commit
458799297d
10 changed files with 257 additions and 23 deletions
|
|
@ -144,7 +144,7 @@ export function useServerEvents() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener("volume:status_updated", (e) => {
|
eventSource.addEventListener("volume:status_changed", (e) => {
|
||||||
const data = JSON.parse(e.data) as VolumeEvent;
|
const data = JSON.parse(e.data) as VolumeEvent;
|
||||||
console.info("[SSE] Volume status updated:", data);
|
console.info("[SSE] Volume status updated:", data);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,60 @@ import { test, describe, expect } from "bun:test";
|
||||||
import { createApp } from "~/server/app";
|
import { createApp } from "~/server/app";
|
||||||
import { createTestSession } from "~/test/helpers/auth";
|
import { createTestSession } from "~/test/helpers/auth";
|
||||||
import { db } from "~/server/db/db";
|
import { db } from "~/server/db/db";
|
||||||
import { repositoriesTable, volumesTable, backupSchedulesTable } from "~/server/db/schema";
|
import {
|
||||||
|
repositoriesTable,
|
||||||
|
volumesTable,
|
||||||
|
backupSchedulesTable,
|
||||||
|
sessionsTable,
|
||||||
|
organization,
|
||||||
|
notificationDestinationsTable,
|
||||||
|
backupScheduleNotificationsTable,
|
||||||
|
} from "~/server/db/schema";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { generateShortId } from "~/server/utils/id";
|
import { generateShortId } from "~/server/utils/id";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
describe("multi-organization isolation", () => {
|
describe("multi-organization isolation", () => {
|
||||||
|
test("should reject requests when session active organization is not a membership", async () => {
|
||||||
|
const session = await createTestSession();
|
||||||
|
|
||||||
|
// Create a different organization the user is NOT a member of
|
||||||
|
const foreignOrgId = crypto.randomUUID();
|
||||||
|
await db.insert(organization).values({
|
||||||
|
id: foreignOrgId,
|
||||||
|
name: `Org ${foreignOrgId}`,
|
||||||
|
slug: `test-org-${foreignOrgId}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await db.insert(repositoriesTable).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
shortId: generateShortId(),
|
||||||
|
name: "Foreign Repo",
|
||||||
|
type: "local",
|
||||||
|
config: { backend: "local", name: "foreign", path: "/tmp/repo-foreign" },
|
||||||
|
organizationId: foreignOrgId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force the session to point at the foreign organization
|
||||||
|
const rawSessionToken = decodeURIComponent(session.token).split(".")[0];
|
||||||
|
await db
|
||||||
|
.update(sessionsTable)
|
||||||
|
.set({ activeOrganizationId: foreignOrgId })
|
||||||
|
.where(eq(sessionsTable.id, rawSessionToken));
|
||||||
|
|
||||||
|
const res = await app.request("/api/v1/repositories", {
|
||||||
|
headers: {
|
||||||
|
Cookie: `better-auth.session_token=${session.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.message).toBe("Invalid organization context");
|
||||||
|
});
|
||||||
|
|
||||||
test("should not be able to access repositories from another organization", async () => {
|
test("should not be able to access repositories from another organization", async () => {
|
||||||
const session1 = await createTestSession();
|
const session1 = await createTestSession();
|
||||||
const session2 = await createTestSession();
|
const session2 = await createTestSession();
|
||||||
|
|
@ -204,4 +251,89 @@ describe("multi-organization isolation", () => {
|
||||||
});
|
});
|
||||||
expect(resOk.status).toBe(200);
|
expect(resOk.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should not be able to access or modify notifications for another organization's schedule", async () => {
|
||||||
|
const session1 = await createTestSession();
|
||||||
|
const session2 = await createTestSession();
|
||||||
|
|
||||||
|
const volId = Math.floor(Math.random() * 1000000);
|
||||||
|
await db.insert(volumesTable).values({
|
||||||
|
id: volId,
|
||||||
|
shortId: generateShortId(),
|
||||||
|
name: "Org 1 Volume",
|
||||||
|
type: "directory",
|
||||||
|
config: { backend: "directory", path: "/tmp/vol1" },
|
||||||
|
organizationId: session1.organizationId,
|
||||||
|
status: "unmounted",
|
||||||
|
});
|
||||||
|
|
||||||
|
const repoId = crypto.randomUUID();
|
||||||
|
await db.insert(repositoriesTable).values({
|
||||||
|
id: repoId,
|
||||||
|
shortId: generateShortId(),
|
||||||
|
name: "Org 1 Repo",
|
||||||
|
type: "local",
|
||||||
|
config: { backend: "local", name: "org1repo", path: "/tmp/repo1" },
|
||||||
|
organizationId: session1.organizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [schedule] = await db
|
||||||
|
.insert(backupSchedulesTable)
|
||||||
|
.values({
|
||||||
|
shortId: generateShortId(),
|
||||||
|
name: "Org 1 Schedule",
|
||||||
|
volumeId: volId,
|
||||||
|
repositoryId: repoId,
|
||||||
|
cronExpression: "0 0 * * *",
|
||||||
|
organizationId: session1.organizationId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [destination] = await db
|
||||||
|
.insert(notificationDestinationsTable)
|
||||||
|
.values({
|
||||||
|
name: "Org 1 Destination",
|
||||||
|
enabled: true,
|
||||||
|
type: "discord",
|
||||||
|
config: { type: "discord", url: "https://example.com/webhook" },
|
||||||
|
organizationId: session1.organizationId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db.insert(backupScheduleNotificationsTable).values({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
destinationId: destination.id,
|
||||||
|
notifyOnStart: true,
|
||||||
|
notifyOnSuccess: true,
|
||||||
|
notifyOnWarning: true,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resGet = await app.request(`/api/v1/backups/${schedule.id}/notifications`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `better-auth.session_token=${session2.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resGet.status).toBe(404);
|
||||||
|
|
||||||
|
const resPut = await app.request(`/api/v1/backups/${schedule.id}/notifications`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Cookie: `better-auth.session_token=${session2.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
destinationId: destination.id,
|
||||||
|
notifyOnStart: false,
|
||||||
|
notifyOnSuccess: false,
|
||||||
|
notifyOnWarning: false,
|
||||||
|
notifyOnFailure: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(resPut.status).toBe(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ import type { DoctorResult } from "~/schemas/restic";
|
||||||
* Event payloads for the SSE system
|
* Event payloads for the SSE system
|
||||||
*/
|
*/
|
||||||
interface ServerEvents {
|
interface ServerEvents {
|
||||||
"backup:started": (data: { scheduleId: number; volumeName: string; repositoryName: string }) => void;
|
"backup:started": (data: { organizationId: string; scheduleId: number; volumeName: string; repositoryName: string }) => void;
|
||||||
"backup:progress": (data: {
|
"backup:progress": (data: {
|
||||||
|
organizationId: string;
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
|
|
@ -20,31 +21,44 @@ interface ServerEvents {
|
||||||
current_files: string[];
|
current_files: string[];
|
||||||
}) => void;
|
}) => void;
|
||||||
"backup:completed": (data: {
|
"backup:completed": (data: {
|
||||||
|
organizationId: string;
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error" | "stopped" | "warning";
|
status: "success" | "error" | "stopped" | "warning";
|
||||||
}) => void;
|
}) => void;
|
||||||
"mirror:started": (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => void;
|
"mirror:started": (data: {
|
||||||
|
organizationId: string;
|
||||||
|
scheduleId: number;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryName: string;
|
||||||
|
}) => void;
|
||||||
"mirror:completed": (data: {
|
"mirror:completed": (data: {
|
||||||
|
organizationId: string;
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error";
|
status: "success" | "error";
|
||||||
error?: string;
|
error?: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
"volume:mounted": (data: { volumeName: string }) => void;
|
"volume:mounted": (data: { organizationId: string; volumeName: string }) => void;
|
||||||
"volume:unmounted": (data: { volumeName: string }) => void;
|
"volume:unmounted": (data: { organizationId: string; volumeName: string }) => void;
|
||||||
"volume:updated": (data: { volumeName: string }) => void;
|
"volume:updated": (data: { organizationId: string; volumeName: string }) => void;
|
||||||
"volume:status_changed": (data: { volumeName: string; status: string }) => void;
|
"volume:status_changed": (data: { organizationId: string; volumeName: string; status: string }) => void;
|
||||||
"doctor:started": (data: { repositoryId: string; repositoryName: string }) => void;
|
"doctor:started": (data: { organizationId: string; repositoryId: string; repositoryName: string }) => void;
|
||||||
"doctor:completed": (
|
"doctor:completed": (
|
||||||
data: {
|
data: {
|
||||||
|
organizationId: string;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
} & DoctorResult,
|
} & DoctorResult,
|
||||||
) => void;
|
) => void;
|
||||||
"doctor:cancelled": (data: { repositoryId: string; repositoryName: string; error?: string }) => void;
|
"doctor:cancelled": (data: {
|
||||||
|
organizationId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryName: string;
|
||||||
|
error?: string;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ export const requireAuth = createMiddleware(async (c, next) => {
|
||||||
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
|
return c.json<unknown>({ message: "Invalid or expired session" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const membership = await db.query.member.findFirst({
|
||||||
|
where: and(eq(member.userId, user.id), eq(member.organizationId, activeOrganizationId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return c.json({ message: "Invalid organization context" }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
c.set("user", user);
|
c.set("user", user);
|
||||||
c.set("organizationId", activeOrganizationId);
|
c.set("organizationId", activeOrganizationId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
logger.info(`Starting backup ${schedule.name} for volume ${volume.name} to repository ${repository.name}`);
|
logger.info(`Starting backup ${schedule.name} for volume ${volume.name} to repository ${repository.name}`);
|
||||||
|
|
||||||
serverEvents.emit("backup:started", {
|
serverEvents.emit("backup:started", {
|
||||||
|
organizationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
|
|
@ -318,6 +319,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
organizationId,
|
organizationId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
serverEvents.emit("backup:progress", {
|
serverEvents.emit("backup:progress", {
|
||||||
|
organizationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
|
|
@ -367,6 +369,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
serverEvents.emit("backup:completed", {
|
serverEvents.emit("backup:completed", {
|
||||||
|
organizationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
|
|
@ -398,6 +401,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)));
|
.where(and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)));
|
||||||
|
|
||||||
serverEvents.emit("backup:completed", {
|
serverEvents.emit("backup:completed", {
|
||||||
|
organizationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
|
|
@ -633,6 +637,7 @@ const copyToMirrors = async (
|
||||||
logger.info(`[Background] Copying to mirror repository: ${mirror.repository.name}`);
|
logger.info(`[Background] Copying to mirror repository: ${mirror.repository.name}`);
|
||||||
|
|
||||||
serverEvents.emit("mirror:started", {
|
serverEvents.emit("mirror:started", {
|
||||||
|
organizationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
repositoryId: mirror.repositoryId,
|
repositoryId: mirror.repositoryId,
|
||||||
repositoryName: mirror.repository.name,
|
repositoryName: mirror.repository.name,
|
||||||
|
|
@ -665,6 +670,7 @@ const copyToMirrors = async (
|
||||||
logger.info(`[Background] Successfully copied to mirror repository: ${mirror.repository.name}`);
|
logger.info(`[Background] Successfully copied to mirror repository: ${mirror.repository.name}`);
|
||||||
|
|
||||||
serverEvents.emit("mirror:completed", {
|
serverEvents.emit("mirror:completed", {
|
||||||
|
organizationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
repositoryId: mirror.repositoryId,
|
repositoryId: mirror.repositoryId,
|
||||||
repositoryName: mirror.repository.name,
|
repositoryName: mirror.repository.name,
|
||||||
|
|
@ -680,6 +686,7 @@ const copyToMirrors = async (
|
||||||
.where(eq(backupScheduleMirrorsTable.id, mirror.id));
|
.where(eq(backupScheduleMirrorsTable.id, mirror.id));
|
||||||
|
|
||||||
serverEvents.emit("mirror:completed", {
|
serverEvents.emit("mirror:completed", {
|
||||||
|
organizationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
repositoryId: mirror.repositoryId,
|
repositoryId: mirror.repositoryId,
|
||||||
repositoryName: mirror.repository.name,
|
repositoryName: mirror.repository.name,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { DoctorResult } from "~/schemas/restic";
|
||||||
|
|
||||||
export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
logger.info("Client connected to SSE endpoint");
|
logger.info("Client connected to SSE endpoint");
|
||||||
|
const organizationId = c.get("organizationId");
|
||||||
|
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
|
|
@ -14,7 +15,13 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
event: "connected",
|
event: "connected",
|
||||||
});
|
});
|
||||||
|
|
||||||
const onBackupStarted = async (data: { scheduleId: number; volumeName: string; repositoryName: string }) => {
|
const onBackupStarted = async (data: {
|
||||||
|
organizationId: string;
|
||||||
|
scheduleId: number;
|
||||||
|
volumeName: string;
|
||||||
|
repositoryName: string;
|
||||||
|
}) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "backup:started",
|
event: "backup:started",
|
||||||
|
|
@ -22,6 +29,7 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBackupProgress = async (data: {
|
const onBackupProgress = async (data: {
|
||||||
|
organizationId: string;
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
|
|
@ -33,6 +41,7 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
bytes_done: number;
|
bytes_done: number;
|
||||||
current_files: string[];
|
current_files: string[];
|
||||||
}) => {
|
}) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "backup:progress",
|
event: "backup:progress",
|
||||||
|
|
@ -40,39 +49,50 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBackupCompleted = async (data: {
|
const onBackupCompleted = async (data: {
|
||||||
|
organizationId: string;
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
volumeName: string;
|
volumeName: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error" | "stopped" | "warning";
|
status: "success" | "error" | "stopped" | "warning";
|
||||||
}) => {
|
}) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "backup:completed",
|
event: "backup:completed",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onVolumeMounted = async (data: { volumeName: string }) => {
|
const onVolumeMounted = async (data: { organizationId: string; volumeName: string }) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "volume:mounted",
|
event: "volume:mounted",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onVolumeUnmounted = async (data: { volumeName: string }) => {
|
const onVolumeUnmounted = async (data: { organizationId: string; volumeName: string }) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "volume:unmounted",
|
event: "volume:unmounted",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onVolumeUpdated = async (data: { volumeName: string }) => {
|
const onVolumeUpdated = async (data: { organizationId: string; volumeName: string }) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "volume:updated",
|
event: "volume:updated",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMirrorStarted = async (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => {
|
const onMirrorStarted = async (data: {
|
||||||
|
organizationId: string;
|
||||||
|
scheduleId: number;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryName: string;
|
||||||
|
}) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "mirror:started",
|
event: "mirror:started",
|
||||||
|
|
@ -80,19 +100,22 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMirrorCompleted = async (data: {
|
const onMirrorCompleted = async (data: {
|
||||||
|
organizationId: string;
|
||||||
scheduleId: number;
|
scheduleId: number;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
status: "success" | "error";
|
status: "success" | "error";
|
||||||
error?: string;
|
error?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "mirror:completed",
|
event: "mirror:completed",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDoctorStarted = async (data: { repositoryId: string; repositoryName: string }) => {
|
const onDoctorStarted = async (data: { organizationId: string; repositoryId: string; repositoryName: string }) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "doctor:started",
|
event: "doctor:started",
|
||||||
|
|
@ -101,17 +124,25 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||||
|
|
||||||
const onDoctorCompleted = async (
|
const onDoctorCompleted = async (
|
||||||
data: {
|
data: {
|
||||||
|
organizationId: string;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
repositoryName: string;
|
repositoryName: string;
|
||||||
} & DoctorResult,
|
} & DoctorResult,
|
||||||
) => {
|
) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "doctor:completed",
|
event: "doctor:completed",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDoctorCancelled = async (data: { repositoryId: string; repositoryName: string; error?: string }) => {
|
const onDoctorCancelled = async (data: {
|
||||||
|
organizationId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryName: string;
|
||||||
|
error?: string;
|
||||||
|
}) => {
|
||||||
|
if (data.organizationId !== organizationId) return;
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
event: "doctor:cancelled",
|
event: "doctor:cancelled",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { eq, and, ne } from "drizzle-orm";
|
import { eq, and, ne, inArray } from "drizzle-orm";
|
||||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import {
|
import {
|
||||||
notificationDestinationsTable,
|
notificationDestinationsTable,
|
||||||
backupScheduleNotificationsTable,
|
backupScheduleNotificationsTable,
|
||||||
|
backupSchedulesTable,
|
||||||
type NotificationDestination,
|
type NotificationDestination,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
|
|
@ -256,6 +257,15 @@ const testDestination = async (id: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScheduleNotifications = async (scheduleId: number) => {
|
const getScheduleNotifications = async (scheduleId: number) => {
|
||||||
|
const organizationId = getOrganizationId();
|
||||||
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
|
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
|
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
|
||||||
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
|
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
|
||||||
with: {
|
with: {
|
||||||
|
|
@ -263,7 +273,7 @@ const getScheduleNotifications = async (scheduleId: number) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return assignments;
|
return assignments.filter((a) => a.destination.organizationId === organizationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateScheduleNotifications = async (
|
const updateScheduleNotifications = async (
|
||||||
|
|
@ -276,6 +286,29 @@ const updateScheduleNotifications = async (
|
||||||
notifyOnFailure: boolean;
|
notifyOnFailure: boolean;
|
||||||
}>,
|
}>,
|
||||||
) => {
|
) => {
|
||||||
|
const organizationId = getOrganizationId();
|
||||||
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
|
where: and(eq(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationIds = [...new Set(assignments.map((a) => a.destinationId))];
|
||||||
|
if (destinationIds.length > 0) {
|
||||||
|
const destinations = await db.query.notificationDestinationsTable.findMany({
|
||||||
|
where: and(
|
||||||
|
inArray(notificationDestinationsTable.id, destinationIds),
|
||||||
|
eq(notificationDestinationsTable.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (destinations.length !== destinationIds.length) {
|
||||||
|
throw new NotFoundError("One or more notification destinations were not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.delete(backupScheduleNotificationsTable).where(eq(backupScheduleNotificationsTable.scheduleId, scheduleId));
|
await db.delete(backupScheduleNotificationsTable).where(eq(backupScheduleNotificationsTable.scheduleId, scheduleId));
|
||||||
|
|
||||||
if (assignments.length > 0) {
|
if (assignments.length > 0) {
|
||||||
|
|
@ -305,6 +338,8 @@ const sendBackupNotification = async (
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
const organizationId = getOrganizationId();
|
||||||
|
|
||||||
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
|
const assignments = await db.query.backupScheduleNotificationsTable.findMany({
|
||||||
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
|
where: eq(backupScheduleNotificationsTable.scheduleId, scheduleId),
|
||||||
with: {
|
with: {
|
||||||
|
|
@ -313,6 +348,7 @@ const sendBackupNotification = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
const relevantAssignments = assignments.filter((assignment) => {
|
const relevantAssignments = assignments.filter((assignment) => {
|
||||||
|
if (assignment.destination.organizationId !== organizationId) return false;
|
||||||
if (!assignment.destination.enabled) return false;
|
if (!assignment.destination.enabled) return false;
|
||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ export const executeDoctor = async (
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
) => {
|
) => {
|
||||||
const steps: DoctorStep[] = [];
|
const steps: DoctorStep[] = [];
|
||||||
|
const organizationId = getOrganizationId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repositoryId, "doctor", signal);
|
const releaseLock = await repoMutex.acquireExclusive(repositoryId, "doctor", signal);
|
||||||
|
|
@ -151,6 +152,7 @@ export const executeDoctor = async (
|
||||||
await saveDoctorResults(repositoryId, steps, finalStatus);
|
await saveDoctorResults(repositoryId, steps, finalStatus);
|
||||||
|
|
||||||
serverEvents.emit("doctor:completed", {
|
serverEvents.emit("doctor:completed", {
|
||||||
|
organizationId,
|
||||||
repositoryId,
|
repositoryId,
|
||||||
repositoryName,
|
repositoryName,
|
||||||
success: steps.every((s) => s.success),
|
success: steps.every((s) => s.success),
|
||||||
|
|
@ -176,6 +178,7 @@ export const executeDoctor = async (
|
||||||
.where(eq(repositoriesTable.id, repositoryId));
|
.where(eq(repositoriesTable.id, repositoryId));
|
||||||
|
|
||||||
serverEvents.emit("doctor:cancelled", {
|
serverEvents.emit("doctor:cancelled", {
|
||||||
|
organizationId,
|
||||||
repositoryId,
|
repositoryId,
|
||||||
repositoryName,
|
repositoryName,
|
||||||
error: toMessage(error),
|
error: toMessage(error),
|
||||||
|
|
@ -188,6 +191,7 @@ export const executeDoctor = async (
|
||||||
|
|
||||||
steps.push({ step: "doctor", success: false, output: null, error: toMessage(error) });
|
steps.push({ step: "doctor", success: false, output: null, error: toMessage(error) });
|
||||||
serverEvents.emit("doctor:completed", {
|
serverEvents.emit("doctor:completed", {
|
||||||
|
organizationId,
|
||||||
repositoryId,
|
repositoryId,
|
||||||
repositoryName,
|
repositoryName,
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,7 @@ const startDoctor = async (id: string) => {
|
||||||
await db.update(repositoriesTable).set({ status: "doctor" }).where(eq(repositoriesTable.id, repository.id));
|
await db.update(repositoriesTable).set({ status: "doctor" }).where(eq(repositoriesTable.id, repository.id));
|
||||||
|
|
||||||
serverEvents.emit("doctor:started", {
|
serverEvents.emit("doctor:started", {
|
||||||
|
organizationId: repository.organizationId,
|
||||||
repositoryId: repository.id,
|
repositoryId: repository.id,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
});
|
});
|
||||||
|
|
@ -405,6 +406,7 @@ const cancelDoctor = async (id: string) => {
|
||||||
await db.update(repositoriesTable).set({ status: "unknown" }).where(eq(repositoriesTable.id, repository.id));
|
await db.update(repositoriesTable).set({ status: "unknown" }).where(eq(repositoriesTable.id, repository.id));
|
||||||
|
|
||||||
serverEvents.emit("doctor:cancelled", {
|
serverEvents.emit("doctor:cancelled", {
|
||||||
|
organizationId: repository.organizationId,
|
||||||
repositoryId: repository.id,
|
repositoryId: repository.id,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ const mountVolume = async (idOrShortId: string | number) => {
|
||||||
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
|
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
|
||||||
|
|
||||||
if (status === "mounted") {
|
if (status === "mounted") {
|
||||||
serverEvents.emit("volume:mounted", { volumeName: volume.name });
|
serverEvents.emit("volume:mounted", { organizationId, volumeName: volume.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error, status };
|
return { error, status };
|
||||||
|
|
@ -159,7 +159,7 @@ const unmountVolume = async (idOrShortId: string | number) => {
|
||||||
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
|
.where(and(eq(volumesTable.id, volume.id), eq(volumesTable.organizationId, organizationId)));
|
||||||
|
|
||||||
if (status === "unmounted") {
|
if (status === "unmounted") {
|
||||||
serverEvents.emit("volume:unmounted", { volumeName: volume.name });
|
serverEvents.emit("volume:unmounted", { organizationId, volumeName: volume.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error, status };
|
return { error, status };
|
||||||
|
|
@ -250,7 +250,7 @@ const updateVolume = async (idOrShortId: string | number, volumeData: UpdateVolu
|
||||||
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
|
||||||
.where(and(eq(volumesTable.id, existing.id), eq(volumesTable.organizationId, organizationId)));
|
.where(and(eq(volumesTable.id, existing.id), eq(volumesTable.organizationId, organizationId)));
|
||||||
|
|
||||||
serverEvents.emit("volume:updated", { volumeName: updated.name });
|
serverEvents.emit("volume:updated", { organizationId, volumeName: updated.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { volume: updated };
|
return { volume: updated };
|
||||||
|
|
@ -301,7 +301,7 @@ const checkHealth = async (idOrShortId: string | number) => {
|
||||||
const { error, status } = await backend.checkHealth();
|
const { error, status } = await backend.checkHealth();
|
||||||
|
|
||||||
if (status !== volume.status) {
|
if (status !== volume.status) {
|
||||||
serverEvents.emit("volume:status_changed", { volumeName: volume.name, status });
|
serverEvents.emit("volume:status_changed", { organizationId, volumeName: volume.name, status });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue