Added back Remote Cursors
This commit is contained in:
@@ -7,6 +7,11 @@ NEXTAUTH_SECRET=your-very-long-secret-here
|
|||||||
GITHUB_CLIENT_ID=your-github-client-id
|
GITHUB_CLIENT_ID=your-github-client-id
|
||||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||||
|
|
||||||
|
# OAuth Callback URL (change to your domain for production)
|
||||||
|
# For local development: http://localhost:3000/auth/github/callback
|
||||||
|
# For production: https://your-domain.com/auth/github/callback
|
||||||
|
CALLBACK_URL=http://localhost:3000/auth/github/callback
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useSocket } from '../context/SocketContext';
|
import { useSocket } from '../context/SocketContext';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Pixel } from '@shared/types';
|
import { Pixel, PaintAck } from '../types';
|
||||||
|
|
||||||
interface CanvasProps {
|
interface CanvasProps {
|
||||||
cursors: any[];
|
cursors: any[];
|
||||||
@@ -26,6 +26,7 @@ export default function Canvas({
|
|||||||
const { user, showLoginPrompt } = useAuth();
|
const { user, showLoginPrompt } = useAuth();
|
||||||
|
|
||||||
const pixelsRef = useRef<Map<string, Pixel>>(new Map());
|
const pixelsRef = useRef<Map<string, Pixel>>(new Map());
|
||||||
|
const pendingPixelsRef = useRef<Map<string, { pixel: Pixel; timestamp: number }>>(new Map());
|
||||||
const animationFrameRef = useRef<number>();
|
const animationFrameRef = useRef<number>();
|
||||||
const mouseScreenRef = useRef({ x: 0, y: 0 });
|
const mouseScreenRef = useRef({ x: 0, y: 0 });
|
||||||
const lastPanRef = useRef({ x: 0, y: 0 });
|
const lastPanRef = useRef({ x: 0, y: 0 });
|
||||||
@@ -76,10 +77,26 @@ export default function Canvas({
|
|||||||
pixelsRef.current.set(`${p.x},${p.y}`, p);
|
pixelsRef.current.set(`${p.x},${p.y}`, p);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('canvas-update', (update: { pixels: Pixel[] }) => {
|
||||||
|
for (const p of update.pixels) {
|
||||||
|
const key = `${p.x},${p.y}`;
|
||||||
|
const pending = pendingPixelsRef.current.get(key);
|
||||||
|
if (pending) {
|
||||||
|
const pendingKey = `${pending.pixel.x},${pending.pixel.y}`;
|
||||||
|
pendingPixelsRef.current.delete(pendingKey);
|
||||||
|
}
|
||||||
|
pixelsRef.current.set(key, p);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', resize);
|
window.removeEventListener('resize', resize);
|
||||||
|
if (socket) {
|
||||||
|
socket.off('canvas-state');
|
||||||
|
socket.off('canvas-update');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
@@ -159,6 +176,30 @@ export default function Canvas({
|
|||||||
ctx.lineTo(sx + 5 * scale, sy + 15 * scale);
|
ctx.lineTo(sx + 5 * scale, sy + 15 * scale);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
|
if (c.username) {
|
||||||
|
const fontSize = Math.max(12, 14 * scale);
|
||||||
|
ctx.font = `${fontSize}px sans-serif`;
|
||||||
|
const textWidth = ctx.measureText(c.username).width;
|
||||||
|
const padding = 4 * scale;
|
||||||
|
const labelX = sx + 18 * scale;
|
||||||
|
const labelY = sy + 5 * scale;
|
||||||
|
const labelWidth = textWidth + padding * 2;
|
||||||
|
const labelHeight = fontSize + padding;
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
ctx.beginPath();
|
||||||
|
if (ctx.roundRect) {
|
||||||
|
ctx.roundRect(labelX, labelY - fontSize + padding, labelWidth, labelHeight, 4 * scale);
|
||||||
|
} else {
|
||||||
|
ctx.rect(labelX, labelY - fontSize + padding, labelWidth, labelHeight);
|
||||||
|
}
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(c.username, labelX + padding, labelY - fontSize + padding);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -192,7 +233,7 @@ export default function Canvas({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const paintPixel = () => {
|
const paintPixel = useCallback(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
showLoginPrompt();
|
showLoginPrompt();
|
||||||
return;
|
return;
|
||||||
@@ -209,8 +250,24 @@ export default function Canvas({
|
|||||||
pixelsRef.current.set(`${p.x},${p.y}`, p);
|
pixelsRef.current.set(`${p.x},${p.y}`, p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendPaintPixels(pixels);
|
|
||||||
};
|
sendPaintPixels(pixels, (ack: PaintAck) => {
|
||||||
|
if (!ack.success) {
|
||||||
|
for (const pixel of pixels) {
|
||||||
|
const key = `${pixel.x},${pixel.y}`;
|
||||||
|
if (!pendingPixelsRef.current.has(key)) {
|
||||||
|
pendingPixelsRef.current.set(key, { pixel, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (ack.conflicts > 0 && ack.pixels) {
|
||||||
|
for (const serverPixel of ack.pixels) {
|
||||||
|
const key = `${serverPixel.x},${serverPixel.y}`;
|
||||||
|
pixelsRef.current.set(key, serverPixel);
|
||||||
|
pendingPixelsRef.current.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [user, showLoginPrompt, sendPaintPixels]);
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef } from 'react';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import { Pixel, Cursor } from '../types';
|
import { Pixel, Cursor, PaintAck, PendingPixel } from '../types';
|
||||||
|
|
||||||
interface SocketContextType {
|
interface SocketContextType {
|
||||||
socket: Socket | null;
|
socket: Socket | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
cursors: Cursor[];
|
cursors: Cursor[];
|
||||||
sendCursorMove: (x: number, y: number) => void;
|
sendCursorMove: (x: number, y: number) => void;
|
||||||
sendPaintPixels: (pixels: Pixel[]) => void;
|
sendPaintPixels: (pixels: Pixel[], onAck?: (ack: PaintAck) => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SocketContext = createContext<SocketContextType | undefined>(undefined);
|
const SocketContext = createContext<SocketContextType | undefined>(undefined);
|
||||||
@@ -18,16 +18,15 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
|||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [cursors, setCursors] = useState<Cursor[]>([]);
|
const [cursors, setCursors] = useState<Cursor[]>([]);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const pendingPixelsRef = useRef<Map<string, PendingPixel>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only connect sock if user is authed
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setSocket(null);
|
setSocket(null);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get socket auth token first
|
|
||||||
fetch('/api/socket-token', {
|
fetch('/api/socket-token', {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
@@ -39,14 +38,12 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
// Conn with token
|
|
||||||
const newSocket = io('', {
|
const newSocket = io('', {
|
||||||
transports: ['websocket']
|
transports: ['websocket']
|
||||||
});
|
});
|
||||||
|
|
||||||
newSocket.on('connect', () => {
|
newSocket.on('connect', () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
// auth when logged in
|
|
||||||
newSocket.emit('join-canvas', data.token);
|
newSocket.emit('join-canvas', data.token);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,11 +65,9 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
newSocket.on('error', (message: string) => {
|
newSocket.on('error', (message: string) => {
|
||||||
console.error('Socket error:', message);
|
console.error('Socket error:', message);
|
||||||
// If auth fails we disconnect and retry after delay
|
|
||||||
if (message.includes('Authentication required') || message.includes('expired')) {
|
if (message.includes('Authentication required') || message.includes('expired')) {
|
||||||
newSocket.disconnect();
|
newSocket.disconnect();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Trigger reconn
|
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -94,15 +89,74 @@ export function SocketProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const resendPendingPixels = useCallback((socket: Socket) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxRetries = 3;
|
||||||
|
const retryDelay = 1000;
|
||||||
|
|
||||||
|
for (const [key, pending] of pendingPixelsRef.current) {
|
||||||
|
if (pending.retries >= maxRetries) {
|
||||||
|
pendingPixelsRef.current.delete(key);
|
||||||
|
window.dispatchEvent(new CustomEvent('pixel-sync-failed', { detail: pending.pixel }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now - pending.timestamp > retryDelay * (pending.retries + 1)) {
|
||||||
|
pending.retries++;
|
||||||
|
pending.timestamp = now;
|
||||||
|
socket.emit('paint-pixels', {
|
||||||
|
pixels: [pending.pixel],
|
||||||
|
timestamp: pending.timestamp
|
||||||
|
}, (ack: PaintAck) => {
|
||||||
|
if (ack.success) {
|
||||||
|
pendingPixelsRef.current.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket || !isConnected) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (pendingPixelsRef.current.size > 0) {
|
||||||
|
resendPendingPixels(socket);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [socket, isConnected, resendPendingPixels]);
|
||||||
|
|
||||||
const sendCursorMove = (x: number, y: number) => {
|
const sendCursorMove = (x: number, y: number) => {
|
||||||
if (socket && isConnected) {
|
if (socket && isConnected) {
|
||||||
socket.emit('cursor-move', { x, y });
|
socket.emit('cursor-move', { x, y });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendPaintPixels = (pixels: Pixel[]) => {
|
const sendPaintPixels = (pixels: Pixel[], onAck?: (ack: PaintAck) => void) => {
|
||||||
if (socket && isConnected) {
|
if (socket && isConnected) {
|
||||||
socket.emit('paint-pixels', pixels);
|
const timestamp = Date.now();
|
||||||
|
const key = `${pixels[0]?.x},${pixels[0]?.y}`;
|
||||||
|
|
||||||
|
socket.emit('paint-pixels', { pixels, timestamp }, (ack: PaintAck) => {
|
||||||
|
if (onAck) {
|
||||||
|
onAck(ack);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ack.success) {
|
||||||
|
pendingPixelsRef.current.delete(key);
|
||||||
|
} else if (ack.conflicts > 0 && ack.pixels) {
|
||||||
|
for (const pixel of ack.pixels) {
|
||||||
|
const pixelKey = `${pixel.x},${pixel.y}`;
|
||||||
|
pendingPixelsRef.current.set(pixelKey, {
|
||||||
|
pixel,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
retries: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
|
|
||||||
|
|
||||||
export interface Cursor {
|
export interface Cursor {
|
||||||
userId: string;
|
userId: string;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -13,4 +12,18 @@ export interface Pixel {
|
|||||||
color: string;
|
color: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaintAck {
|
||||||
|
success: boolean;
|
||||||
|
saved: number;
|
||||||
|
conflicts: number;
|
||||||
|
pixels?: Pixel[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingPixel {
|
||||||
|
pixel: Pixel;
|
||||||
|
timestamp: number;
|
||||||
|
retries: number;
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ import passport from 'passport';
|
|||||||
import { Strategy as GitHubStrategy } from 'passport-github2';
|
import { Strategy as GitHubStrategy } from 'passport-github2';
|
||||||
import { db } from './database';
|
import { db } from './database';
|
||||||
|
|
||||||
|
const callbackURL = process.env.CALLBACK_URL || '/auth/github/callback';
|
||||||
|
|
||||||
passport.serializeUser((user: any, done) => {
|
passport.serializeUser((user: any, done) => {
|
||||||
done(null, user.id);
|
done(null, user.id);
|
||||||
});
|
});
|
||||||
@@ -19,7 +21,7 @@ passport.deserializeUser((id: number, done) => {
|
|||||||
passport.use(new GitHubStrategy({
|
passport.use(new GitHubStrategy({
|
||||||
clientID: process.env.GITHUB_CLIENT_ID!,
|
clientID: process.env.GITHUB_CLIENT_ID!,
|
||||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
callbackURL: '/auth/github/callback'
|
callbackURL: callbackURL
|
||||||
}, async (accessToken, refreshToken, profile, done) => {
|
}, async (accessToken, refreshToken, profile, done) => {
|
||||||
try {
|
try {
|
||||||
let user;
|
let user;
|
||||||
|
|||||||
@@ -28,29 +28,93 @@ db.exec(`
|
|||||||
x INTEGER NOT NULL,
|
x INTEGER NOT NULL,
|
||||||
y INTEGER NOT NULL,
|
y INTEGER NOT NULL,
|
||||||
color TEXT NOT NULL,
|
color TEXT NOT NULL,
|
||||||
|
user_id INTEGER,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (x, y)
|
PRIMARY KEY (x, y),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pixels_updated ON pixels(updated_at);
|
CREATE INDEX IF NOT EXISTS idx_pixels_updated ON pixels(updated_at);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const tableInfo = db.pragma('table_info(pixels)') as { name: string }[];
|
||||||
|
const hasUserId = tableInfo.some(col => col.name === 'user_id');
|
||||||
|
if (!hasUserId) {
|
||||||
|
db.exec('ALTER TABLE pixels ADD COLUMN user_id INTEGER REFERENCES users(id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexes = db.pragma('index_list(pixels)') as { name: string }[];
|
||||||
|
const hasIndex = indexes.some(idx => idx.name === 'idx_pixels_updated');
|
||||||
|
if (!hasIndex) {
|
||||||
|
db.exec('CREATE INDEX idx_pixels_updated ON pixels(updated_at)');
|
||||||
|
}
|
||||||
|
|
||||||
export function generateUserId(): string {
|
export function generateUserId(): string {
|
||||||
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertPixels(pixels: { x: number; y: number; color: string }[]): void {
|
function isValidColor(color: string): boolean {
|
||||||
|
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePixel(pixel: { x: number; y: number; color: string }): string | null {
|
||||||
|
if (!Number.isInteger(pixel.x) || pixel.x < -1000000 || pixel.x > 1000000) {
|
||||||
|
return 'Invalid x coordinate';
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(pixel.y) || pixel.y < -1000000 || pixel.y > 1000000) {
|
||||||
|
return 'Invalid y coordinate';
|
||||||
|
}
|
||||||
|
if (!isValidColor(pixel.color)) {
|
||||||
|
return 'Invalid color format';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertPixelsWithConflictResolution(
|
||||||
|
pixels: { x: number; y: number; color: string; userId?: number }[],
|
||||||
|
clientTimestamp: number
|
||||||
|
): { saved: number; conflicts: number } {
|
||||||
|
const getStmt = db.prepare('SELECT updated_at FROM pixels WHERE x = ? AND y = ?');
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'INSERT OR REPLACE INTO pixels (x, y, color, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
|
'INSERT OR REPLACE INTO pixels (x, y, color, user_id, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let saved = 0;
|
||||||
|
let conflicts = 0;
|
||||||
|
|
||||||
const transaction = db.transaction((pixels: any[]) => {
|
const transaction = db.transaction((pixels: any[]) => {
|
||||||
for (const pixel of pixels) {
|
for (const pixel of pixels) {
|
||||||
stmt.run(pixel.x, pixel.y, pixel.color);
|
try {
|
||||||
|
const existing = getStmt.get(pixel.x, pixel.y) as { updated_at: string } | undefined;
|
||||||
|
if (existing) {
|
||||||
|
const existingTime = new Date(existing.updated_at).getTime();
|
||||||
|
if (clientTimestamp && existingTime > clientTimestamp) {
|
||||||
|
conflicts++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stmt.run(pixel.x, pixel.y, pixel.color, pixel.userId || null);
|
||||||
|
saved++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error upserting pixel:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
transaction(pixels);
|
transaction(pixels);
|
||||||
|
return { saved, conflicts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPixelsInRange(
|
||||||
|
minX: number,
|
||||||
|
maxX: number,
|
||||||
|
minY: number,
|
||||||
|
maxY: number
|
||||||
|
): { x: number; y: number; color: string }[] {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'SELECT x, y, color FROM pixels WHERE x >= ? AND x <= ? AND y >= ? AND y <= ? ORDER BY x, y'
|
||||||
|
);
|
||||||
|
return stmt.all(minX, maxX, minY, maxY) as { x: number; y: number; color: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllPixels(): { x: number; y: number; color: string }[] {
|
export function getAllPixels(): { x: number; y: number; color: string }[] {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { logger } from './logger';
|
|||||||
import './auth';
|
import './auth';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set('trust proxy', 1);
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const io = new SocketIOServer(server, {
|
const io = new SocketIOServer(server, {
|
||||||
cors: {
|
cors: {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const securityHeaders = helmet({
|
|||||||
frameSrc: ["'none'"],
|
frameSrc: ["'none'"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
crossOriginEmbedderPolicy: false, // Req. for Socket.io
|
crossOriginEmbedderPolicy: false,
|
||||||
hsts: {
|
hsts: {
|
||||||
maxAge: 31536000,
|
maxAge: 31536000,
|
||||||
includeSubDomains: true,
|
includeSubDomains: true,
|
||||||
@@ -37,6 +37,18 @@ export const createRateLimit = rateLimit({
|
|||||||
},
|
},
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req: Request) => {
|
||||||
|
const forwarded = req.headers['x-forwarded-for'];
|
||||||
|
if (forwarded) {
|
||||||
|
return typeof forwarded === 'string'
|
||||||
|
? forwarded.split(',')[0].trim()
|
||||||
|
: forwarded[0];
|
||||||
|
}
|
||||||
|
return req.ip ||
|
||||||
|
req.headers['x-real-ip'] as string ||
|
||||||
|
req.connection.remoteAddress ||
|
||||||
|
'unknown';
|
||||||
|
},
|
||||||
handler: (req: Request, res: Response) => {
|
handler: (req: Request, res: Response) => {
|
||||||
securityLogger.rateLimitExceeded(
|
securityLogger.rateLimitExceeded(
|
||||||
req.ip || req.connection.remoteAddress || 'unknown',
|
req.ip || req.connection.remoteAddress || 'unknown',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Server as SocketIOServer, Socket } from 'socket.io';
|
import { Server as SocketIOServer, Socket } from 'socket.io';
|
||||||
import { upsertPixels, getAllPixels, db } from './database';
|
import { upsertPixelsWithConflictResolution, getAllPixels, db, validatePixel } from './database';
|
||||||
import { Cursor, Pixel } from '../shared/types';
|
import { Cursor, Pixel } from '../shared/types';
|
||||||
import passport from 'passport';
|
import passport from 'passport';
|
||||||
|
|
||||||
@@ -18,7 +18,19 @@ interface UserSocket extends Socket {
|
|||||||
user?: any;
|
user?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get user data from db
|
interface PaintRequest {
|
||||||
|
pixels: Pixel[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaintAck {
|
||||||
|
success: boolean;
|
||||||
|
saved: number;
|
||||||
|
conflicts: number;
|
||||||
|
pixels?: Pixel[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function getUserById(userId: number) {
|
function getUserById(userId: number) {
|
||||||
try {
|
try {
|
||||||
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
|
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||||
@@ -30,7 +42,7 @@ function getUserById(userId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupSocketIO(io: SocketIOServer): void {
|
export function setupSocketIO(io: SocketIOServer): void {
|
||||||
const connectedUsers = new Map<string, { x: number; y: number; color: string; username: string; userId: number }>();
|
const connectedUsers = new Map<string, { x: number; y: number; color: string; username: string; userId: string }>();
|
||||||
|
|
||||||
io.on('connection', (socket: Socket) => {
|
io.on('connection', (socket: Socket) => {
|
||||||
const userSocket = socket as UserSocket;
|
const userSocket = socket as UserSocket;
|
||||||
@@ -75,18 +87,52 @@ export function setupSocketIO(io: SocketIOServer): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('paint-pixels', async (pixels: Pixel[]) => {
|
socket.on('paint-pixels', async (request: PaintRequest, callback: (ack: PaintAck) => void) => {
|
||||||
try {
|
try {
|
||||||
if (!userSocket.user) {
|
if (!userSocket.user) {
|
||||||
socket.emit('error', 'Authentication required to paint pixels');
|
const ack: PaintAck = { success: false, saved: 0, conflicts: 0, message: 'Authentication required' };
|
||||||
|
callback(ack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertPixels(pixels);
|
const { pixels, timestamp } = request;
|
||||||
socket.broadcast.emit('canvas-update', { pixels });
|
|
||||||
|
if (!Array.isArray(pixels) || pixels.length === 0) {
|
||||||
|
const ack: PaintAck = { success: false, saved: 0, conflicts: 0, message: 'Invalid pixels data' };
|
||||||
|
callback(ack);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pixel of pixels) {
|
||||||
|
const validationError = validatePixel(pixel);
|
||||||
|
if (validationError) {
|
||||||
|
const ack: PaintAck = { success: false, saved: 0, conflicts: 0, message: validationError };
|
||||||
|
callback(ack);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIdNum = typeof userData.id === 'number' ? userData.id : parseInt(userData.id, 10);
|
||||||
|
const result = upsertPixelsWithConflictResolution(
|
||||||
|
pixels.map(p => ({ ...p, userId: userIdNum })),
|
||||||
|
timestamp || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.saved > 0) {
|
||||||
|
socket.broadcast.emit('canvas-update', { pixels });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ack: PaintAck = {
|
||||||
|
success: true,
|
||||||
|
saved: result.saved,
|
||||||
|
conflicts: result.conflicts,
|
||||||
|
pixels: result.conflicts > 0 ? pixels : undefined
|
||||||
|
};
|
||||||
|
callback(ack);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving pixels:', error);
|
console.error('Error saving pixels:', error);
|
||||||
socket.emit('error', 'Failed to save pixels');
|
const ack: PaintAck = { success: false, saved: 0, conflicts: 0, message: 'Failed to save pixels' };
|
||||||
|
callback(ack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ export interface CanvasUpdate {
|
|||||||
pixels: Pixel[];
|
pixels: Pixel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaintRequest {
|
||||||
|
pixels: Pixel[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaintAck {
|
||||||
|
success: boolean;
|
||||||
|
saved: number;
|
||||||
|
conflicts: number;
|
||||||
|
pixels?: Pixel[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -40,9 +53,15 @@ export interface AuthResponse {
|
|||||||
export interface SocketEvents {
|
export interface SocketEvents {
|
||||||
'join-canvas': (token: string) => void;
|
'join-canvas': (token: string) => void;
|
||||||
'cursor-move': (cursor: Cursor) => void;
|
'cursor-move': (cursor: Cursor) => void;
|
||||||
'paint-pixels': (pixels: Pixel[]) => void;
|
'paint-pixels': (request: PaintRequest, callback: (ack: PaintAck) => void) => void;
|
||||||
'canvas-update': (update: CanvasUpdate) => void;
|
'canvas-update': (update: CanvasUpdate) => void;
|
||||||
'cursor-update': (cursors: Cursor[]) => void;
|
'cursor-update': (cursors: Cursor[]) => void;
|
||||||
'canvas-state': (pixels: Pixel[]) => void;
|
'canvas-state': (pixels: Pixel[]) => void;
|
||||||
'error': (message: string) => void;
|
'error': (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingPixel {
|
||||||
|
pixel: Pixel;
|
||||||
|
timestamp: number;
|
||||||
|
retries: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user