images in preview

This commit is contained in:
Zimeng Xiong
2026-02-07 17:21:58 -08:00
parent 2aa749a2f0
commit 35bbbb9599
15 changed files with 654 additions and 77 deletions
@@ -267,6 +267,90 @@ describe("Security Sanitization - Image Data URLs", () => {
}); });
}); });
describe("sanitizeDrawingData - preview svg handling", () => {
it("should preserve safe SVG layout attributes needed for thumbnail rendering", () => {
const preview = [
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 728.39453125 606.908203125" width="1456.7890625" height="1213.81640625" preserveAspectRatio="xMidYMid meet">',
'<rect x="0" y="0" width="728.39453125" height="606.908203125" fill="#ffffff"></rect>',
'<path d="M0 0 L20 20" stroke="#000" stroke-linecap="round"></path>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain('viewBox="0 0 728.39453125 606.908203125"');
expect(result.preview).toContain('preserveAspectRatio="xMidYMid meet"');
expect(result.preview).toContain('stroke-linecap="round"');
expect(result.preview).toContain('xmlns="http://www.w3.org/2000/svg"');
});
it("should preserve safe embedded image previews", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<image x="0" y="0" width="40" height="40" href="data:image/png;base64,AAAA"></image>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain("<image");
expect(result.preview).toContain('href="data:image/png;base64,AAAA"');
});
it("should remove embedded images with unsafe href values", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<image x="0" y="0" width="40" height="40" href="javascript:alert(1)"></image>',
'<rect x="0" y="0" width="10" height="10" fill="#000"></rect>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).not.toContain("<image");
expect(result.preview).toContain("<rect");
});
it("should preserve safe defs/pattern image structures used by Excalidraw exports", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
'<defs><pattern id="p1" width="1" height="1" patternUnits="objectBoundingBox">',
'<image href="data:image/png;base64,AAAA" width="100" height="100"></image>',
"</pattern></defs>",
'<rect x="0" y="0" width="100" height="100" fill="url(#p1)"></rect>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain("<defs>");
expect(result.preview).toContain("<pattern");
expect(result.preview).toContain('id="p1"');
expect(result.preview).toContain("<image");
expect(result.preview).toContain('fill="url(#p1)"');
});
});
describe("validateImportedDrawing - with files", () => { describe("validateImportedDrawing - with files", () => {
it("should validate drawing with embedded images", () => { it("should validate drawing with embedded images", () => {
const files = createSampleFilesObject(2, "large"); const files = createSampleFilesObject(2, "large");
+21 -9
View File
@@ -267,10 +267,15 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
expiresAt, expiresAt,
}, },
}); });
} catch { } catch (error) {
if (process.env.NODE_ENV === "development") { if (isMissingRefreshTokenTableError(error)) {
console.debug("Refresh token storage skipped (feature disabled or table missing)"); console.error("Refresh token rotation is enabled but refresh token storage is unavailable");
return res.status(503).json({
error: "Service unavailable",
message: "Refresh token storage is unavailable. Please run database migrations.",
});
} }
throw error;
} }
} }
@@ -380,10 +385,15 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
expiresAt, expiresAt,
}, },
}); });
} catch { } catch (error) {
if (process.env.NODE_ENV === "development") { if (isMissingRefreshTokenTableError(error)) {
console.debug("Refresh token rotation skipped (feature disabled or table missing)"); console.error("Refresh token rotation is enabled but refresh token storage is unavailable");
return res.status(503).json({
error: "Service unavailable",
message: "Refresh token storage is unavailable. Please run database migrations.",
});
} }
throw error;
} }
} }
@@ -514,9 +524,11 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
} }
if (isMissingRefreshTokenTableError(error)) { if (isMissingRefreshTokenTableError(error)) {
if (process.env.NODE_ENV === "development") { console.error("Refresh token rotation is enabled but refresh token storage is unavailable");
console.debug("Refresh token rotation skipped (feature disabled or table missing)"); return res.status(503).json({
} error: "Service unavailable",
message: "Refresh token storage is unavailable. Please run database migrations.",
});
} else { } else {
console.error("Refresh token rotation error:", error); console.error("Refresh token rotation error:", error);
return res.status(500).json({ return res.status(500).json({
+2 -2
View File
@@ -707,8 +707,8 @@ export const sanitizeDrawingUpdateData = (
collectionId: data.collectionId, collectionId: data.collectionId,
}; };
const sanitized = sanitizeDrawingData(fullData); const sanitized = sanitizeDrawingData(fullData);
sanitizedData.elements = sanitized.elements; if (data.elements !== undefined) sanitizedData.elements = sanitized.elements;
sanitizedData.appState = sanitized.appState; if (data.appState !== undefined) sanitizedData.appState = sanitized.appState;
if (data.files !== undefined) sanitizedData.files = sanitized.files; if (data.files !== undefined) sanitizedData.files = sanitized.files;
if (data.preview !== undefined) sanitizedData.preview = sanitized.preview; if (data.preview !== undefined) sanitizedData.preview = sanitized.preview;
Object.assign(data, sanitizedData); Object.assign(data, sanitizedData);
+3 -16
View File
@@ -53,23 +53,10 @@ const getAuthEnabled = async (): Promise<boolean> => {
}; };
const getBootstrapActingUser = async () => { const getBootstrapActingUser = async () => {
const user = await prisma.user.findUnique({ return prisma.user.upsert({
where: { id: BOOTSTRAP_USER_ID }, where: { id: BOOTSTRAP_USER_ID },
select: { update: {},
id: true, create: {
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (user) return user;
return prisma.user.create({
data: {
id: BOOTSTRAP_USER_ID, id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local", email: "bootstrap@excalidash.local",
username: null, username: null,
+58 -6
View File
@@ -101,11 +101,45 @@ export const sanitizeHtml = (input: string): string => {
export const sanitizeSvg = (svgContent: string): string => { export const sanitizeSvg = (svgContent: string): string => {
if (typeof svgContent !== "string") return ""; if (typeof svgContent !== "string") return "";
return purify const safeImageDataUrlPattern =
/^data:image\/(?:png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\s]+$/i;
const sanitizeSvgImageTags = (content: string): string =>
content.replace(/<image\b[^>]*>/gi, (imageTag) => {
const hrefMatch =
imageTag.match(/\shref\s*=\s*"([^"]*)"/i) ??
imageTag.match(/\shref\s*=\s*'([^']*)'/i) ??
imageTag.match(/\sxlink:href\s*=\s*"([^"]*)"/i) ??
imageTag.match(/\sxlink:href\s*=\s*'([^']*)'/i);
const hrefValue = hrefMatch?.[1]?.trim();
if (!hrefValue || !safeImageDataUrlPattern.test(hrefValue)) {
return "";
}
const withoutXlinkHref = imageTag.replace(
/\sxlink:href\s*=\s*(?:"[^"]*"|'[^']*')/gi,
""
);
if (/\shref\s*=/i.test(withoutXlinkHref)) {
return withoutXlinkHref.replace(
/\shref\s*=\s*(?:"[^"]*"|'[^']*')/i,
` href="${hrefValue}"`
);
}
return withoutXlinkHref.replace(/<image\b/i, `<image href="${hrefValue}"`);
});
const sanitized = purify
.sanitize(svgContent, { .sanitize(svgContent, {
ALLOWED_TAGS: [ ALLOWED_TAGS: [
"svg", "svg",
"defs",
"pattern",
"g", "g",
"image",
"rect", "rect",
"circle", "circle",
"ellipse", "ellipse",
@@ -117,6 +151,12 @@ export const sanitizeSvg = (svgContent: string): string => {
"tspan", "tspan",
], ],
ALLOWED_ATTR: [ ALLOWED_ATTR: [
"xmlns",
"xmlns:xlink",
"version",
"id",
"viewBox",
"preserveAspectRatio",
"x", "x",
"y", "y",
"width", "width",
@@ -133,14 +173,29 @@ export const sanitizeSvg = (svgContent: string): string => {
"points", "points",
"d", "d",
"fill", "fill",
"fill-opacity",
"fill-rule",
"stroke", "stroke",
"stroke-width", "stroke-width",
"stroke-opacity",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-dasharray",
"stroke-dashoffset",
"opacity", "opacity",
"transform", "transform",
"vector-effect",
"patternUnits",
"patternContentUnits",
"font-size", "font-size",
"font-family", "font-family",
"font-weight",
"letter-spacing",
"text-anchor", "text-anchor",
"dominant-baseline", "dominant-baseline",
"href",
"xlink:href",
], ],
FORBID_TAGS: [ FORBID_TAGS: [
"script", "script",
@@ -149,10 +204,8 @@ export const sanitizeSvg = (svgContent: string): string => {
"object", "object",
"embed", "embed",
"use", "use",
"image",
"style", "style",
"link", "link",
"defs",
"symbol", "symbol",
"marker", "marker",
"clipPath", "clipPath",
@@ -166,17 +219,16 @@ export const sanitizeSvg = (svgContent: string): string => {
"onmouseover", "onmouseover",
"onfocus", "onfocus",
"onblur", "onblur",
"href",
"xlink:href",
"src", "src",
"action", "action",
"style", "style",
"class", "class",
"id",
], ],
KEEP_CONTENT: true, KEEP_CONTENT: true,
}) })
.trim(); .trim();
return sanitizeSvgImageTags(sanitized).trim();
}; };
export const sanitizeText = ( export const sanitizeText = (
+50
View File
@@ -0,0 +1,50 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"dist",
"coverage",
"playwright-report",
"test-results",
"node_modules",
],
},
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{
files: ["**/*.{test,spec}.{ts,tsx}"],
languageOptions: {
globals: {
...globals.vitest,
},
},
}
);
+84 -9
View File
@@ -1,10 +1,12 @@
import axios from "axios"; import axios from "axios";
import type { Drawing, Collection, DrawingSummary } from "../types"; import type { Drawing, Collection, DrawingSummary } from "../types";
import { normalizePreviewSvg } from "../utils/previewSvg";
export const API_URL = import.meta.env.VITE_API_URL || "/api"; export const API_URL = import.meta.env.VITE_API_URL || "/api";
export const api = axios.create({ export const api = axios.create({
baseURL: API_URL, baseURL: API_URL,
withCredentials: true,
}); });
// Re-export axios for type checking // Re-export axios for type checking
@@ -18,14 +20,19 @@ export { api as default };
const TOKEN_KEY = 'excalidash-access-token'; const TOKEN_KEY = 'excalidash-access-token';
const REFRESH_TOKEN_KEY = 'excalidash-refresh-token'; const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
const USER_KEY = 'excalidash-user'; const USER_KEY = 'excalidash-user';
const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
const AUTH_STATUS_TTL_MS = 5000;
type RetriableRequestConfig = { type RetriableRequestConfig = {
_retry?: boolean; _retry?: boolean;
_csrfRetry?: boolean; _csrfRetry?: boolean;
_authModeRetry?: boolean;
url?: string; url?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
}; };
let authEnabledProbeCache: { value: boolean; fetchedAt: number } | null = null;
const getAuthToken = (): string | null => { const getAuthToken = (): string | null => {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY);
@@ -39,7 +46,8 @@ let csrfTokenPromise: Promise<void> | null = null;
export const fetchCsrfToken = async (): Promise<void> => { export const fetchCsrfToken = async (): Promise<void> => {
try { try {
const response = await axios.get<{ token: string; header: string }>( const response = await axios.get<{ token: string; header: string }>(
`${API_URL}/csrf-token` `${API_URL}/csrf-token`,
{ withCredentials: true }
); );
csrfToken = response.data.token; csrfToken = response.data.token;
csrfHeaderName = response.data.header || "x-csrf-token"; csrfHeaderName = response.data.header || "x-csrf-token";
@@ -71,7 +79,47 @@ const clearStoredAuth = () => {
localStorage.removeItem(USER_KEY); localStorage.removeItem(USER_KEY);
}; };
const redirectToLogin = () => { const readCachedAuthEnabled = (): boolean | null => {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(AUTH_ENABLED_CACHE_KEY);
if (raw === "true") return true;
if (raw === "false") return false;
return null;
};
const cacheAuthEnabled = (enabled: boolean) => {
if (typeof window === "undefined") return;
authEnabledProbeCache = { value: enabled, fetchedAt: Date.now() };
localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(enabled));
};
const getAuthEnabledStatus = async (): Promise<boolean | null> => {
const now = Date.now();
if (authEnabledProbeCache && now - authEnabledProbeCache.fetchedAt < AUTH_STATUS_TTL_MS) {
return authEnabledProbeCache.value;
}
try {
const response = await axios.get<{ authEnabled?: boolean; enabled?: boolean }>(
`${API_URL}/auth/status`,
{ withCredentials: true }
);
const enabled =
typeof response.data?.authEnabled === "boolean"
? response.data.authEnabled
: typeof response.data?.enabled === "boolean"
? response.data.enabled
: true;
cacheAuthEnabled(enabled);
return enabled;
} catch {
return readCachedAuthEnabled();
}
};
const redirectToLogin = async () => {
const authEnabled = await getAuthEnabledStatus();
if (authEnabled === false) return;
if (window.location.pathname !== '/login') { if (window.location.pathname !== '/login') {
window.location.href = '/login'; window.location.href = '/login';
} }
@@ -87,9 +135,13 @@ const refreshAccessToken = async (): Promise<string> => {
throw new Error("Missing refresh token"); throw new Error("Missing refresh token");
} }
const refreshResponse = await axios.post(`${API_URL}/auth/refresh`, { const refreshResponse = await axios.post(
refreshToken, `${API_URL}/auth/refresh`,
}); {
refreshToken,
},
{ withCredentials: true }
);
const nextAccessToken = String(refreshResponse.data.accessToken || ""); const nextAccessToken = String(refreshResponse.data.accessToken || "");
if (!nextAccessToken) { if (!nextAccessToken) {
@@ -173,6 +225,15 @@ api.interceptors.response.use(
const url = String(originalRequest.url || ""); const url = String(originalRequest.url || "");
const isAuthRoute = url.includes('/auth/'); const isAuthRoute = url.includes('/auth/');
const hasRefreshToken = Boolean(localStorage.getItem(REFRESH_TOKEN_KEY)); const hasRefreshToken = Boolean(localStorage.getItem(REFRESH_TOKEN_KEY));
const authEnabled = !isAuthRoute ? await getAuthEnabledStatus() : true;
if (!isAuthRoute && authEnabled === false) {
if (!originalRequest._authModeRetry) {
originalRequest._authModeRetry = true;
return api(originalRequest as any);
}
return Promise.reject(error);
}
if (!isAuthRoute && hasRefreshToken && !originalRequest._retry) { if (!isAuthRoute && hasRefreshToken && !originalRequest._retry) {
try { try {
@@ -183,14 +244,14 @@ api.interceptors.response.use(
return api(originalRequest as any); return api(originalRequest as any);
} catch { } catch {
clearStoredAuth(); clearStoredAuth();
redirectToLogin(); await redirectToLogin();
return Promise.reject(error); return Promise.reject(error);
} }
} }
if (!isAuthRoute) { if (!isAuthRoute) {
clearStoredAuth(); clearStoredAuth();
redirectToLogin(); await redirectToLogin();
} }
} }
@@ -243,14 +304,28 @@ const deserializeDrawingSummary = (drawing: unknown): DrawingSummary => {
if (typeof drawing !== 'object' || drawing === null) { if (typeof drawing !== 'object' || drawing === null) {
throw new Error('Invalid drawing data'); throw new Error('Invalid drawing data');
} }
return deserializeTimestamps(drawing as HasTimestamps & DrawingSummary); const parsed = drawing as HasTimestamps & DrawingSummary;
return deserializeTimestamps({
...parsed,
preview:
typeof parsed.preview === "string"
? normalizePreviewSvg(parsed.preview)
: parsed.preview,
});
}; };
const deserializeDrawing = (drawing: unknown): Drawing => { const deserializeDrawing = (drawing: unknown): Drawing => {
if (typeof drawing !== 'object' || drawing === null) { if (typeof drawing !== 'object' || drawing === null) {
throw new Error('Invalid drawing data'); throw new Error('Invalid drawing data');
} }
return deserializeTimestamps(drawing as HasTimestamps & Drawing); const parsed = drawing as HasTimestamps & Drawing;
return deserializeTimestamps({
...parsed,
preview:
typeof parsed.preview === "string"
? normalizePreviewSvg(parsed.preview)
: parsed.preview,
});
}; };
export interface PaginatedDrawings<T> { export interface PaginatedDrawings<T> {
+32 -2
View File
@@ -7,6 +7,7 @@ import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx'; import clsx from 'clsx';
// import { exportToSvg } from "@excalidraw/excalidraw"; // Lazy load this instead // import { exportToSvg } from "@excalidraw/excalidraw"; // Lazy load this instead
import { exportDrawingToFile } from '../utils/exportUtils'; import { exportDrawingToFile } from '../utils/exportUtils';
import { previewHasEmbeddedImages } from '../utils/previewSvg';
import * as api from '../api'; import * as api from '../api';
@@ -16,6 +17,31 @@ type HydratedDrawingData = {
files: Record<string, any>; files: Record<string, any>;
}; };
const normalizeImageElementsForPreview = (
elements: any[] = [],
files: Record<string, any> = {}
): any[] =>
elements.map((element) => {
if (!element || element.type !== "image" || typeof element.fileId !== "string") {
return element;
}
const file = files[element.fileId];
const hasImageData =
typeof file?.dataURL === "string" &&
file.dataURL.startsWith("data:image/") &&
file.dataURL.length > 0;
if (!hasImageData || element.status === "saved") {
return element;
}
return {
...element,
status: "saved",
};
});
interface DrawingCardProps { interface DrawingCardProps {
drawing: DrawingSummary; drawing: DrawingSummary;
collections: Collection[]; collections: Collection[];
@@ -60,6 +86,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null); const [exportError, setExportError] = useState<string | null>(null);
const [fullData, setFullData] = useState<HydratedDrawingData | null>(null); const [fullData, setFullData] = useState<HydratedDrawingData | null>(null);
const hasEmbeddedImages = previewHasEmbeddedImages(previewSvg);
const fullDataRef = React.useRef(fullData); const fullDataRef = React.useRef(fullData);
fullDataRef.current = fullData; fullDataRef.current = fullData;
@@ -118,7 +145,7 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
if (cancelled) return; if (cancelled) return;
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: data.elements, elements: normalizeImageElementsForPreview(data.elements, data.files || {}),
appState: { appState: {
...data.appState, ...data.appState,
exportBackground: true, exportBackground: true,
@@ -248,7 +275,10 @@ export const DrawingCard: React.FC<DrawingCardProps> = ({
{previewSvg ? ( {previewSvg ? (
<div <div
className="w-full h-full p-3 sm:p-4 lg:p-5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm dark:[&>svg]:invert dark:[&>svg_rect[fill='white']]:opacity-0 dark:[&>svg_rect[fill='#ffffff']]:opacity-0 transition-transform duration-500 group-hover:scale-105" className={clsx(
"w-full h-full p-3 sm:p-4 lg:p-5 flex items-center justify-center [&>svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm transition-transform duration-500",
!hasEmbeddedImages && "dark:[&>svg]:invert dark:[&>svg_rect[fill='white']]:opacity-0 dark:[&>svg_rect[fill='#ffffff']]:opacity-0"
)}
dangerouslySetInnerHTML={{ __html: previewSvg }} dangerouslySetInnerHTML={{ __html: previewSvg }}
/> />
) : ( ) : (
+39
View File
@@ -87,4 +87,43 @@ describe("AuthProvider", () => {
expect(storage.get("excalidash-refresh-token")).toBeUndefined(); expect(storage.get("excalidash-refresh-token")).toBeUndefined();
expect(storage.get("excalidash-user")).toBeUndefined(); expect(storage.get("excalidash-user")).toBeUndefined();
}); });
it("uses cached auth-disabled mode when /auth/status is temporarily unavailable", async () => {
const storage = new Map<string, string>([
["excalidash-auth-enabled", "false"],
["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").mockRejectedValueOnce(new Error("network down"));
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();
});
}); });
+13 -2
View File
@@ -30,6 +30,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'excalidash-access-token'; const TOKEN_KEY = 'excalidash-access-token';
const REFRESH_TOKEN_KEY = 'excalidash-refresh-token'; const REFRESH_TOKEN_KEY = 'excalidash-refresh-token';
const USER_KEY = 'excalidash-user'; const USER_KEY = 'excalidash-user';
const AUTH_ENABLED_CACHE_KEY = "excalidash-auth-enabled";
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
@@ -52,6 +53,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
? statusResponse.data.enabled ? statusResponse.data.enabled
: true; : true;
setAuthEnabled(enabled); setAuthEnabled(enabled);
localStorage.setItem(AUTH_ENABLED_CACHE_KEY, String(enabled));
setBootstrapRequired(Boolean(statusResponse.data?.bootstrapRequired)); setBootstrapRequired(Boolean(statusResponse.data?.bootstrapRequired));
// In single-user mode, do not require login. // In single-user mode, do not require login.
@@ -63,8 +65,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return; return;
} }
} catch { } catch {
// If status fails, default to auth-enabled mode to avoid exposing const cachedAuthEnabled = localStorage.getItem(AUTH_ENABLED_CACHE_KEY);
// single-user UI paths accidentally. Backend remains the source of truth. if (cachedAuthEnabled === "false") {
setAuthEnabled(false);
setBootstrapRequired(false);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setUser(null);
return;
}
// If status fails and no cached mode exists, default to auth-enabled mode.
setAuthEnabled(true); setAuthEnabled(true);
setBootstrapRequired(false); setBootstrapRequired(false);
} }
+1 -1
View File
@@ -719,7 +719,7 @@ export const Dashboard: React.FC = () => {
{d.preview ? ( {d.preview ? (
<div <div
className="w-full h-full p-2 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full [&>svg]:object-contain [&>svg]:drop-shadow-sm relative z-10" className="w-full h-full p-2 flex items-center justify-center [&>svg]:w-auto [&>svg]:h-auto [&>svg]:max-w-full [&>svg]:max-h-full [&>svg]:drop-shadow-sm relative z-10"
dangerouslySetInnerHTML={{ __html: d.preview }} dangerouslySetInnerHTML={{ __html: d.preview }}
/> />
) : ( ) : (
+113 -29
View File
@@ -30,6 +30,13 @@ interface Peer extends UserIdentity {
isActive: boolean; isActive: boolean;
} }
class DrawingSaveConflictError extends Error {
constructor(message = "Drawing version conflict") {
super(message);
this.name = "DrawingSaveConflictError";
}
}
export const Editor: React.FC = () => { export const Editor: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -174,6 +181,39 @@ export const Editor: React.FC = () => {
[getRenderableBaselineSnapshot] [getRenderableBaselineSnapshot]
); );
const normalizeImageElementStatus = useCallback(
(elements: readonly any[] = [], files?: Record<string, any> | null): readonly any[] => {
if (!Array.isArray(elements) || elements.length === 0) return elements;
const fileMap = files || {};
let changed = false;
const normalized = elements.map((element: any) => {
if (!element || element.type !== "image" || typeof element.fileId !== "string") {
return element;
}
const file = fileMap[element.fileId];
const hasImageData =
typeof file?.dataURL === "string" &&
file.dataURL.startsWith("data:image/") &&
file.dataURL.length > 0;
if (!hasImageData || element.status === "saved") {
return element;
}
changed = true;
return {
...element,
status: "saved",
};
});
return changed ? normalized : elements;
},
[]
);
const emitFilesDeltaIfNeeded = useCallback( const emitFilesDeltaIfNeeded = useCallback(
(nextFiles: Record<string, any>) => { (nextFiles: Record<string, any>) => {
if (!socketRef.current || !id) return false; if (!socketRef.current || !id) return false;
@@ -519,18 +559,23 @@ export const Editor: React.FC = () => {
return; return;
} }
const persistableFiles = files ?? latestFilesRef.current ?? {}; const persistableFiles = files ?? latestFilesRef.current ?? {};
const normalizedElements = normalizeImageElementStatus(
persistableElements,
persistableFiles
);
const normalizedElementsForSave = Array.from(normalizedElements);
console.log("[Editor] Saving drawing", { console.log("[Editor] Saving drawing", {
drawingId, drawingId,
elementCount: persistableElements.length, elementCount: normalizedElementsForSave.length,
hasRenderableElements: hasRenderableElements(persistableElements), hasRenderableElements: hasRenderableElements(normalizedElementsForSave),
appState: persistableAppState, appState: persistableAppState,
}); });
const persistScene = async (attempt: number): Promise<void> => { const persistScene = async (attempt: number): Promise<void> => {
try { try {
const updated = await api.updateDrawing(drawingId, { const updated = await api.updateDrawing(drawingId, {
elements: persistableElements, elements: normalizedElementsForSave,
appState: persistableAppState, appState: persistableAppState,
files: persistableFiles, files: persistableFiles,
version: currentDrawingVersionRef.current ?? undefined, version: currentDrawingVersionRef.current ?? undefined,
@@ -538,7 +583,7 @@ export const Editor: React.FC = () => {
if (typeof updated.version === "number") { if (typeof updated.version === "number") {
currentDrawingVersionRef.current = updated.version; currentDrawingVersionRef.current = updated.version;
} }
lastPersistedElementsRef.current = persistableElements; lastPersistedElementsRef.current = normalizedElementsForSave;
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) {
@@ -557,9 +602,7 @@ export const Editor: React.FC = () => {
return; return;
} }
console.warn("[Editor] Version conflict while saving drawing", { drawingId }); throw new DrawingSaveConflictError();
toast.error("Drawing changed in another tab. Refresh to load latest.");
return;
} }
throw err; throw err;
@@ -568,17 +611,38 @@ export const Editor: React.FC = () => {
await persistScene(0); await persistScene(0);
} catch (err) { } catch (err) {
if (err instanceof DrawingSaveConflictError) {
console.warn("[Editor] Version conflict while saving drawing", { drawingId });
toast.error("Drawing changed in another tab. Refresh to load latest.");
throw 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");
throw err;
} }
}; };
const enqueueSceneSave = useCallback( const enqueueSceneSave = useCallback(
(drawingId: string, elements: readonly any[], appState: any, files?: Record<string, any>) => { (
drawingId: string,
elements: readonly any[],
appState: any,
files?: Record<string, any>,
options?: { suppressErrors?: boolean }
) => {
const suppressErrors = options?.suppressErrors ?? true;
saveQueueRef.current = saveQueueRef.current saveQueueRef.current = saveQueueRef.current
.catch(() => undefined) .catch(() => undefined)
.then(async () => { .then(async () => {
if (!saveDataRef.current) return; if (!saveDataRef.current) return;
if (suppressErrors) {
try {
await saveDataRef.current(drawingId, elements, appState, files);
} catch {
// Background autosaves already surface their own toast via saveDataRef.
}
return;
}
await saveDataRef.current(drawingId, elements, appState, files); await saveDataRef.current(drawingId, elements, appState, files);
}); });
return saveQueueRef.current; return saveQueueRef.current;
@@ -590,7 +654,12 @@ export const Editor: React.FC = () => {
if (!drawingId) return; if (!drawingId) return;
try { try {
const candidateSnapshot = latestElementsRef.current ?? elements; const snapshotFromArgs = Array.isArray(elements) ? elements : [];
const snapshotFromRef = latestElementsRef.current ?? [];
const candidateSnapshot =
hasRenderableElements(snapshotFromArgs) || !hasRenderableElements(snapshotFromRef)
? snapshotFromArgs
: snapshotFromRef;
const { const {
snapshot: currentSnapshot, snapshot: currentSnapshot,
prevented: preventedPreviewOverwrite, prevented: preventedPreviewOverwrite,
@@ -598,6 +667,7 @@ export const Editor: React.FC = () => {
staleNonRenderableSnapshot: staleNonRenderablePreview, staleNonRenderableSnapshot: staleNonRenderablePreview,
} = resolveSafeSnapshot(candidateSnapshot); } = resolveSafeSnapshot(candidateSnapshot);
const currentFiles = latestFilesRef.current ?? files; const currentFiles = latestFilesRef.current ?? files;
const normalizedSnapshot = normalizeImageElementStatus(currentSnapshot, currentFiles);
if (suspiciousBlankLoadRef.current && !hasRenderableElements(currentSnapshot)) { if (suspiciousBlankLoadRef.current && !hasRenderableElements(currentSnapshot)) {
console.warn("[Editor] Blocking non-renderable preview due to suspicious blank load", { console.warn("[Editor] Blocking non-renderable preview due to suspicious blank load", {
drawingId, drawingId,
@@ -616,7 +686,7 @@ export const Editor: React.FC = () => {
} }
const svg = await exportToSvg({ const svg = await exportToSvg({
elements: currentSnapshot, elements: normalizedSnapshot,
appState: { appState: {
...appState, ...appState,
exportBackground: true, exportBackground: true,
@@ -628,7 +698,7 @@ export const Editor: React.FC = () => {
console.log("[Editor] Saving preview", { console.log("[Editor] Saving preview", {
drawingId, drawingId,
elementCount: currentSnapshot.length, elementCount: normalizedSnapshot.length,
}); });
await api.updateDrawing(drawingId, { preview }); await api.updateDrawing(drawingId, { preview });
@@ -1013,6 +1083,14 @@ export const Editor: React.FC = () => {
if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) { if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) {
hasSceneChangesSinceLoadRef.current = true; hasSceneChangesSinceLoadRef.current = true;
debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles); debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles);
if (savePreviewRef.current) {
void savePreviewRef.current(
id,
latestElementsRef.current,
latestAppStateRef.current,
nextFiles
);
}
} }
}, 1000); }, 1000);
@@ -1044,18 +1122,21 @@ export const Editor: React.FC = () => {
if (isSavingOnLeave) return; // Prevent double clicks if (isSavingOnLeave) return; // Prevent double clicks
setIsSavingOnLeave(true); setIsSavingOnLeave(true);
let shouldNavigate = false;
// 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) { // If editor API is not ready, allow navigation instead of trapping the user.
console.log("[Editor] Skipping back-navigation save: no scene changes since load", { shouldNavigate = true;
drawingId: id, } else if (!hasSceneChangesSinceLoadRef.current) {
}); console.log("[Editor] Skipping back-navigation save: no scene changes since load", {
navigate('/'); drawingId: id,
return; });
} shouldNavigate = true;
if (!id) return; } else if (!id) {
shouldNavigate = true;
} else {
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
const { const {
snapshot: safeElements, snapshot: safeElements,
@@ -1081,22 +1162,25 @@ export const Editor: React.FC = () => {
elementCount: safeElements.length, elementCount: safeElements.length,
}); });
toast.warning("Blank scene detected on load. Skipping save to protect existing data."); toast.warning("Blank scene detected on load. Skipping save to protect existing data.");
navigate('/'); shouldNavigate = true;
return; } else {
await Promise.all([
enqueueSceneSave(id, safeElements, appState, files, { suppressErrors: false }),
savePreviewRef.current(id, safeElements, appState, files)
]);
console.log("[Editor] Saved on back navigation", { drawingId: id });
shouldNavigate = true;
} }
await Promise.all([
enqueueSceneSave(id, safeElements, appState, files),
savePreviewRef.current(id, safeElements, appState, files)
]);
console.log("[Editor] Saved on back navigation", { drawingId: id });
} }
} catch (err) { } catch (err) {
console.error('Failed to save on back navigation', err); console.error('Failed to save on back navigation', err);
toast.error("Failed to save changes. Please retry before leaving.");
} finally { } finally {
setIsSavingOnLeave(false); setIsSavingOnLeave(false);
} }
navigate('/'); if (shouldNavigate) {
navigate('/');
}
}; };
return ( return (
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { normalizePreviewSvg, previewHasEmbeddedImages } from "../previewSvg";
describe("normalizePreviewSvg", () => {
it("adds viewBox from background rect when missing", () => {
const raw = [
'<svg width="1456.7890625" height="1213.81640625">',
'<rect x="0" y="0" width="728.39453125" height="606.908203125" fill="#fff"></rect>',
'<path d="M0 0 L20 20"></path>',
"</svg>",
].join("");
const normalized = normalizePreviewSvg(raw);
expect(normalized).toContain('viewBox="0 0 728.39453125 606.908203125"');
expect(normalized).toContain('preserveAspectRatio="xMidYMid meet"');
});
it("leaves existing viewBox unchanged", () => {
const raw = '<svg viewBox="0 0 100 50" width="200" height="100"></svg>';
const normalized = normalizePreviewSvg(raw);
expect(normalized).toContain('viewBox="0 0 100 50"');
});
it("detects embedded image tags", () => {
const raw = '<svg><image href="data:image/png;base64,AAAA"></image></svg>';
expect(previewHasEmbeddedImages(raw)).toBe(true);
expect(previewHasEmbeddedImages("<svg><rect/></svg>")).toBe(false);
});
it("repairs flattened image previews that are hidden by white canvas rect", () => {
const raw = [
'<svg viewBox="0 0 500 700" width="1000" height="1400">',
'<image width="100%" height="100%" href="data:image/png;base64,AAAA"></image>',
'<defs></defs>',
'<rect x="0" y="0" width="500" height="700" fill="#ffffff"></rect>',
"</svg>",
].join("");
const normalized = normalizePreviewSvg(raw);
expect(normalized).toContain('fill="transparent"');
});
});
+1 -1
View File
@@ -231,7 +231,7 @@ export const importLegacyFiles = async (
// Import each drawing entry // Import each drawing entry
for (let i = 0; i < drawings.length; i += 1) { for (let i = 0; i < drawings.length; i += 1) {
const d = drawings[i] as LegacyExportDrawing; const d = drawings[i] as LegacyExportDrawing;
const elements = Array.isArray(d.elements) ? d.elements : null; const elements = Array.isArray(d.elements) ? (d.elements as any[]) : null;
const appState = const appState =
typeof d.appState === "object" && d.appState !== null typeof d.appState === "object" && d.appState !== null
? (d.appState as Record<string, unknown>) ? (d.appState as Record<string, unknown>)
+108
View File
@@ -0,0 +1,108 @@
const parseDimension = (value: string | null): number | null => {
if (!value) return null;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
};
const parseCoordinate = (value: string | null): number | null => {
if (!value) return null;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
};
const parseViewBox = (value: string | null): { width: number; height: number } | null => {
if (!value) return null;
const parts = value.trim().split(/[,\s]+/).map((part) => Number.parseFloat(part));
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) return null;
const [, , width, height] = parts;
if (width <= 0 || height <= 0) return null;
return { width, height };
};
const isNear = (a: number, b: number, epsilon = 0.5): boolean => Math.abs(a - b) <= epsilon;
const maybeRepairFlattenedImagePreview = (svg: SVGSVGElement) => {
const rootImage = Array.from(svg.children).find(
(child) =>
child.tagName.toLowerCase() === "image" &&
/^(100%|1(?:\.0+)?%?)$/i.test(child.getAttribute("width") ?? "") &&
/^(100%|1(?:\.0+)?%?)$/i.test(child.getAttribute("height") ?? "") &&
/^data:image\//i.test(child.getAttribute("href") ?? child.getAttribute("xlink:href") ?? "")
);
if (!rootImage) return;
const hasPattern = svg.querySelector("pattern") !== null;
const hasUrlFill = Array.from(svg.querySelectorAll("[fill]")).some((node) =>
/^url\(#/i.test(node.getAttribute("fill") ?? "")
);
if (hasPattern || hasUrlFill) return;
const viewBox = parseViewBox(svg.getAttribute("viewBox"));
const fallbackWidth = parseDimension(svg.getAttribute("width"));
const fallbackHeight = parseDimension(svg.getAttribute("height"));
const canvasWidth = viewBox?.width ?? fallbackWidth;
const canvasHeight = viewBox?.height ?? fallbackHeight;
if (!canvasWidth || !canvasHeight) return;
const candidateRect = Array.from(svg.children).find((child) => {
if (child.tagName.toLowerCase() !== "rect") return false;
const fill = (child.getAttribute("fill") || "").trim().toLowerCase();
if (fill !== "#fff" && fill !== "#ffffff" && fill !== "white") return false;
const x = parseCoordinate(child.getAttribute("x"));
const y = parseCoordinate(child.getAttribute("y"));
const width = parseDimension(child.getAttribute("width"));
const height = parseDimension(child.getAttribute("height"));
if (x !== 0 || y !== 0 || !width || !height) return false;
return isNear(width, canvasWidth) && isNear(height, canvasHeight);
});
if (candidateRect) {
candidateRect.setAttribute("fill", "transparent");
}
};
export const previewHasEmbeddedImages = (
preview: string | null | undefined
): boolean => typeof preview === "string" && /<image[\s>]/i.test(preview);
export const normalizePreviewSvg = (preview: string | null | undefined): string | null => {
if (typeof preview !== "string" || preview.trim().length === 0) {
return preview ?? null;
}
if (typeof DOMParser === "undefined") {
return preview;
}
try {
const doc = new DOMParser().parseFromString(preview, "image/svg+xml");
const svg = doc.documentElement;
if (!svg || svg.tagName.toLowerCase() !== "svg") {
return preview;
}
if (!svg.hasAttribute("viewBox")) {
const backgroundRect = svg.querySelector("rect[x='0'][y='0']");
const width =
parseDimension(backgroundRect?.getAttribute("width") ?? null) ??
parseDimension(svg.getAttribute("width"));
const height =
parseDimension(backgroundRect?.getAttribute("height") ?? null) ??
parseDimension(svg.getAttribute("height"));
if (width && height) {
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
}
}
if (!svg.hasAttribute("preserveAspectRatio")) {
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
}
maybeRepairFlattenedImagePreview(svg as unknown as SVGSVGElement);
return svg.outerHTML;
} catch {
return preview;
}
};