prevent preview updates from overwriting drawings
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
JWT_SECRET_FILE="/app/prisma/.jwt_secret"
|
JWT_SECRET_FILE="/app/prisma/.jwt_secret"
|
||||||
|
CSRF_SECRET_FILE="/app/prisma/.csrf_secret"
|
||||||
|
|
||||||
# Ensure JWT secret exists for production startup.
|
# Ensure JWT secret exists for production startup.
|
||||||
# Backward compatibility: older installs may not have JWT_SECRET configured.
|
# Backward compatibility: older installs may not have JWT_SECRET configured.
|
||||||
@@ -25,6 +26,27 @@ fi
|
|||||||
|
|
||||||
export JWT_SECRET
|
export JWT_SECRET
|
||||||
|
|
||||||
|
# Ensure CSRF secret exists for stable token validation across restarts.
|
||||||
|
# (Still recommend setting explicitly for multi-instance deployments.)
|
||||||
|
if [ -z "${CSRF_SECRET:-}" ]; then
|
||||||
|
echo "CSRF_SECRET not provided, resolving persisted secret..."
|
||||||
|
if [ -f "${CSRF_SECRET_FILE}" ]; then
|
||||||
|
CSRF_SECRET="$(tr -d '\r\n' < "${CSRF_SECRET_FILE}")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${CSRF_SECRET}" ]; then
|
||||||
|
echo "No persisted CSRF secret found. Generating a new secret..."
|
||||||
|
CSRF_SECRET="$(openssl rand -base64 32)"
|
||||||
|
umask 077
|
||||||
|
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
umask 077
|
||||||
|
printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export CSRF_SECRET
|
||||||
|
|
||||||
# 1. Hydrate volume if empty (Running as root)
|
# 1. Hydrate volume if empty (Running as root)
|
||||||
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
||||||
echo "Mount is empty. Hydrating /app/prisma..."
|
echo "Mount is empty. Hydrating /app/prisma..."
|
||||||
@@ -43,11 +65,12 @@ chown -R nodejs:nodejs /app/uploads
|
|||||||
chown -R nodejs:nodejs /app/prisma
|
chown -R nodejs:nodejs /app/prisma
|
||||||
chmod 755 /app/uploads
|
chmod 755 /app/uploads
|
||||||
chmod 600 "${JWT_SECRET_FILE}"
|
chmod 600 "${JWT_SECRET_FILE}"
|
||||||
|
chmod 600 "${CSRF_SECRET_FILE}"
|
||||||
|
|
||||||
# Ensure database file has proper permissions
|
# Ensure database file has proper permissions
|
||||||
if [ -f "/app/prisma/dev.db" ]; then
|
if [ -f "/app/prisma/dev.db" ]; then
|
||||||
echo "Database file found, ensuring write permissions..."
|
echo "Database file found, ensuring write permissions..."
|
||||||
chmod 666 /app/prisma/dev.db
|
chmod 600 /app/prisma/dev.db
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Run Migrations (Drop privileges to nodejs)
|
# 3. Run Migrations (Drop privileges to nodejs)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import jwt, { SignOptions } from "jsonwebtoken";
|
||||||
|
import { StringValue } from "ms";
|
||||||
|
import { PrismaClient } from "../generated/client";
|
||||||
|
import { config } from "../config";
|
||||||
|
import { getTestPrisma, setupTestDb } from "./testUtils";
|
||||||
|
|
||||||
|
describe("Auth Enabled Toggle Authorization", () => {
|
||||||
|
const userAgent = "vitest-auth-enabled";
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let app: any;
|
||||||
|
let csrfHeaderName: string;
|
||||||
|
let csrfToken: string;
|
||||||
|
let regularUserToken: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
setupTestDb();
|
||||||
|
prisma = getTestPrisma();
|
||||||
|
|
||||||
|
({ app } = await import("../index"));
|
||||||
|
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { id: "default" },
|
||||||
|
update: {
|
||||||
|
authEnabled: true,
|
||||||
|
registrationEnabled: false,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: "default",
|
||||||
|
authEnabled: true,
|
||||||
|
registrationEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash("password123", 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: "regular-user@test.local",
|
||||||
|
passwordHash,
|
||||||
|
name: "Regular User",
|
||||||
|
role: "USER",
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signOptions: SignOptions = {
|
||||||
|
expiresIn: config.jwtAccessExpiresIn as StringValue,
|
||||||
|
};
|
||||||
|
regularUserToken = jwt.sign(
|
||||||
|
{ userId: user.id, email: user.email, type: "access" },
|
||||||
|
config.jwtSecret,
|
||||||
|
signOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
const agent = request.agent(app);
|
||||||
|
const csrfRes = await agent
|
||||||
|
.get("/csrf-token")
|
||||||
|
.set("User-Agent", userAgent);
|
||||||
|
csrfHeaderName = csrfRes.body.header;
|
||||||
|
csrfToken = csrfRes.body.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated auth-enabled toggle when auth is enabled", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/auth/auth-enabled")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.send({ enabled: false });
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-admin auth-enabled toggle", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/auth/auth-enabled")
|
||||||
|
.set("User-Agent", userAgent)
|
||||||
|
.set("Authorization", `Bearer ${regularUserToken}`)
|
||||||
|
.set(csrfHeaderName, csrfToken)
|
||||||
|
.send({ enabled: false });
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body?.message).toContain("Admin access required");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createCsrfToken, validateCsrfToken } from "../security";
|
||||||
|
|
||||||
|
describe("CSRF client identity stability", () => {
|
||||||
|
it("keeps token validation stable when using cookie-based client IDs", () => {
|
||||||
|
const cookieClientId = "cookie:fixed-client-id";
|
||||||
|
const token = createCsrfToken(cookieClientId);
|
||||||
|
|
||||||
|
expect(validateCsrfToken(cookieClientId, token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows why legacy IP-based IDs are unstable across proxy hops", () => {
|
||||||
|
const userAgent = "Mozilla/5.0 test";
|
||||||
|
const clientIdViaProxyA = `10.0.0.5:${userAgent}`;
|
||||||
|
const clientIdViaProxyB = `10.0.0.6:${userAgent}`;
|
||||||
|
const token = createCsrfToken(clientIdViaProxyA);
|
||||||
|
|
||||||
|
expect(validateCsrfToken(clientIdViaProxyB, token)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -345,7 +345,9 @@ describe("Import compatibility (legacy exports)", () => {
|
|||||||
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
|
expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"])
|
||||||
);
|
);
|
||||||
|
|
||||||
const trash = await prisma.collection.findUnique({ where: { id: "trash" } });
|
const trash = await prisma.collection.findUnique({
|
||||||
|
where: { id: "trash:bootstrap-admin" },
|
||||||
|
});
|
||||||
expect(trash).toBeTruthy();
|
expect(trash).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sanitizeDrawingUpdateData } from "../index";
|
||||||
|
|
||||||
|
describe("sanitizeDrawingUpdateData regression", () => {
|
||||||
|
it("does not inject empty scene fields for preview-only updates", () => {
|
||||||
|
const payload: {
|
||||||
|
preview?: string | null;
|
||||||
|
elements?: unknown[];
|
||||||
|
appState?: Record<string, unknown>;
|
||||||
|
files?: Record<string, unknown>;
|
||||||
|
} = {
|
||||||
|
preview: "<svg><rect width=\"10\" height=\"10\"/></svg>",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ok = sanitizeDrawingUpdateData(payload);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(typeof payload.preview).toBe("string");
|
||||||
|
expect(String(payload.preview)).toContain("<svg");
|
||||||
|
expect(payload.elements).toBeUndefined();
|
||||||
|
expect(payload.appState).toBeUndefined();
|
||||||
|
expect(payload.files).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still sanitizes scene fields when scene data is provided", () => {
|
||||||
|
const payload: {
|
||||||
|
preview?: string | null;
|
||||||
|
elements?: any[];
|
||||||
|
appState?: Record<string, unknown>;
|
||||||
|
files?: Record<string, unknown>;
|
||||||
|
} = {
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: "el-1",
|
||||||
|
type: "rectangle",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
version: 1,
|
||||||
|
versionNonce: 1,
|
||||||
|
isDeleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
appState: { viewBackgroundColor: "#ffffff" },
|
||||||
|
files: {},
|
||||||
|
preview: "<svg/>",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ok = sanitizeDrawingUpdateData(payload);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(Array.isArray(payload.elements)).toBe(true);
|
||||||
|
expect(typeof payload.appState).toBe("object");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -98,11 +98,9 @@ export const setupTestDb = () => {
|
|||||||
* Clean up the test database between tests
|
* Clean up the test database between tests
|
||||||
*/
|
*/
|
||||||
export const cleanupTestDb = async (prisma: PrismaClient) => {
|
export const cleanupTestDb = async (prisma: PrismaClient) => {
|
||||||
// Delete all drawings and collections (except Trash)
|
// Delete all drawings and collections.
|
||||||
await prisma.drawing.deleteMany({});
|
await prisma.drawing.deleteMany({});
|
||||||
await prisma.collection.deleteMany({
|
await prisma.collection.deleteMany({});
|
||||||
where: { id: { not: "trash" } },
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,14 +127,15 @@ export const createTestUser = async (prisma: PrismaClient, email: string = "test
|
|||||||
export const initTestDb = async (prisma: PrismaClient) => {
|
export const initTestDb = async (prisma: PrismaClient) => {
|
||||||
// Create a test user first
|
// Create a test user first
|
||||||
const testUser = await createTestUser(prisma);
|
const testUser = await createTestUser(prisma);
|
||||||
|
const trashCollectionId = `trash:${testUser.id}`;
|
||||||
|
|
||||||
// Ensure Trash collection exists
|
// Ensure Trash collection exists
|
||||||
const trash = await prisma.collection.findUnique({
|
const trash = await prisma.collection.findFirst({
|
||||||
where: { id: "trash" },
|
where: { id: trashCollectionId, userId: testUser.id },
|
||||||
});
|
});
|
||||||
if (!trash) {
|
if (!trash) {
|
||||||
await prisma.collection.create({
|
await prisma.collection.create({
|
||||||
data: { id: "trash", name: "Trash", userId: testUser.id },
|
data: { id: trashCollectionId, name: "Trash", userId: testUser.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-2
@@ -224,12 +224,52 @@ const requireAdmin = (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getClientId = (req: Request): string => {
|
const CSRF_CLIENT_COOKIE_NAME = "excalidash-csrf-client";
|
||||||
|
|
||||||
|
const parseCookies = (cookieHeader: string | undefined): Record<string, string> => {
|
||||||
|
if (!cookieHeader) return {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
for (const part of cookieHeader.split(";")) {
|
||||||
|
const [rawKey, ...rawValueParts] = part.split("=");
|
||||||
|
const key = rawKey?.trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const rawValue = rawValueParts.join("=").trim();
|
||||||
|
try {
|
||||||
|
cookies[key] = decodeURIComponent(rawValue);
|
||||||
|
} catch {
|
||||||
|
cookies[key] = rawValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCsrfClientCookieValue = (req: Request): string | null => {
|
||||||
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
const value = cookies[CSRF_CLIENT_COOKIE_NAME];
|
||||||
|
if (!value) return null;
|
||||||
|
if (!/^[A-Za-z0-9_-]{16,128}$/.test(value)) return null;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLegacyClientId = (req: Request): string => {
|
||||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
const userAgent = req.headers["user-agent"] || "unknown";
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
return `${ip}:${userAgent}`.slice(0, 256);
|
return `${ip}:${userAgent}`.slice(0, 256);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCsrfValidationClientIds = (req: Request): string[] => {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const cookieValue = getCsrfClientCookieValue(req);
|
||||||
|
if (cookieValue) {
|
||||||
|
candidates.push(`cookie:${cookieValue}`);
|
||||||
|
}
|
||||||
|
const legacyClientId = getLegacyClientId(req);
|
||||||
|
if (!candidates.includes(legacyClientId)) {
|
||||||
|
candidates.push(legacyClientId);
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
};
|
||||||
|
|
||||||
const requireCsrf = (req: Request, res: Response): boolean => {
|
const requireCsrf = (req: Request, res: Response): boolean => {
|
||||||
const headerName = getCsrfTokenHeader();
|
const headerName = getCsrfTokenHeader();
|
||||||
const tokenHeader = req.headers[headerName];
|
const tokenHeader = req.headers[headerName];
|
||||||
@@ -243,7 +283,9 @@ const requireCsrf = (req: Request, res: Response): boolean => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateCsrfToken(getClientId(req), token)) {
|
const clientIds = getCsrfValidationClientIds(req);
|
||||||
|
const isValidToken = clientIds.some((clientId) => validateCsrfToken(clientId, token));
|
||||||
|
if (!isValidToken) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
error: "CSRF token invalid",
|
error: "CSRF token invalid",
|
||||||
message: "Invalid or expired CSRF token. Please refresh and try again.",
|
message: "Invalid or expired CSRF token. Please refresh and try again.",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
updateEmailSchema,
|
updateEmailSchema,
|
||||||
updateProfileSchema,
|
updateProfileSchema,
|
||||||
} from "./schemas";
|
} from "./schemas";
|
||||||
|
import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity";
|
||||||
|
|
||||||
type RegisterAccountRoutesDeps = {
|
type RegisterAccountRoutesDeps = {
|
||||||
router: express.Router;
|
router: express.Router;
|
||||||
@@ -81,7 +82,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await prisma.passwordResetToken.create({
|
await prisma.passwordResetToken.create({
|
||||||
data: { userId: user.id, token: resetToken, expiresAt },
|
data: { userId: user.id, token: hashTokenForStorage(resetToken), expiresAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.enableAuditLogging) {
|
if (config.enableAuditLogging) {
|
||||||
@@ -137,8 +138,10 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { token, password } = parsed.data;
|
const { token, password } = parsed.data;
|
||||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
const resetToken = await prisma.passwordResetToken.findFirst({
|
||||||
where: { token },
|
where: {
|
||||||
|
OR: getTokenLookupCandidates(token).map((candidate) => ({ token: candidate })),
|
||||||
|
},
|
||||||
include: { user: true },
|
include: { user: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,7 +351,11 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
|||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
try {
|
try {
|
||||||
await prisma.refreshToken.create({
|
await prisma.refreshToken.create({
|
||||||
data: { userId: updatedUser.id, token: refreshToken, expiresAt },
|
data: {
|
||||||
|
userId: updatedUser.id,
|
||||||
|
token: hashTokenForStorage(refreshToken),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
@@ -525,7 +532,11 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
|
|||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
try {
|
try {
|
||||||
await prisma.refreshToken.create({
|
await prisma.refreshToken.create({
|
||||||
data: { userId: updatedUser.id, token: refreshToken, expiresAt },
|
data: {
|
||||||
|
userId: updatedUser.id,
|
||||||
|
token: hashTokenForStorage(refreshToken),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
loginRateLimitUpdateSchema,
|
loginRateLimitUpdateSchema,
|
||||||
registrationToggleSchema,
|
registrationToggleSchema,
|
||||||
} from "./schemas";
|
} from "./schemas";
|
||||||
|
import { hashTokenForStorage } from "./tokenSecurity";
|
||||||
|
|
||||||
type RegisterAdminRoutesDeps = {
|
type RegisterAdminRoutesDeps = {
|
||||||
router: express.Router;
|
router: express.Router;
|
||||||
@@ -610,7 +611,7 @@ export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => {
|
|||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
try {
|
try {
|
||||||
await prisma.refreshToken.create({
|
await prisma.refreshToken.create({
|
||||||
data: { userId: target.id, token: refreshToken, expiresAt },
|
data: { userId: target.id, token: hashTokenForStorage(refreshToken), expiresAt },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
loginSchema,
|
loginSchema,
|
||||||
registerSchema,
|
registerSchema,
|
||||||
} from "./schemas";
|
} from "./schemas";
|
||||||
|
import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity";
|
||||||
|
|
||||||
type RegisterCoreRoutesDeps = {
|
type RegisterCoreRoutesDeps = {
|
||||||
router: express.Router;
|
router: express.Router;
|
||||||
@@ -86,6 +87,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
bootstrapUserId,
|
bootstrapUserId,
|
||||||
defaultSystemConfigId,
|
defaultSystemConfigId,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
|
||||||
|
|
||||||
router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => {
|
router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@@ -139,13 +141,14 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingTrash = await prisma.collection.findUnique({
|
const trashCollectionId = getUserTrashCollectionId(user.id);
|
||||||
where: { id: "trash" },
|
const existingTrash = await prisma.collection.findFirst({
|
||||||
|
where: { id: trashCollectionId, userId: user.id },
|
||||||
});
|
});
|
||||||
if (!existingTrash) {
|
if (!existingTrash) {
|
||||||
await prisma.collection.create({
|
await prisma.collection.create({
|
||||||
data: {
|
data: {
|
||||||
id: "trash",
|
id: trashCollectionId,
|
||||||
name: "Trash",
|
name: "Trash",
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
@@ -157,7 +160,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
if (config.enableRefreshTokenRotation) {
|
if (config.enableRefreshTokenRotation) {
|
||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
await prisma.refreshToken.create({
|
await prisma.refreshToken.create({
|
||||||
data: { userId: user.id, token: refreshToken, expiresAt },
|
data: { userId: user.id, token: hashTokenForStorage(refreshToken), expiresAt },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,13 +240,14 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingTrash = await prisma.collection.findUnique({
|
const trashCollectionId = getUserTrashCollectionId(user.id);
|
||||||
where: { id: "trash" },
|
const existingTrash = await prisma.collection.findFirst({
|
||||||
|
where: { id: trashCollectionId, userId: user.id },
|
||||||
});
|
});
|
||||||
if (!existingTrash) {
|
if (!existingTrash) {
|
||||||
await prisma.collection.create({
|
await prisma.collection.create({
|
||||||
data: {
|
data: {
|
||||||
id: "trash",
|
id: trashCollectionId,
|
||||||
name: "Trash",
|
name: "Trash",
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
@@ -259,7 +263,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
await prisma.refreshToken.create({
|
await prisma.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token: refreshToken,
|
token: hashTokenForStorage(refreshToken),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -372,7 +376,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
await prisma.refreshToken.create({
|
await prisma.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token: refreshToken,
|
token: hashTokenForStorage(refreshToken),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -464,8 +468,12 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
const expiresAt = getRefreshTokenExpiresAt();
|
const expiresAt = getRefreshTokenExpiresAt();
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
const storedToken = await tx.refreshToken.findUnique({
|
const storedToken = await tx.refreshToken.findFirst({
|
||||||
where: { token: oldRefreshToken },
|
where: {
|
||||||
|
OR: getTokenLookupCandidates(oldRefreshToken).map((candidate) => ({
|
||||||
|
token: candidate,
|
||||||
|
})),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!storedToken || storedToken.userId !== user.id || storedToken.revoked) {
|
if (!storedToken || storedToken.userId !== user.id || storedToken.revoked) {
|
||||||
@@ -487,7 +495,7 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
await tx.refreshToken.create({
|
await tx.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token: newRefreshToken,
|
token: hashTokenForStorage(newRefreshToken),
|
||||||
expiresAt,
|
expiresAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -638,9 +646,19 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/auth-enabled", optionalAuth, async (req: Request, res: Response) => {
|
router.post("/auth-enabled", requireAuth, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!requireCsrf(req, res)) return;
|
if (!requireCsrf(req, res)) return;
|
||||||
|
if (!req.user) {
|
||||||
|
return res
|
||||||
|
.status(401)
|
||||||
|
.json({ error: "Unauthorized", message: "User not authenticated" });
|
||||||
|
}
|
||||||
|
if (req.user.role !== "ADMIN") {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Forbidden", message: "Admin access required" });
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = authEnabledToggleSchema.safeParse(req.body);
|
const parsed = authEnabledToggleSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -653,19 +671,6 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
|
|||||||
const current = systemConfig.authEnabled;
|
const current = systemConfig.authEnabled;
|
||||||
const next = parsed.data.enabled;
|
const next = parsed.data.enabled;
|
||||||
|
|
||||||
if (current && !next) {
|
|
||||||
if (!req.user) {
|
|
||||||
return res
|
|
||||||
.status(401)
|
|
||||||
.json({ error: "Unauthorized", message: "User not authenticated" });
|
|
||||||
}
|
|
||||||
if (req.user.role !== "ADMIN") {
|
|
||||||
return res
|
|
||||||
.status(403)
|
|
||||||
.json({ error: "Forbidden", message: "Admin access required" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!current && next) {
|
if (!current && next) {
|
||||||
const bootstrap = await prisma.user.findUnique({
|
const bootstrap = await prisma.user.findUnique({
|
||||||
where: { id: bootstrapUserId },
|
where: { id: bootstrapUserId },
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
export const hashTokenForStorage = (token: string): string =>
|
||||||
|
crypto.createHash("sha256").update(token, "utf8").digest("hex");
|
||||||
|
|
||||||
|
export const getTokenLookupCandidates = (token: string): string[] => {
|
||||||
|
const candidates = new Set<string>();
|
||||||
|
candidates.add(token);
|
||||||
|
candidates.add(hashTokenForStorage(token));
|
||||||
|
return [...candidates];
|
||||||
|
};
|
||||||
+205
-22
@@ -202,23 +202,32 @@ const invalidateDrawingsCache = () => {
|
|||||||
drawingsCache.clear();
|
drawingsCache.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
|
||||||
|
|
||||||
const ensureTrashCollection = async (
|
const ensureTrashCollection = async (
|
||||||
db: Prisma.TransactionClient | PrismaClient,
|
db: Prisma.TransactionClient | PrismaClient,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const trashCollection = await db.collection.findUnique({
|
const trashCollectionId = getUserTrashCollectionId(userId);
|
||||||
where: { id: "trash" },
|
const trashCollection = await db.collection.findFirst({
|
||||||
|
where: { id: trashCollectionId, userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!trashCollection) {
|
if (!trashCollection) {
|
||||||
await db.collection.create({
|
await db.collection.create({
|
||||||
data: {
|
data: {
|
||||||
id: "trash",
|
id: trashCollectionId,
|
||||||
name: "Trash",
|
name: "Trash",
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy migration: move this user's drawings off global "trash".
|
||||||
|
await db.drawing.updateMany({
|
||||||
|
where: { userId, collectionId: "trash" },
|
||||||
|
data: { collectionId: trashCollectionId },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -375,13 +384,109 @@ app.use(generalRateLimiter);
|
|||||||
|
|
||||||
// CSRF Protection Middleware
|
// CSRF Protection Middleware
|
||||||
// Generates a unique client ID based on IP and User-Agent for token association
|
// Generates a unique client ID based on IP and User-Agent for token association
|
||||||
const getClientId = (req: express.Request): string => {
|
const CSRF_CLIENT_COOKIE_NAME = "excalidash-csrf-client";
|
||||||
|
const CSRF_CLIENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
|
||||||
|
|
||||||
|
const parseCookies = (cookieHeader: string | undefined): Record<string, string> => {
|
||||||
|
if (!cookieHeader) return {};
|
||||||
|
const cookies: Record<string, string> = {};
|
||||||
|
for (const part of cookieHeader.split(";")) {
|
||||||
|
const [rawKey, ...rawValueParts] = part.split("=");
|
||||||
|
const key = rawKey?.trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const rawValue = rawValueParts.join("=").trim();
|
||||||
|
try {
|
||||||
|
cookies[key] = decodeURIComponent(rawValue);
|
||||||
|
} catch {
|
||||||
|
cookies[key] = rawValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookies;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCsrfClientCookieValue = (req: express.Request): string | null => {
|
||||||
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
const value = cookies[CSRF_CLIENT_COOKIE_NAME];
|
||||||
|
if (!value) return null;
|
||||||
|
if (!/^[A-Za-z0-9_-]{16,128}$/.test(value)) return null;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestUsesHttps = (req: express.Request): boolean => {
|
||||||
|
if (req.secure) return true;
|
||||||
|
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||||
|
const raw = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||||
|
const firstHop = String(raw || "")
|
||||||
|
.split(",")[0]
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return firstHop === "https";
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCsrfClientCookie = (req: express.Request, res: express.Response, value: string): void => {
|
||||||
|
const secure = requestUsesHttps(req) ? "; Secure" : "";
|
||||||
|
res.append(
|
||||||
|
"Set-Cookie",
|
||||||
|
`${CSRF_CLIENT_COOKIE_NAME}=${encodeURIComponent(
|
||||||
|
value
|
||||||
|
)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${CSRF_CLIENT_COOKIE_MAX_AGE_SECONDS}${secure}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLegacyClientId = (req: express.Request): string => {
|
||||||
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
const userAgent = req.headers["user-agent"] || "unknown";
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
const clientId = `${ip}:${userAgent}`.slice(0, 256);
|
return `${ip}:${userAgent}`.slice(0, 256);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientIdForTokenIssue = (
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response
|
||||||
|
): { clientId: string; strategy: "cookie" | "legacy-bootstrap" } => {
|
||||||
|
const existingCookieValue = getCsrfClientCookieValue(req);
|
||||||
|
if (existingCookieValue) {
|
||||||
|
return {
|
||||||
|
clientId: `cookie:${existingCookieValue}`,
|
||||||
|
strategy: "cookie",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cookie presented by client yet:
|
||||||
|
// - issue a token bound to legacy identity for compatibility with non-cookie clients
|
||||||
|
// - still set a cookie so subsequent browser requests can transition to cookie-bound tokens
|
||||||
|
const generatedCookieValue = uuidv4().replace(/-/g, "");
|
||||||
|
setCsrfClientCookie(req, res, generatedCookieValue);
|
||||||
|
return {
|
||||||
|
clientId: getLegacyClientId(req),
|
||||||
|
strategy: "legacy-bootstrap",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientIdCandidatesForValidation = (req: express.Request): string[] => {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
const cookieValue = getCsrfClientCookieValue(req);
|
||||||
|
if (cookieValue) {
|
||||||
|
candidates.push(`cookie:${cookieValue}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyClientId = getLegacyClientId(req);
|
||||||
|
if (!candidates.includes(legacyClientId)) {
|
||||||
|
candidates.push(legacyClientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientIdForTokenIssueDebug = (
|
||||||
|
req: express.Request,
|
||||||
|
res: express.Response
|
||||||
|
): string => {
|
||||||
|
const { clientId, strategy } = getClientIdForTokenIssue(req, res);
|
||||||
|
|
||||||
// Debug logging for CSRF troubleshooting (issue #38)
|
// Debug logging for CSRF troubleshooting (issue #38)
|
||||||
if (process.env.DEBUG_CSRF === "true") {
|
if (process.env.DEBUG_CSRF === "true") {
|
||||||
|
const validationCandidates = getClientIdCandidatesForValidation(req);
|
||||||
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
console.log("[CSRF DEBUG] getClientId", {
|
console.log("[CSRF DEBUG] getClientId", {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
path: req.path,
|
path: req.path,
|
||||||
@@ -389,9 +494,13 @@ const getClientId = (req: express.Request): string => {
|
|||||||
remoteAddress: req.connection.remoteAddress,
|
remoteAddress: req.connection.remoteAddress,
|
||||||
"x-forwarded-for": req.headers["x-forwarded-for"],
|
"x-forwarded-for": req.headers["x-forwarded-for"],
|
||||||
"x-real-ip": req.headers["x-real-ip"],
|
"x-real-ip": req.headers["x-real-ip"],
|
||||||
userAgent: userAgent.slice(0, 100),
|
hasCsrfCookie: Boolean(getCsrfClientCookieValue(req)),
|
||||||
clientIdPreview: clientId.slice(0, 60) + "...",
|
clientIdPreview: clientId.slice(0, 60) + "...",
|
||||||
trustProxySetting: req.app.get("trust proxy"),
|
trustProxySetting: req.app.get("trust proxy"),
|
||||||
|
strategy,
|
||||||
|
validationCandidatesPreview: validationCandidates.map((candidate) =>
|
||||||
|
`${candidate.slice(0, 60)}...`
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +545,7 @@ app.get("/csrf-token", (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = getClientId(req);
|
const clientId = getClientIdForTokenIssueDebug(req, res);
|
||||||
const token = createCsrfToken(clientId);
|
const token = createCsrfToken(clientId);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -487,7 +596,7 @@ const csrfProtectionMiddleware = (
|
|||||||
// Some legitimate clients/proxies might strip these, so we don't block strictly on their absence,
|
// Some legitimate clients/proxies might strip these, so we don't block strictly on their absence,
|
||||||
// but relying on the token is the primary defense.
|
// but relying on the token is the primary defense.
|
||||||
|
|
||||||
const clientId = getClientId(req);
|
const clientIdCandidates = getClientIdCandidatesForValidation(req);
|
||||||
const headerName = getCsrfTokenHeader();
|
const headerName = getCsrfTokenHeader();
|
||||||
const tokenHeader = req.headers[headerName];
|
const tokenHeader = req.headers[headerName];
|
||||||
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
|
const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader;
|
||||||
@@ -499,7 +608,10 @@ const csrfProtectionMiddleware = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateCsrfToken(clientId, token)) {
|
const isValidToken = clientIdCandidates.some((clientId) =>
|
||||||
|
validateCsrfToken(clientId, token)
|
||||||
|
);
|
||||||
|
if (!isValidToken) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: "CSRF token invalid",
|
error: "CSRF token invalid",
|
||||||
message: "Invalid or expired CSRF token. Please refresh and try again.",
|
message: "Invalid or expired CSRF token. Please refresh and try again.",
|
||||||
@@ -555,24 +667,34 @@ const drawingCreateSchema = drawingBaseSchema
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const drawingUpdateSchema = drawingBaseSchema
|
const drawingUpdateSchemaBase = drawingBaseSchema
|
||||||
.extend({
|
.extend({
|
||||||
elements: elementSchema.array().optional(),
|
elements: elementSchema.array().optional(),
|
||||||
appState: appStateSchema.optional(),
|
appState: appStateSchema.optional(),
|
||||||
files: filesFieldSchema,
|
files: filesFieldSchema,
|
||||||
version: z.number().int().positive().optional(),
|
version: z.number().int().positive().optional(),
|
||||||
})
|
});
|
||||||
.refine(
|
|
||||||
(data) => {
|
export const sanitizeDrawingUpdateData = (
|
||||||
const needsSanitization =
|
data: {
|
||||||
|
elements?: unknown[];
|
||||||
|
appState?: Record<string, unknown>;
|
||||||
|
files?: Record<string, unknown>;
|
||||||
|
preview?: string | null;
|
||||||
|
name?: string;
|
||||||
|
collectionId?: string | null;
|
||||||
|
}
|
||||||
|
): boolean => {
|
||||||
|
const hasSceneFields =
|
||||||
data.elements !== undefined ||
|
data.elements !== undefined ||
|
||||||
data.appState !== undefined ||
|
data.appState !== undefined ||
|
||||||
data.files !== undefined ||
|
data.files !== undefined;
|
||||||
data.preview !== undefined;
|
const hasPreviewField = data.preview !== undefined;
|
||||||
|
const needsSanitization = hasSceneFields || hasPreviewField;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sanitizedData = { ...data };
|
const sanitizedData = { ...data };
|
||||||
if (needsSanitization) {
|
if (hasSceneFields) {
|
||||||
const fullData = {
|
const fullData = {
|
||||||
elements: Array.isArray(data.elements) ? data.elements : [],
|
elements: Array.isArray(data.elements) ? data.elements : [],
|
||||||
appState:
|
appState:
|
||||||
@@ -588,8 +710,14 @@ const drawingUpdateSchema = drawingBaseSchema
|
|||||||
sanitizedData.elements = sanitized.elements;
|
sanitizedData.elements = sanitized.elements;
|
||||||
sanitizedData.appState = sanitized.appState;
|
sanitizedData.appState = sanitized.appState;
|
||||||
if (data.files !== undefined) sanitizedData.files = sanitized.files;
|
if (data.files !== undefined) sanitizedData.files = sanitized.files;
|
||||||
if (data.preview !== undefined)
|
if (data.preview !== undefined) sanitizedData.preview = sanitized.preview;
|
||||||
sanitizedData.preview = sanitized.preview;
|
Object.assign(data, sanitizedData);
|
||||||
|
} else if (hasPreviewField && typeof data.preview === "string") {
|
||||||
|
// Preview-only updates must not inject default scene fields.
|
||||||
|
data.preview = sanitizeSvg(data.preview);
|
||||||
|
Object.assign(data, { ...data, preview: data.preview });
|
||||||
|
} else if (hasPreviewField && data.preview === null) {
|
||||||
|
// Explicitly allow clearing preview without touching scene data.
|
||||||
Object.assign(data, sanitizedData);
|
Object.assign(data, sanitizedData);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -600,7 +728,10 @@ const drawingUpdateSchema = drawingBaseSchema
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const drawingUpdateSchema = drawingUpdateSchemaBase.refine(
|
||||||
|
(data) => sanitizeDrawingUpdateData(data as any),
|
||||||
{
|
{
|
||||||
message: "Invalid or malicious drawing data detected",
|
message: "Invalid or malicious drawing data detected",
|
||||||
}
|
}
|
||||||
@@ -726,6 +857,33 @@ const roomUsers = new Map<string, User[]>();
|
|||||||
// Track which authenticated user owns each socket for authorization checks
|
// Track which authenticated user owns each socket for authorization checks
|
||||||
const socketUserMap = new Map<string, string>();
|
const socketUserMap = new Map<string, string>();
|
||||||
|
|
||||||
|
const toPresenceName = (value: unknown): string => {
|
||||||
|
if (typeof value !== "string") return "User";
|
||||||
|
const trimmed = value.trim().slice(0, 120);
|
||||||
|
return trimmed.length > 0 ? trimmed : "User";
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPresenceInitials = (name: string): string => {
|
||||||
|
const words = name
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0);
|
||||||
|
if (words.length === 0) return "U";
|
||||||
|
const first = words[0]?.[0] ?? "";
|
||||||
|
const second = words.length > 1 ? words[1]?.[0] ?? "" : "";
|
||||||
|
const initials = `${first}${second}`.toUpperCase().slice(0, 2);
|
||||||
|
return initials.length > 0 ? initials : "U";
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPresenceColor = (value: unknown): string => {
|
||||||
|
if (typeof value !== "string") return "#4f46e5";
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return "#4f46e5";
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify JWT from Socket.io auth and check if auth is required.
|
* Verify JWT from Socket.io auth and check if auth is required.
|
||||||
* When auth is disabled (single-user mode), all connections are allowed.
|
* When auth is disabled (single-user mode), all connections are allowed.
|
||||||
@@ -815,10 +973,35 @@ io.on("connection", (socket) => {
|
|||||||
socket.join(roomId);
|
socket.join(roomId);
|
||||||
authorizedDrawingIds.add(drawingId);
|
authorizedDrawingIds.add(drawingId);
|
||||||
|
|
||||||
const newUser: User = { ...user, socketId: socket.id, isActive: true };
|
let trustedUserId =
|
||||||
|
typeof user?.id === "string" && user.id.trim().length > 0
|
||||||
|
? user.id.trim().slice(0, 200)
|
||||||
|
: socket.id;
|
||||||
|
let trustedName = toPresenceName(user?.name);
|
||||||
|
|
||||||
|
// In auth-enabled mode, identity should come from the authenticated account.
|
||||||
|
if (authenticatedUserId && authenticatedUserId !== "bootstrap-admin") {
|
||||||
|
const account = await prisma.user.findUnique({
|
||||||
|
where: { id: authenticatedUserId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (account) {
|
||||||
|
trustedUserId = account.id;
|
||||||
|
trustedName = toPresenceName(account.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser: User = {
|
||||||
|
id: trustedUserId,
|
||||||
|
name: trustedName,
|
||||||
|
initials: toPresenceInitials(trustedName),
|
||||||
|
color: toPresenceColor(user?.color),
|
||||||
|
socketId: socket.id,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
const currentUsers = roomUsers.get(roomId) || [];
|
const currentUsers = roomUsers.get(roomId) || [];
|
||||||
const filteredUsers = currentUsers.filter((u) => u.id !== user.id);
|
const filteredUsers = currentUsers.filter((u) => u.id !== newUser.id);
|
||||||
filteredUsers.push(newUser);
|
filteredUsers.push(newUser);
|
||||||
roomUsers.set(roomId, filteredUsers);
|
roomUsers.set(roomId, filteredUsers);
|
||||||
|
|
||||||
|
|||||||
@@ -79,11 +79,30 @@ export const registerDashboardRoutes = (
|
|||||||
logAuditEvent,
|
logAuditEvent,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
|
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
|
||||||
|
const isTrashCollectionId = (
|
||||||
|
collectionId: string | null | undefined,
|
||||||
|
userId: string
|
||||||
|
): boolean =>
|
||||||
|
Boolean(collectionId) &&
|
||||||
|
(collectionId === "trash" || collectionId === getUserTrashCollectionId(userId));
|
||||||
|
const toInternalTrashCollectionId = (
|
||||||
|
collectionId: string | null | undefined,
|
||||||
|
userId: string
|
||||||
|
): string | null | undefined =>
|
||||||
|
collectionId === "trash" ? getUserTrashCollectionId(userId) : collectionId;
|
||||||
|
const toPublicTrashCollectionId = (
|
||||||
|
collectionId: string | null | undefined,
|
||||||
|
userId: string
|
||||||
|
): string | null | undefined =>
|
||||||
|
isTrashCollectionId(collectionId, userId) ? "trash" : collectionId;
|
||||||
|
|
||||||
app.get("/drawings", requireAuth, asyncHandler(async (req, res) => {
|
app.get("/drawings", requireAuth, asyncHandler(async (req, res) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: "Unauthorized" });
|
return res.status(401).json({ error: "Unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trashCollectionId = getUserTrashCollectionId(req.user.id);
|
||||||
const { search, collectionId, includeData, limit, offset, sortField, sortDirection } = req.query;
|
const { search, collectionId, includeData, limit, offset, sortField, sortDirection } = req.query;
|
||||||
const where: Prisma.DrawingWhereInput = { userId: req.user.id };
|
const where: Prisma.DrawingWhereInput = { userId: req.user.id };
|
||||||
const searchTerm =
|
const searchTerm =
|
||||||
@@ -100,7 +119,7 @@ export const registerDashboardRoutes = (
|
|||||||
} else if (collectionId) {
|
} else if (collectionId) {
|
||||||
const normalizedCollectionId = String(collectionId);
|
const normalizedCollectionId = String(collectionId);
|
||||||
if (normalizedCollectionId === "trash") {
|
if (normalizedCollectionId === "trash") {
|
||||||
where.collectionId = "trash";
|
where.collectionId = { in: [trashCollectionId, "trash"] };
|
||||||
collectionFilterKey = "trash";
|
collectionFilterKey = "trash";
|
||||||
} else {
|
} else {
|
||||||
const collection = await prisma.collection.findFirst({
|
const collection = await prisma.collection.findFirst({
|
||||||
@@ -113,7 +132,10 @@ export const registerDashboardRoutes = (
|
|||||||
collectionFilterKey = `id:${normalizedCollectionId}`;
|
collectionFilterKey = `id:${normalizedCollectionId}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }];
|
where.OR = [
|
||||||
|
{ collectionId: { notIn: [trashCollectionId, "trash"] } },
|
||||||
|
{ collectionId: null },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldIncludeData =
|
const shouldIncludeData =
|
||||||
@@ -188,10 +210,16 @@ export const registerDashboardRoutes = (
|
|||||||
if (shouldIncludeData) {
|
if (shouldIncludeData) {
|
||||||
responsePayload = (drawings as any[]).map((d: any) => ({
|
responsePayload = (drawings as any[]).map((d: any) => ({
|
||||||
...d,
|
...d,
|
||||||
|
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
|
||||||
elements: parseJsonField(d.elements, []),
|
elements: parseJsonField(d.elements, []),
|
||||||
appState: parseJsonField(d.appState, {}),
|
appState: parseJsonField(d.appState, {}),
|
||||||
files: parseJsonField(d.files, {}),
|
files: parseJsonField(d.files, {}),
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
responsePayload = (drawings as any[]).map((d: any) => ({
|
||||||
|
...d,
|
||||||
|
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalResponse = {
|
const finalResponse = {
|
||||||
@@ -223,6 +251,7 @@ export const registerDashboardRoutes = (
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
...drawing,
|
...drawing,
|
||||||
|
collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user.id),
|
||||||
elements: parseJsonField(drawing.elements, []),
|
elements: parseJsonField(drawing.elements, []),
|
||||||
appState: parseJsonField(drawing.appState, {}),
|
appState: parseJsonField(drawing.appState, {}),
|
||||||
files: parseJsonField(drawing.files, {}),
|
files: parseJsonField(drawing.files, {}),
|
||||||
@@ -254,14 +283,16 @@ export const registerDashboardRoutes = (
|
|||||||
files?: Record<string, unknown>;
|
files?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
const drawingName = payload.name ?? "Untitled Drawing";
|
const drawingName = payload.name ?? "Untitled Drawing";
|
||||||
const targetCollectionId = payload.collectionId === undefined ? null : payload.collectionId;
|
const targetCollectionIdRaw = payload.collectionId === undefined ? null : payload.collectionId;
|
||||||
|
const targetCollectionId =
|
||||||
|
toInternalTrashCollectionId(targetCollectionIdRaw, req.user.id) ?? null;
|
||||||
|
|
||||||
if (targetCollectionId && targetCollectionId !== "trash") {
|
if (targetCollectionId && !isTrashCollectionId(targetCollectionId, req.user.id)) {
|
||||||
const collection = await prisma.collection.findFirst({
|
const collection = await prisma.collection.findFirst({
|
||||||
where: { id: targetCollectionId, userId: req.user.id },
|
where: { id: targetCollectionId, userId: req.user.id },
|
||||||
});
|
});
|
||||||
if (!collection) return res.status(404).json({ error: "Collection not found" });
|
if (!collection) return res.status(404).json({ error: "Collection not found" });
|
||||||
} else if (targetCollectionId === "trash") {
|
} else if (targetCollectionIdRaw === "trash") {
|
||||||
await ensureTrashCollection(prisma, req.user.id);
|
await ensureTrashCollection(prisma, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +311,7 @@ export const registerDashboardRoutes = (
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
...newDrawing,
|
...newDrawing,
|
||||||
|
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
|
||||||
elements: parseJsonField(newDrawing.elements, []),
|
elements: parseJsonField(newDrawing.elements, []),
|
||||||
appState: parseJsonField(newDrawing.appState, {}),
|
appState: parseJsonField(newDrawing.appState, {}),
|
||||||
files: parseJsonField(newDrawing.files, {}),
|
files: parseJsonField(newDrawing.files, {}),
|
||||||
@@ -312,11 +344,14 @@ export const registerDashboardRoutes = (
|
|||||||
files?: Record<string, unknown>;
|
files?: Record<string, unknown>;
|
||||||
version?: number;
|
version?: number;
|
||||||
};
|
};
|
||||||
|
const trashCollectionId = getUserTrashCollectionId(req.user.id);
|
||||||
const isSceneUpdate =
|
const isSceneUpdate =
|
||||||
payload.elements !== undefined ||
|
payload.elements !== undefined ||
|
||||||
payload.appState !== undefined ||
|
payload.appState !== undefined ||
|
||||||
payload.files !== undefined;
|
payload.files !== undefined;
|
||||||
const data: Prisma.DrawingUpdateInput = { version: { increment: 1 } };
|
const data: Prisma.DrawingUpdateInput = isSceneUpdate
|
||||||
|
? { version: { increment: 1 } }
|
||||||
|
: {};
|
||||||
|
|
||||||
if (payload.name !== undefined) data.name = payload.name;
|
if (payload.name !== undefined) data.name = payload.name;
|
||||||
if (payload.elements !== undefined) data.elements = JSON.stringify(payload.elements);
|
if (payload.elements !== undefined) data.elements = JSON.stringify(payload.elements);
|
||||||
@@ -327,7 +362,7 @@ export const registerDashboardRoutes = (
|
|||||||
if (payload.collectionId !== undefined) {
|
if (payload.collectionId !== undefined) {
|
||||||
if (payload.collectionId === "trash") {
|
if (payload.collectionId === "trash") {
|
||||||
await ensureTrashCollection(prisma, req.user.id);
|
await ensureTrashCollection(prisma, req.user.id);
|
||||||
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = "trash";
|
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = trashCollectionId;
|
||||||
} else if (payload.collectionId) {
|
} else if (payload.collectionId) {
|
||||||
const collection = await prisma.collection.findFirst({
|
const collection = await prisma.collection.findFirst({
|
||||||
where: { id: payload.collectionId, userId: req.user.id },
|
where: { id: payload.collectionId, userId: req.user.id },
|
||||||
@@ -374,6 +409,7 @@ export const registerDashboardRoutes = (
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
...updatedDrawing,
|
...updatedDrawing,
|
||||||
|
collectionId: toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id),
|
||||||
elements: parseJsonField(updatedDrawing.elements, []),
|
elements: parseJsonField(updatedDrawing.elements, []),
|
||||||
appState: parseJsonField(updatedDrawing.appState, {}),
|
appState: parseJsonField(updatedDrawing.appState, {}),
|
||||||
files: parseJsonField(updatedDrawing.files, {}),
|
files: parseJsonField(updatedDrawing.files, {}),
|
||||||
@@ -415,8 +451,10 @@ export const registerDashboardRoutes = (
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
||||||
if (!original) return res.status(404).json({ error: "Original drawing not found" });
|
if (!original) return res.status(404).json({ error: "Original drawing not found" });
|
||||||
if (original.collectionId === "trash") {
|
let duplicatedCollectionId = original.collectionId;
|
||||||
|
if (isTrashCollectionId(original.collectionId, req.user.id)) {
|
||||||
await ensureTrashCollection(prisma, req.user.id);
|
await ensureTrashCollection(prisma, req.user.id);
|
||||||
|
duplicatedCollectionId = getUserTrashCollectionId(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDrawing = await prisma.drawing.create({
|
const newDrawing = await prisma.drawing.create({
|
||||||
@@ -426,7 +464,7 @@ export const registerDashboardRoutes = (
|
|||||||
appState: original.appState,
|
appState: original.appState,
|
||||||
files: original.files,
|
files: original.files,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
collectionId: original.collectionId,
|
collectionId: duplicatedCollectionId,
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -434,6 +472,7 @@ export const registerDashboardRoutes = (
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
...newDrawing,
|
...newDrawing,
|
||||||
|
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
|
||||||
elements: parseJsonField(newDrawing.elements, []),
|
elements: parseJsonField(newDrawing.elements, []),
|
||||||
appState: parseJsonField(newDrawing.appState, {}),
|
appState: parseJsonField(newDrawing.appState, {}),
|
||||||
files: parseJsonField(newDrawing.files, {}),
|
files: parseJsonField(newDrawing.files, {}),
|
||||||
@@ -442,11 +481,21 @@ export const registerDashboardRoutes = (
|
|||||||
|
|
||||||
app.get("/collections", requireAuth, asyncHandler(async (req, res) => {
|
app.get("/collections", requireAuth, asyncHandler(async (req, res) => {
|
||||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
const trashCollectionId = getUserTrashCollectionId(req.user.id);
|
||||||
|
await ensureTrashCollection(prisma, req.user.id);
|
||||||
|
|
||||||
const collections = await prisma.collection.findMany({
|
const rawCollections = await prisma.collection.findMany({
|
||||||
where: { userId: req.user.id },
|
where: { userId: req.user.id },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
const hasInternalTrash = rawCollections.some((collection) => collection.id === trashCollectionId);
|
||||||
|
const collections = rawCollections
|
||||||
|
.filter((collection) => !(hasInternalTrash && collection.id === "trash"))
|
||||||
|
.map((collection) =>
|
||||||
|
collection.id === trashCollectionId
|
||||||
|
? { ...collection, id: "trash", name: "Trash" }
|
||||||
|
: collection
|
||||||
|
);
|
||||||
return res.json(collections);
|
return res.json(collections);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -472,6 +521,12 @@ export const registerDashboardRoutes = (
|
|||||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
if (isTrashCollectionId(id, req.user.id)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Validation error",
|
||||||
|
message: "Trash collection cannot be renamed",
|
||||||
|
});
|
||||||
|
}
|
||||||
const existingCollection = await prisma.collection.findFirst({
|
const existingCollection = await prisma.collection.findFirst({
|
||||||
where: { id, userId: req.user.id },
|
where: { id, userId: req.user.id },
|
||||||
});
|
});
|
||||||
@@ -506,6 +561,12 @@ export const registerDashboardRoutes = (
|
|||||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
if (isTrashCollectionId(id, req.user.id)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Validation error",
|
||||||
|
message: "Trash collection cannot be deleted",
|
||||||
|
});
|
||||||
|
}
|
||||||
const collection = await prisma.collection.findFirst({
|
const collection = await prisma.collection.findFirst({
|
||||||
where: { id, userId: req.user.id },
|
where: { id, userId: req.user.id },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -146,6 +146,21 @@ const normalizeNonEmptyId = (value: unknown): string | null => {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`;
|
||||||
|
|
||||||
|
const isTrashCollectionId = (
|
||||||
|
collectionId: string | null | undefined,
|
||||||
|
userId: string
|
||||||
|
): boolean =>
|
||||||
|
Boolean(collectionId) &&
|
||||||
|
(collectionId === "trash" || collectionId === getUserTrashCollectionId(userId));
|
||||||
|
|
||||||
|
const toPublicTrashCollectionId = (
|
||||||
|
collectionId: string | null | undefined,
|
||||||
|
userId: string
|
||||||
|
): string | null =>
|
||||||
|
isTrashCollectionId(collectionId, userId) ? "trash" : collectionId ?? null;
|
||||||
|
|
||||||
const findSqliteTable = (tables: string[], candidates: string[]): string | null => {
|
const findSqliteTable = (tables: string[], candidates: string[]): string | null => {
|
||||||
const byLower = new Map(tables.map((t) => [t.toLowerCase(), t]));
|
const byLower = new Map(tables.map((t) => [t.toLowerCase(), t]));
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -264,6 +279,7 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => {
|
|||||||
|
|
||||||
app.get("/export/excalidash", requireAuth, asyncHandler(async (req, res) => {
|
app.get("/export/excalidash", requireAuth, asyncHandler(async (req, res) => {
|
||||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||||
|
const trashCollectionId = getUserTrashCollectionId(req.user.id);
|
||||||
|
|
||||||
const extParam = typeof req.query.ext === "string" ? req.query.ext.toLowerCase() : "";
|
const extParam = typeof req.query.ext === "string" ? req.query.ext.toLowerCase() : "";
|
||||||
const zipSuffix = extParam === "zip";
|
const zipSuffix = extParam === "zip";
|
||||||
@@ -281,10 +297,23 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => {
|
|||||||
where: { userId: req.user.id },
|
where: { userId: req.user.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasTrashDrawings = drawings.some((d) => d.collectionId === "trash");
|
const hasInternalTrashCollection = userCollections.some((collection) => collection.id === trashCollectionId);
|
||||||
const collectionsToExport = [...userCollections];
|
const normalizedUserCollections = userCollections.filter(
|
||||||
if (hasTrashDrawings && !collectionsToExport.some((c) => c.id === "trash")) {
|
(collection) => !(hasInternalTrashCollection && collection.id === "trash")
|
||||||
const trash = await prisma.collection.findUnique({ where: { id: "trash" } });
|
);
|
||||||
|
const hasTrashDrawings = drawings.some((drawing) =>
|
||||||
|
isTrashCollectionId(drawing.collectionId, req.user!.id)
|
||||||
|
);
|
||||||
|
const collectionsToExport = [...normalizedUserCollections];
|
||||||
|
if (
|
||||||
|
hasTrashDrawings &&
|
||||||
|
!collectionsToExport.some((collection) =>
|
||||||
|
isTrashCollectionId(collection.id, req.user!.id)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const trash = await prisma.collection.findFirst({
|
||||||
|
where: { userId: req.user.id, id: { in: [trashCollectionId, "trash"] } },
|
||||||
|
});
|
||||||
if (trash) collectionsToExport.push(trash);
|
if (trash) collectionsToExport.push(trash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,13 +338,23 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => {
|
|||||||
id: drawing.id,
|
id: drawing.id,
|
||||||
name: drawing.name,
|
name: drawing.name,
|
||||||
filePath: `${folder}/${fileName}`,
|
filePath: `${folder}/${fileName}`,
|
||||||
collectionId: drawing.collectionId ?? null,
|
collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user!.id),
|
||||||
version: drawing.version,
|
version: drawing.version,
|
||||||
createdAt: drawing.createdAt.toISOString(),
|
createdAt: drawing.createdAt.toISOString(),
|
||||||
updatedAt: drawing.updatedAt.toISOString(),
|
updatedAt: drawing.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const manifestCollections = collectionsToExport
|
||||||
|
.map((collection) => ({
|
||||||
|
id: toPublicTrashCollectionId(collection.id, req.user!.id) || collection.id,
|
||||||
|
name: isTrashCollectionId(collection.id, req.user!.id) ? "Trash" : collection.name,
|
||||||
|
folder: folderByCollectionId.get(collection.id) || sanitizePathSegment(collection.name, "Collection"),
|
||||||
|
createdAt: collection.createdAt.toISOString(),
|
||||||
|
updatedAt: collection.updatedAt.toISOString(),
|
||||||
|
}))
|
||||||
|
.filter((collection, index, all) => all.findIndex((c) => c.id === collection.id) === index);
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
format: "excalidash" as const,
|
format: "excalidash" as const,
|
||||||
formatVersion: 1 as const,
|
formatVersion: 1 as const,
|
||||||
@@ -323,13 +362,7 @@ export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => {
|
|||||||
excalidashBackendVersion: getBackendVersion(),
|
excalidashBackendVersion: getBackendVersion(),
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
unorganizedFolder,
|
unorganizedFolder,
|
||||||
collections: collectionsToExport.map((c) => ({
|
collections: manifestCollections,
|
||||||
id: c.id,
|
|
||||||
name: c.name,
|
|
||||||
folder: folderByCollectionId.get(c.id) || sanitizePathSegment(c.name, "Collection"),
|
|
||||||
createdAt: c.createdAt.toISOString(),
|
|
||||||
updatedAt: c.updatedAt.toISOString(),
|
|
||||||
})),
|
|
||||||
drawings: drawingsManifest,
|
drawings: drawingsManifest,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -657,6 +690,7 @@ Drawings: ${drawings.length}
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const trashCollectionId = getUserTrashCollectionId(req.user!.id);
|
||||||
const collectionIdMap = new Map<string, string>();
|
const collectionIdMap = new Map<string, string>();
|
||||||
let collectionsCreated = 0;
|
let collectionsCreated = 0;
|
||||||
let collectionsUpdated = 0;
|
let collectionsUpdated = 0;
|
||||||
@@ -672,7 +706,7 @@ Drawings: ${drawings.length}
|
|||||||
|
|
||||||
for (const c of manifest.collections) {
|
for (const c of manifest.collections) {
|
||||||
if (c.id === "trash") {
|
if (c.id === "trash") {
|
||||||
collectionIdMap.set("trash", "trash");
|
collectionIdMap.set("trash", trashCollectionId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +741,7 @@ Drawings: ${drawings.length}
|
|||||||
|
|
||||||
const resolveCollectionId = (collectionId: string | null): string | null => {
|
const resolveCollectionId = (collectionId: string | null): string | null => {
|
||||||
if (!collectionId) return null;
|
if (!collectionId) return null;
|
||||||
if (collectionId === "trash") return "trash";
|
if (collectionId === "trash") return trashCollectionId;
|
||||||
return collectionIdMap.get(collectionId) || null;
|
return collectionIdMap.get(collectionId) || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1006,6 +1040,7 @@ Drawings: ${drawings.length}
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const trashCollectionId = getUserTrashCollectionId(req.user!.id);
|
||||||
const hasTrash = importedDrawings.some((d) => String(d.collectionId || "") === "trash");
|
const hasTrash = importedDrawings.some((d) => String(d.collectionId || "") === "trash");
|
||||||
if (hasTrash) await ensureTrashCollection(tx, req.user!.id);
|
if (hasTrash) await ensureTrashCollection(tx, req.user!.id);
|
||||||
|
|
||||||
@@ -1022,7 +1057,7 @@ Drawings: ${drawings.length}
|
|||||||
const name = typeof c.name === "string" ? c.name : "Collection";
|
const name = typeof c.name === "string" ? c.name : "Collection";
|
||||||
|
|
||||||
if (importedId === "trash" || name === "Trash") {
|
if (importedId === "trash" || name === "Trash") {
|
||||||
collectionIdMap.set(importedId || "trash", "trash");
|
collectionIdMap.set(importedId || "trash", trashCollectionId);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,7 +1106,7 @@ Drawings: ${drawings.length}
|
|||||||
const id = typeof rawCollectionId === "string" ? rawCollectionId : null;
|
const id = typeof rawCollectionId === "string" ? rawCollectionId : null;
|
||||||
const name = typeof rawCollectionName === "string" ? rawCollectionName : null;
|
const name = typeof rawCollectionName === "string" ? rawCollectionName : null;
|
||||||
|
|
||||||
if (id === "trash" || name === "Trash") return "trash";
|
if (id === "trash" || name === "Trash") return trashCollectionId;
|
||||||
if (id && collectionIdMap.has(id)) return collectionIdMap.get(id)!;
|
if (id && collectionIdMap.has(id)) return collectionIdMap.get(id)!;
|
||||||
if (name && collectionIdMap.has(`__name:${name}`)) return collectionIdMap.get(`__name:${name}`)!;
|
if (name && collectionIdMap.has(`__name:${name}`)) return collectionIdMap.get(`__name:${name}`)!;
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ services:
|
|||||||
# if unset, backend auto-generates and persists one in the volume.
|
# if unset, backend auto-generates and persists one in the volume.
|
||||||
# Recommended to set explicitly for portability and multi-instance setups.
|
# Recommended to set explicitly for portability and multi-instance setups.
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
- CSRF_SECRET=${CSRF_SECRET}
|
||||||
# - CSRF_SECRET=${CSRF_SECRET}
|
|
||||||
volumes:
|
volumes:
|
||||||
- backend-data:/app/prisma
|
- backend-data:/app/prisma
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+1
-2
@@ -12,8 +12,7 @@ services:
|
|||||||
# if unset, backend auto-generates and persists one in the volume.
|
# if unset, backend auto-generates and persists one in the volume.
|
||||||
# Recommended to set explicitly for portability and multi-instance setups.
|
# Recommended to set explicitly for portability and multi-instance setups.
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
# Required for horizontal scaling (k8s): uncomment and set to same value on all instances
|
- CSRF_SECRET=${CSRF_SECRET}
|
||||||
# - CSRF_SECRET=${CSRF_SECRET}
|
|
||||||
volumes:
|
volumes:
|
||||||
- backend-data:/app/prisma
|
- backend-data:/app/prisma
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+16
-2
@@ -6,6 +6,14 @@ http {
|
|||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Preserve upstream TLS context (if present) when proxying to backend.
|
||||||
|
# Falls back to current scheme when no forwarded proto is provided.
|
||||||
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
|
default $scheme;
|
||||||
|
~*^https(?:,|$) https;
|
||||||
|
~*^http(?:,|$) http;
|
||||||
|
}
|
||||||
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -21,6 +29,12 @@ http {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https: ws: wss:;" always;
|
||||||
|
|
||||||
# API and WebSocket proxy to backend
|
# API and WebSocket proxy to backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000/;
|
proxy_pass http://backend:8000/;
|
||||||
@@ -31,7 +45,7 @@ http {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
|
||||||
# Buffer and timeout settings for large payloads
|
# Buffer and timeout settings for large payloads
|
||||||
proxy_buffering on;
|
proxy_buffering on;
|
||||||
@@ -56,7 +70,7 @@ http {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Frontend routes
|
# Frontend routes
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ http {
|
|||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Preserve upstream TLS context (if present) when proxying to backend.
|
||||||
|
# Falls back to current scheme when no forwarded proto is provided.
|
||||||
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
|
default $scheme;
|
||||||
|
~*^https(?:,|$) https;
|
||||||
|
~*^http(?:,|$) http;
|
||||||
|
}
|
||||||
|
|
||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -21,6 +29,12 @@ http {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https: ws: wss:;" always;
|
||||||
|
|
||||||
# API and WebSocket proxy to backend
|
# API and WebSocket proxy to backend
|
||||||
# BACKEND_URL is substituted at container startup (default: backend:8000)
|
# BACKEND_URL is substituted at container startup (default: backend:8000)
|
||||||
location /api/ {
|
location /api/ {
|
||||||
@@ -32,7 +46,7 @@ http {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
|
||||||
# Buffer and timeout settings for large payloads
|
# Buffer and timeout settings for large payloads
|
||||||
proxy_buffering on;
|
proxy_buffering on;
|
||||||
@@ -57,7 +71,7 @@ http {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $forwarded_proto;
|
||||||
|
|
||||||
# Longer timeouts for WebSocket connections
|
# Longer timeouts for WebSocket connections
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
|
|||||||
Generated
+40
-2572
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@excalidraw/excalidraw": "^0.18.0",
|
"@excalidraw/excalidraw": "0.17.6",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { AuthProvider, useAuth } from "./AuthContext";
|
import { AuthProvider, useAuth } from "./AuthContext";
|
||||||
|
|
||||||
const Probe = () => {
|
const Probe = () => {
|
||||||
@@ -15,6 +15,10 @@ const Probe = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("AuthProvider", () => {
|
describe("AuthProvider", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults to auth-enabled mode if /auth/status fails", async () => {
|
it("defaults to auth-enabled mode if /auth/status fails", async () => {
|
||||||
const storage = new Map<string, string>();
|
const storage = new Map<string, string>();
|
||||||
Object.defineProperty(window, "localStorage", {
|
Object.defineProperty(window, "localStorage", {
|
||||||
@@ -45,4 +49,42 @@ describe("AuthProvider", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByTestId("auth-enabled").textContent).toBe("true");
|
expect(screen.getByTestId("auth-enabled").textContent).toBe("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears stored auth state when backend reports auth disabled", async () => {
|
||||||
|
const storage = new Map<string, string>([
|
||||||
|
["excalidash-access-token", "token"],
|
||||||
|
["excalidash-refresh-token", "refresh"],
|
||||||
|
["excalidash-user", JSON.stringify({ id: "u1" })],
|
||||||
|
]);
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
storage.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
storage.delete(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(axios, "get").mockResolvedValueOnce({ data: { authEnabled: false } });
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Probe />
|
||||||
|
</AuthProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("loading").textContent).toBe("false");
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("auth-enabled").textContent).toBe("false");
|
||||||
|
expect(storage.get("excalidash-access-token")).toBeUndefined();
|
||||||
|
expect(storage.get("excalidash-refresh-token")).toBeUndefined();
|
||||||
|
expect(storage.get("excalidash-user")).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
// In single-user mode, do not require login.
|
// In single-user mode, do not require login.
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,7 +340,12 @@ export const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
const handleRenameDrawing = async (id: string, name: string) => {
|
const handleRenameDrawing = async (id: string, name: string) => {
|
||||||
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
|
setDrawings(prev => prev.map(d => d.id === id ? { ...d, name } : d));
|
||||||
|
try {
|
||||||
await api.updateDrawing(id, { name });
|
await api.updateDrawing(id, { name });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename drawing:", err);
|
||||||
|
refreshData();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteDrawing = async (id: string) => {
|
const handleDeleteDrawing = async (id: string) => {
|
||||||
@@ -537,14 +542,24 @@ export const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateCollection = async (name: string) => {
|
const handleCreateCollection = async (name: string) => {
|
||||||
|
try {
|
||||||
await api.createCollection(name);
|
await api.createCollection(name);
|
||||||
const newCollections = await api.getCollections();
|
const newCollections = await api.getCollections();
|
||||||
setCollections(newCollections);
|
setCollections(newCollections);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create collection:", err);
|
||||||
|
refreshData();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditCollection = async (id: string, name: string) => {
|
const handleEditCollection = async (id: string, name: string) => {
|
||||||
setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c));
|
setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c));
|
||||||
|
try {
|
||||||
await api.updateCollection(id, name);
|
await api.updateCollection(id, name);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename collection:", err);
|
||||||
|
refreshData();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteCollection = async (id: string) => {
|
const handleDeleteCollection = async (id: string) => {
|
||||||
@@ -552,8 +567,13 @@ export const Dashboard: React.FC = () => {
|
|||||||
if (selectedCollectionId === id) {
|
if (selectedCollectionId === id) {
|
||||||
setSelectedCollectionId(undefined);
|
setSelectedCollectionId(undefined);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await api.deleteCollection(id);
|
await api.deleteCollection(id);
|
||||||
refreshData();
|
refreshData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete collection:", err);
|
||||||
|
refreshData();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewTitle = React.useMemo(() => {
|
const viewTitle = React.useMemo(() => {
|
||||||
|
|||||||
+259
-21
@@ -3,7 +3,6 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { ArrowLeft, Download, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
|
import { ArrowLeft, Download, Loader2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw';
|
import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw';
|
||||||
import '@excalidraw/excalidraw/index.css';
|
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import { Toaster, toast } from 'sonner';
|
import { Toaster, toast } from 'sonner';
|
||||||
@@ -22,6 +21,8 @@ import {
|
|||||||
hasRenderableElements,
|
hasRenderableElements,
|
||||||
haveSameElements,
|
haveSameElements,
|
||||||
isSuspiciousEmptySnapshot,
|
isSuspiciousEmptySnapshot,
|
||||||
|
isStaleEmptySnapshot,
|
||||||
|
isStaleNonRenderableSnapshot,
|
||||||
} from './editor/shared';
|
} from './editor/shared';
|
||||||
import type { ElementVersionInfo } from './editor/shared';
|
import type { ElementVersionInfo } from './editor/shared';
|
||||||
|
|
||||||
@@ -123,12 +124,55 @@ export const Editor: React.FC = () => {
|
|||||||
const cursorBuffer = useRef<Map<string, any>>(new Map());
|
const cursorBuffer = useRef<Map<string, any>>(new Map());
|
||||||
const animationFrameId = useRef<number>(0);
|
const animationFrameId = useRef<number>(0);
|
||||||
const latestElementsRef = useRef<readonly any[]>([]);
|
const latestElementsRef = useRef<readonly any[]>([]);
|
||||||
|
const initialSceneElementsRef = useRef<readonly any[]>([]);
|
||||||
const latestFilesRef = useRef<any>(null);
|
const latestFilesRef = useRef<any>(null);
|
||||||
const lastSyncedFilesRef = useRef<Record<string, any>>({});
|
const lastSyncedFilesRef = useRef<Record<string, any>>({});
|
||||||
const latestAppStateRef = useRef<any>(null);
|
const latestAppStateRef = useRef<any>(null);
|
||||||
const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => void) | null>(null);
|
const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => void) | null>(null);
|
||||||
const currentDrawingVersionRef = useRef<number | null>(null);
|
const currentDrawingVersionRef = useRef<number | null>(null);
|
||||||
const lastPersistedElementsRef = useRef<readonly any[]>([]);
|
const lastPersistedElementsRef = useRef<readonly any[]>([]);
|
||||||
|
const saveQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||||
|
const patchedAddFilesApisRef = useRef<WeakSet<object>>(new WeakSet());
|
||||||
|
const suspiciousBlankLoadRef = useRef(false);
|
||||||
|
const hasSceneChangesSinceLoadRef = useRef(false);
|
||||||
|
|
||||||
|
const getRenderableBaselineSnapshot = useCallback((): readonly any[] => {
|
||||||
|
if (hasRenderableElements(lastPersistedElementsRef.current)) {
|
||||||
|
return lastPersistedElementsRef.current;
|
||||||
|
}
|
||||||
|
if (hasRenderableElements(initialSceneElementsRef.current)) {
|
||||||
|
return initialSceneElementsRef.current;
|
||||||
|
}
|
||||||
|
return latestElementsRef.current;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolveSafeSnapshot = useCallback(
|
||||||
|
(candidateSnapshot: readonly any[] = []) => {
|
||||||
|
const baseline = getRenderableBaselineSnapshot();
|
||||||
|
const staleEmptySnapshot = isStaleEmptySnapshot(baseline, candidateSnapshot);
|
||||||
|
const staleNonRenderableSnapshot = isStaleNonRenderableSnapshot(
|
||||||
|
baseline,
|
||||||
|
candidateSnapshot
|
||||||
|
);
|
||||||
|
|
||||||
|
if (staleEmptySnapshot || staleNonRenderableSnapshot) {
|
||||||
|
return {
|
||||||
|
snapshot: baseline,
|
||||||
|
prevented: true,
|
||||||
|
staleEmptySnapshot,
|
||||||
|
staleNonRenderableSnapshot,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot: candidateSnapshot,
|
||||||
|
prevented: false,
|
||||||
|
staleEmptySnapshot: false,
|
||||||
|
staleNonRenderableSnapshot: false,
|
||||||
|
} as const;
|
||||||
|
},
|
||||||
|
[getRenderableBaselineSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
const emitFilesDeltaIfNeeded = useCallback(
|
const emitFilesDeltaIfNeeded = useCallback(
|
||||||
(nextFiles: Record<string, any>) => {
|
(nextFiles: Record<string, any>) => {
|
||||||
@@ -353,7 +397,8 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
|
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
|
||||||
// are broadcast immediately even if Excalidraw doesn't trigger `onChange` for files.
|
// are broadcast immediately even if Excalidraw doesn't trigger `onChange` for files.
|
||||||
if (api && typeof api.addFiles === "function") {
|
if (api && typeof api.addFiles === "function" && !patchedAddFilesApisRef.current.has(api as object)) {
|
||||||
|
patchedAddFilesApisRef.current.add(api as object);
|
||||||
const originalAddFiles = api.addFiles.bind(api);
|
const originalAddFiles = api.addFiles.bind(api);
|
||||||
api.addFiles = (files: Record<string, any>) => {
|
api.addFiles = (files: Record<string, any>) => {
|
||||||
originalAddFiles(files);
|
originalAddFiles(files);
|
||||||
@@ -366,6 +411,7 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Persist after file data becomes available so new tabs (tab3) load correctly.
|
// Persist after file data becomes available so new tabs (tab3) load correctly.
|
||||||
if (didEmit && id && latestAppStateRef.current && debouncedSaveRef.current) {
|
if (didEmit && id && latestAppStateRef.current && debouncedSaveRef.current) {
|
||||||
|
hasSceneChangesSinceLoadRef.current = true;
|
||||||
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, latestFilesRef.current || {});
|
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, latestFilesRef.current || {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -446,9 +492,30 @@ export const Editor: React.FC = () => {
|
|||||||
gridSize: appState?.gridSize || null,
|
gridSize: appState?.gridSize || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const persistableElements = Array.isArray(elements) ? elements : [];
|
const candidateElements = Array.isArray(elements) ? elements : [];
|
||||||
if (isSuspiciousEmptySnapshot(lastPersistedElementsRef.current, persistableElements)) {
|
const {
|
||||||
console.warn("[Editor] Skipping suspicious empty snapshot save", { drawingId });
|
snapshot: safeElements,
|
||||||
|
prevented,
|
||||||
|
staleEmptySnapshot,
|
||||||
|
staleNonRenderableSnapshot,
|
||||||
|
} = resolveSafeSnapshot(candidateElements);
|
||||||
|
const persistableElements = Array.from(safeElements);
|
||||||
|
if (suspiciousBlankLoadRef.current && !hasRenderableElements(persistableElements)) {
|
||||||
|
console.warn("[Editor] Blocking non-renderable save due to suspicious blank load", {
|
||||||
|
drawingId,
|
||||||
|
elementCount: persistableElements.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (staleEmptySnapshot || staleNonRenderableSnapshot) {
|
||||||
|
console.warn("[Editor] Skipping stale snapshot save", {
|
||||||
|
drawingId,
|
||||||
|
candidateElementCount: candidateElements.length,
|
||||||
|
fallbackElementCount: persistableElements.length,
|
||||||
|
prevented,
|
||||||
|
staleEmptySnapshot,
|
||||||
|
staleNonRenderableSnapshot,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persistableFiles = files ?? latestFilesRef.current ?? {};
|
const persistableFiles = files ?? latestFilesRef.current ?? {};
|
||||||
@@ -460,6 +527,8 @@ export const Editor: React.FC = () => {
|
|||||||
appState: persistableAppState,
|
appState: persistableAppState,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const persistScene = async (attempt: number): Promise<void> => {
|
||||||
|
try {
|
||||||
const updated = await api.updateDrawing(drawingId, {
|
const updated = await api.updateDrawing(drawingId, {
|
||||||
elements: persistableElements,
|
elements: persistableElements,
|
||||||
appState: persistableAppState,
|
appState: persistableAppState,
|
||||||
@@ -470,25 +539,81 @@ export const Editor: React.FC = () => {
|
|||||||
currentDrawingVersionRef.current = updated.version;
|
currentDrawingVersionRef.current = updated.version;
|
||||||
}
|
}
|
||||||
lastPersistedElementsRef.current = persistableElements;
|
lastPersistedElementsRef.current = persistableElements;
|
||||||
|
|
||||||
console.log("[Editor] Save complete", { drawingId });
|
console.log("[Editor] Save complete", { drawingId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (api.isAxiosError(err) && err.response?.status === 409) {
|
if (api.isAxiosError(err) && err.response?.status === 409) {
|
||||||
|
const reportedVersion = Number(err.response?.data?.currentVersion);
|
||||||
|
const hasReportedVersion = Number.isInteger(reportedVersion) && reportedVersion > 0;
|
||||||
|
if (hasReportedVersion) {
|
||||||
|
currentDrawingVersionRef.current = reportedVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === 0 && hasReportedVersion) {
|
||||||
|
console.warn("[Editor] Version conflict while saving drawing, retrying once", {
|
||||||
|
drawingId,
|
||||||
|
currentVersion: reportedVersion,
|
||||||
|
});
|
||||||
|
await persistScene(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.warn("[Editor] Version conflict while saving drawing", { drawingId });
|
console.warn("[Editor] Version conflict while saving drawing", { drawingId });
|
||||||
toast.error("Drawing changed in another tab. Refresh to load latest.");
|
toast.error("Drawing changed in another tab. Refresh to load latest.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await persistScene(0);
|
||||||
|
} catch (err) {
|
||||||
console.error('Failed to save drawing', err);
|
console.error('Failed to save drawing', err);
|
||||||
toast.error("Failed to save changes");
|
toast.error("Failed to save changes");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enqueueSceneSave = useCallback(
|
||||||
|
(drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => {
|
||||||
|
saveQueueRef.current = saveQueueRef.current
|
||||||
|
.catch(() => undefined)
|
||||||
|
.then(async () => {
|
||||||
|
if (!saveDataRef.current) return;
|
||||||
|
await saveDataRef.current(drawingId, elements, appState, files);
|
||||||
|
});
|
||||||
|
return saveQueueRef.current;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
savePreviewRef.current = async (drawingId: string, elements: readonly any[], appState: any, files: any) => {
|
savePreviewRef.current = async (drawingId: string, elements: readonly any[], appState: any, files: any) => {
|
||||||
if (!drawingId) return;
|
if (!drawingId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentSnapshot = latestElementsRef.current ?? elements;
|
const candidateSnapshot = latestElementsRef.current ?? elements;
|
||||||
|
const {
|
||||||
|
snapshot: currentSnapshot,
|
||||||
|
prevented: preventedPreviewOverwrite,
|
||||||
|
staleEmptySnapshot: staleEmptyPreview,
|
||||||
|
staleNonRenderableSnapshot: staleNonRenderablePreview,
|
||||||
|
} = resolveSafeSnapshot(candidateSnapshot);
|
||||||
const currentFiles = latestFilesRef.current ?? files;
|
const currentFiles = latestFilesRef.current ?? files;
|
||||||
|
if (suspiciousBlankLoadRef.current && !hasRenderableElements(currentSnapshot)) {
|
||||||
|
console.warn("[Editor] Blocking non-renderable preview due to suspicious blank load", {
|
||||||
|
drawingId,
|
||||||
|
elementCount: currentSnapshot.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preventedPreviewOverwrite) {
|
||||||
|
console.warn("[Editor] Prevented stale snapshot preview overwrite", {
|
||||||
|
drawingId,
|
||||||
|
staleEmptyPreview,
|
||||||
|
staleNonRenderablePreview,
|
||||||
|
fallbackElementCount: currentSnapshot.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const svg = await exportToSvg({
|
const svg = await exportToSvg({
|
||||||
elements: currentSnapshot,
|
elements: currentSnapshot,
|
||||||
@@ -528,11 +653,9 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
const debouncedSave = useCallback(
|
const debouncedSave = useCallback(
|
||||||
debounce((drawingId, elements, appState, files) => {
|
debounce((drawingId, elements, appState, files) => {
|
||||||
if (saveDataRef.current) {
|
enqueueSceneSave(drawingId, elements, appState, files);
|
||||||
saveDataRef.current(drawingId, elements, appState, files);
|
|
||||||
}
|
|
||||||
}, 1000),
|
}, 1000),
|
||||||
[] // Empty dependency array = Stable across renders
|
[enqueueSceneSave] // Stable queue wrapper avoids concurrent version conflicts
|
||||||
);
|
);
|
||||||
// Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves.
|
// Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves.
|
||||||
debouncedSaveRef.current = debouncedSave;
|
debouncedSaveRef.current = debouncedSave;
|
||||||
@@ -602,11 +725,15 @@ export const Editor: React.FC = () => {
|
|||||||
isBootstrappingScene.current = true;
|
isBootstrappingScene.current = true;
|
||||||
hasHydratedInitialScene.current = false;
|
hasHydratedInitialScene.current = false;
|
||||||
elementVersionMap.current.clear();
|
elementVersionMap.current.clear();
|
||||||
|
saveQueueRef.current = Promise.resolve();
|
||||||
latestElementsRef.current = [];
|
latestElementsRef.current = [];
|
||||||
|
initialSceneElementsRef.current = [];
|
||||||
latestFilesRef.current = {};
|
latestFilesRef.current = {};
|
||||||
lastSyncedFilesRef.current = {};
|
lastSyncedFilesRef.current = {};
|
||||||
currentDrawingVersionRef.current = null;
|
currentDrawingVersionRef.current = null;
|
||||||
lastPersistedElementsRef.current = [];
|
lastPersistedElementsRef.current = [];
|
||||||
|
suspiciousBlankLoadRef.current = false;
|
||||||
|
hasSceneChangesSinceLoadRef.current = false;
|
||||||
excalidrawAPI.current = null;
|
excalidrawAPI.current = null;
|
||||||
setIsReady(false);
|
setIsReady(false);
|
||||||
setIsSceneLoading(true);
|
setIsSceneLoading(true);
|
||||||
@@ -631,7 +758,20 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
const elements = data.elements || [];
|
const elements = data.elements || [];
|
||||||
const files = data.files || {};
|
const files = data.files || {};
|
||||||
|
const hasPreview = typeof data.preview === "string" && data.preview.trim().length > 0;
|
||||||
|
const loadedRenderable = hasRenderableElements(elements);
|
||||||
|
suspiciousBlankLoadRef.current = !loadedRenderable && hasPreview;
|
||||||
|
hasSceneChangesSinceLoadRef.current = false;
|
||||||
|
console.log("[Editor] Loaded drawing", {
|
||||||
|
drawingId: id,
|
||||||
|
elementCount: elements.length,
|
||||||
|
loadedRenderable,
|
||||||
|
hasPreview,
|
||||||
|
version: data.version ?? null,
|
||||||
|
suspiciousBlankLoad: suspiciousBlankLoadRef.current,
|
||||||
|
});
|
||||||
latestElementsRef.current = elements;
|
latestElementsRef.current = elements;
|
||||||
|
initialSceneElementsRef.current = elements;
|
||||||
latestFilesRef.current = files;
|
latestFilesRef.current = files;
|
||||||
lastSyncedFilesRef.current = files;
|
lastSyncedFilesRef.current = files;
|
||||||
currentDrawingVersionRef.current = typeof data.version === "number" ? data.version : null;
|
currentDrawingVersionRef.current = typeof data.version === "number" ? data.version : null;
|
||||||
@@ -677,10 +817,13 @@ export const Editor: React.FC = () => {
|
|||||||
}
|
}
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
latestElementsRef.current = [];
|
latestElementsRef.current = [];
|
||||||
|
initialSceneElementsRef.current = [];
|
||||||
latestFilesRef.current = {};
|
latestFilesRef.current = {};
|
||||||
lastSyncedFilesRef.current = {};
|
lastSyncedFilesRef.current = {};
|
||||||
currentDrawingVersionRef.current = null;
|
currentDrawingVersionRef.current = null;
|
||||||
lastPersistedElementsRef.current = [];
|
lastPersistedElementsRef.current = [];
|
||||||
|
suspiciousBlankLoadRef.current = false;
|
||||||
|
hasSceneChangesSinceLoadRef.current = false;
|
||||||
setLoadError(message);
|
setLoadError(message);
|
||||||
setInitialData(null);
|
setInitialData(null);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -697,20 +840,34 @@ export const Editor: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
|
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
|
||||||
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
||||||
|
const {
|
||||||
|
snapshot: safeElements,
|
||||||
|
prevented,
|
||||||
|
staleEmptySnapshot,
|
||||||
|
staleNonRenderableSnapshot,
|
||||||
|
} = resolveSafeSnapshot(elements);
|
||||||
const appState = excalidrawAPI.current.getAppState();
|
const appState = excalidrawAPI.current.getAppState();
|
||||||
const files = excalidrawAPI.current.getFiles() || {};
|
const files = excalidrawAPI.current.getFiles() || {};
|
||||||
latestElementsRef.current = elements;
|
|
||||||
latestFilesRef.current = files;
|
latestFilesRef.current = files;
|
||||||
|
if (prevented) {
|
||||||
|
console.warn("[Editor] Prevented stale Ctrl+S snapshot overwrite", {
|
||||||
|
drawingId: id,
|
||||||
|
staleEmptySnapshot,
|
||||||
|
staleNonRenderableSnapshot,
|
||||||
|
candidateElementCount: elements.length,
|
||||||
|
fallbackElementCount: safeElements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
await saveDataRef.current(id, elements, appState, files);
|
await enqueueSceneSave(id, safeElements, appState, files);
|
||||||
savePreviewRef.current(id, elements, appState, files);
|
savePreviewRef.current(id, safeElements, appState, files);
|
||||||
toast.success("Saved changes to server");
|
toast.success("Saved changes to server");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, []);
|
}, [enqueueSceneSave, id, resolveSafeSnapshot]);
|
||||||
|
|
||||||
const handleCanvasChange = useCallback((elements: readonly any[], appState: any, files?: Record<string, any>) => {
|
const handleCanvasChange = useCallback((elements: readonly any[], appState: any, files?: Record<string, any>) => {
|
||||||
if (isUnmounting.current) {
|
if (isUnmounting.current) {
|
||||||
@@ -733,7 +890,29 @@ export const Editor: React.FC = () => {
|
|||||||
: elements;
|
: elements;
|
||||||
|
|
||||||
if (!hasHydratedInitialScene.current) {
|
if (!hasHydratedInitialScene.current) {
|
||||||
const matchesInitialSnapshot = haveSameElements(allElements, latestElementsRef.current);
|
const matchesInitialSnapshot = haveSameElements(
|
||||||
|
allElements,
|
||||||
|
initialSceneElementsRef.current
|
||||||
|
);
|
||||||
|
const transientHydrationEmpty = isSuspiciousEmptySnapshot(
|
||||||
|
initialSceneElementsRef.current,
|
||||||
|
allElements
|
||||||
|
);
|
||||||
|
const transientHydrationNonRenderable = isStaleNonRenderableSnapshot(
|
||||||
|
initialSceneElementsRef.current,
|
||||||
|
allElements
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transientHydrationEmpty || transientHydrationNonRenderable) {
|
||||||
|
console.log("[Editor] Skipping transient hydration snapshot", {
|
||||||
|
drawingId: id,
|
||||||
|
elementCount: allElements.length,
|
||||||
|
transientHydrationEmpty,
|
||||||
|
transientHydrationNonRenderable,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
hasHydratedInitialScene.current = true;
|
hasHydratedInitialScene.current = true;
|
||||||
isBootstrappingScene.current = false;
|
isBootstrappingScene.current = false;
|
||||||
|
|
||||||
@@ -751,9 +930,35 @@ export const Editor: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
latestElementsRef.current = allElements;
|
const noFileChanges =
|
||||||
|
Object.keys(getFilesDelta(latestFilesRef.current || {}, currentFiles || {})).length === 0;
|
||||||
|
if (haveSameElements(allElements, latestElementsRef.current) && noFileChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
prevented: preventedCanvasOverwrite,
|
||||||
|
staleEmptySnapshot: staleEmptyCanvasSnapshot,
|
||||||
|
staleNonRenderableSnapshot: staleNonRenderableCanvasSnapshot,
|
||||||
|
} = resolveSafeSnapshot(allElements);
|
||||||
|
if (preventedCanvasOverwrite) {
|
||||||
|
console.warn("[Editor] Skipping stale non-renderable change", {
|
||||||
|
drawingId: id,
|
||||||
|
elementCount: allElements.length,
|
||||||
|
staleEmptyCanvasSnapshot,
|
||||||
|
staleNonRenderableCanvasSnapshot,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hasRenderable = hasRenderableElements(allElements);
|
const hasRenderable = hasRenderableElements(allElements);
|
||||||
|
if (hasRenderable && suspiciousBlankLoadRef.current) {
|
||||||
|
suspiciousBlankLoadRef.current = false;
|
||||||
|
console.log("[Editor] Cleared suspicious blank load guard after renderable edit", {
|
||||||
|
drawingId: id,
|
||||||
|
elementCount: allElements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isBootstrappingScene.current && !hasRenderable) {
|
if (isBootstrappingScene.current && !hasRenderable) {
|
||||||
console.log("[Editor] Bootstrapping guard active", {
|
console.log("[Editor] Bootstrapping guard active", {
|
||||||
drawingId: id,
|
drawingId: id,
|
||||||
@@ -761,6 +966,8 @@ export const Editor: React.FC = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
latestElementsRef.current = allElements;
|
||||||
|
hasSceneChangesSinceLoadRef.current = true;
|
||||||
|
|
||||||
// Trigger Sync (Throttled)
|
// Trigger Sync (Throttled)
|
||||||
broadcastChanges(allElements, currentFiles);
|
broadcastChanges(allElements, currentFiles);
|
||||||
@@ -786,7 +993,7 @@ export const Editor: React.FC = () => {
|
|||||||
if (id) {
|
if (id) {
|
||||||
debouncedSavePreview(id, allElements, appState, filesSnapshot);
|
debouncedSavePreview(id, allElements, appState, filesSnapshot);
|
||||||
}
|
}
|
||||||
}, [debouncedSave, debouncedSavePreview, broadcastChanges, id]);
|
}, [debouncedSave, debouncedSavePreview, broadcastChanges, id, resolveSafeSnapshot]);
|
||||||
|
|
||||||
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
|
// Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously)
|
||||||
// are still broadcast to collaborators AND persisted to the server.
|
// are still broadcast to collaborators AND persisted to the server.
|
||||||
@@ -804,6 +1011,7 @@ export const Editor: React.FC = () => {
|
|||||||
|
|
||||||
// Persist after file data becomes available (covers the "tab 3" case).
|
// Persist after file data becomes available (covers the "tab 3" case).
|
||||||
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
|
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
|
||||||
|
hasSceneChangesSinceLoadRef.current = true;
|
||||||
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles);
|
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@@ -840,16 +1048,46 @@ export const Editor: React.FC = () => {
|
|||||||
// Save drawing and generate preview before navigating
|
// Save drawing and generate preview before navigating
|
||||||
try {
|
try {
|
||||||
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
|
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
|
||||||
|
if (!hasSceneChangesSinceLoadRef.current) {
|
||||||
|
console.log("[Editor] Skipping back-navigation save: no scene changes since load", {
|
||||||
|
drawingId: id,
|
||||||
|
});
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
|
||||||
|
const {
|
||||||
|
snapshot: safeElements,
|
||||||
|
prevented,
|
||||||
|
staleEmptySnapshot,
|
||||||
|
staleNonRenderableSnapshot,
|
||||||
|
} = resolveSafeSnapshot(elements);
|
||||||
const appState = excalidrawAPI.current.getAppState();
|
const appState = excalidrawAPI.current.getAppState();
|
||||||
const files = excalidrawAPI.current.getFiles() || {};
|
const files = excalidrawAPI.current.getFiles() || {};
|
||||||
latestElementsRef.current = elements;
|
|
||||||
latestFilesRef.current = files;
|
latestFilesRef.current = files;
|
||||||
|
if (prevented) {
|
||||||
|
console.warn("[Editor] Prevented stale back-navigation snapshot overwrite", {
|
||||||
|
drawingId: id,
|
||||||
|
staleEmptySnapshot,
|
||||||
|
staleNonRenderableSnapshot,
|
||||||
|
candidateElementCount: elements.length,
|
||||||
|
fallbackElementCount: safeElements.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (suspiciousBlankLoadRef.current && !hasRenderableElements(safeElements)) {
|
||||||
|
console.warn("[Editor] Blocking back-navigation save due to suspicious blank load", {
|
||||||
|
drawingId: id,
|
||||||
|
elementCount: safeElements.length,
|
||||||
|
});
|
||||||
|
toast.warning("Blank scene detected on load. Skipping save to protect existing data.");
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
saveDataRef.current(id, elements, appState, files),
|
enqueueSceneSave(id, safeElements, appState, files),
|
||||||
savePreviewRef.current(id, elements, appState, files)
|
savePreviewRef.current(id, safeElements, appState, files)
|
||||||
]);
|
]);
|
||||||
console.log("[Editor] Saved on back navigation", { drawingId: id });
|
console.log("[Editor] Saved on back navigation", { drawingId: id });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
hasRenderableElements,
|
hasRenderableElements,
|
||||||
isSuspiciousEmptySnapshot,
|
isSuspiciousEmptySnapshot,
|
||||||
|
isStaleEmptySnapshot,
|
||||||
|
isStaleNonRenderableSnapshot,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
describe("editor/shared scene guards", () => {
|
describe("editor/shared scene guards", () => {
|
||||||
@@ -29,4 +31,31 @@ describe("editor/shared scene guards", () => {
|
|||||||
const next = [{ id: "a", isDeleted: true }];
|
const next = [{ id: "a", isDeleted: true }];
|
||||||
expect(isSuspiciousEmptySnapshot(previous, next)).toBe(false);
|
expect(isSuspiciousEmptySnapshot(previous, next)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("flags stale empty snapshot when latest scene is non-empty", () => {
|
||||||
|
const latest = [{ id: "a", version: 2, versionNonce: 2, isDeleted: false }];
|
||||||
|
expect(isStaleEmptySnapshot(latest, [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag empty snapshot when latest scene is already empty", () => {
|
||||||
|
expect(isStaleEmptySnapshot([], [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag identical empty snapshots", () => {
|
||||||
|
const latest = [];
|
||||||
|
const candidate = [];
|
||||||
|
expect(isStaleEmptySnapshot(latest, candidate)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags stale non-renderable snapshot when latest scene has renderable elements", () => {
|
||||||
|
const latest = [{ id: "a", version: 2, versionNonce: 2, isDeleted: false }];
|
||||||
|
const candidate = [{ id: "a", version: 1, versionNonce: 1, isDeleted: true }];
|
||||||
|
expect(isStaleNonRenderableSnapshot(latest, candidate)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag non-renderable snapshot when latest scene is already non-renderable", () => {
|
||||||
|
const latest = [{ id: "a", version: 2, versionNonce: 2, isDeleted: true }];
|
||||||
|
const candidate = [{ id: "a", version: 1, versionNonce: 1, isDeleted: true }];
|
||||||
|
expect(isStaleNonRenderableSnapshot(latest, candidate)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user