fix: sync pasted/uploaded images across collaborating tabs (#36)

* fix: sync pasted/uploaded images across collaborating tabs

- Implement file delta synchronization to broadcast image file data
- Add periodic file sync check to catch async file data arrival
- Wrap Excalidraw addFiles API to automatically emit file changes
- Enhance socket element-update to include file payloads
- Add comprehensive E2E test for image collaboration scenarios
- Improve CORS flexibility for development localhost ports

Fixes #25: New images not appearing when collaborating - collaborators
now see uploaded images immediately instead of placeholder until refresh.

* perf: increase file sync polling interval from 500ms to 1000ms

Reduces CPU overhead while still catching async file arrivals. Most
updates go through the addFiles wrapper anyway.

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>
This commit is contained in:
Adrian-Ryan Acala
2026-01-20 10:50:11 -08:00
committed by Zimeng Xiong
parent 77c22916a8
commit 865285fbb7
4 changed files with 535 additions and 55 deletions
+20 -4
View File
@@ -96,6 +96,22 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => {
const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL);
console.log("Allowed origins:", allowedOrigins);
const isDev = (process.env.NODE_ENV || "development") !== "production";
const isLocalDevOrigin = (origin: string): boolean => {
// Allow any localhost/127.0.0.1 port in dev (Vite often picks a free port).
return (
/^http:\/\/localhost:\d+$/i.test(origin) ||
/^http:\/\/127\.0\.0\.1:\d+$/i.test(origin)
);
};
const isAllowedOrigin = (origin?: string): boolean => {
if (!origin) return true; // non-browser clients / same-origin
if (allowedOrigins.includes(origin)) return true;
if (isDev && isLocalDevOrigin(origin)) return true;
return false;
};
const uploadDir = path.resolve(__dirname, "../uploads");
const moveFile = async (source: string, destination: string) => {
@@ -138,7 +154,7 @@ app.set("trust proxy", 1);
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: allowedOrigins,
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
credentials: true,
},
maxHttpBufferSize: 1e8,
@@ -236,7 +252,7 @@ const upload = multer({
app.use(
cors({
origin: allowedOrigins,
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
exposedHeaders: ["x-csrf-token"],
@@ -402,7 +418,7 @@ const csrfProtectionMiddleware = (
const refererValue = Array.isArray(referer) ? referer[0] : referer;
if (originValue) {
if (!allowedOrigins.includes(originValue)) {
if (!isAllowedOrigin(originValue)) {
return res.status(403).json({
error: "CSRF origin mismatch",
message: "Origin not allowed",
@@ -411,7 +427,7 @@ const csrfProtectionMiddleware = (
} else if (refererValue) {
// If no Origin but Referer exists, validate its *origin* (avoid prefix bypass)
const refererOrigin = getOriginFromReferer(refererValue);
if (!refererOrigin || !allowedOrigins.includes(refererOrigin)) {
if (!refererOrigin || !isAllowedOrigin(refererOrigin)) {
return res.status(403).json({
error: "CSRF referer mismatch",
message: "Referer not allowed",