diff --git a/.env.example b/.env.example index 1710adb..ce21b8a 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ NEXTAUTH_SECRET=your-very-long-secret-here GITHUB_CLIENT_ID=your-github-client-id 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 diff --git a/client/src/components/Canvas.tsx b/client/src/components/Canvas.tsx index ee218f2..8f9ba42 100644 --- a/client/src/components/Canvas.tsx +++ b/client/src/components/Canvas.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useSocket } from '../context/SocketContext'; import { useAuth } from '../context/AuthContext'; -import { Pixel } from '@shared/types'; +import { Pixel, PaintAck } from '../types'; interface CanvasProps { cursors: any[]; @@ -26,6 +26,7 @@ export default function Canvas({ const { user, showLoginPrompt } = useAuth(); const pixelsRef = useRef>(new Map()); + const pendingPixelsRef = useRef>(new Map()); const animationFrameRef = useRef(); const mouseScreenRef = 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); } }); + + 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 () => { window.removeEventListener('resize', resize); + if (socket) { + socket.off('canvas-state'); + socket.off('canvas-update'); + } }; }, [socket]); @@ -159,6 +176,30 @@ export default function Canvas({ ctx.lineTo(sx + 5 * scale, sy + 15 * scale); ctx.closePath(); 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) { showLoginPrompt(); return; @@ -209,8 +250,24 @@ export default function Canvas({ 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) => { if (e.button === 1) { diff --git a/client/src/context/SocketContext.tsx b/client/src/context/SocketContext.tsx index 634a58c..c364513 100644 --- a/client/src/context/SocketContext.tsx +++ b/client/src/context/SocketContext.tsx @@ -1,14 +1,14 @@ 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 { Pixel, Cursor } from '../types'; +import { Pixel, Cursor, PaintAck, PendingPixel } from '../types'; interface SocketContextType { socket: Socket | null; isConnected: boolean; cursors: Cursor[]; sendCursorMove: (x: number, y: number) => void; - sendPaintPixels: (pixels: Pixel[]) => void; + sendPaintPixels: (pixels: Pixel[], onAck?: (ack: PaintAck) => void) => void; } const SocketContext = createContext(undefined); @@ -18,16 +18,15 @@ export function SocketProvider({ children }: { children: ReactNode }) { const [isConnected, setIsConnected] = useState(false); const [cursors, setCursors] = useState([]); const { user } = useAuth(); + const pendingPixelsRef = useRef>(new Map()); useEffect(() => { - // Only connect sock if user is authed if (!user) { setSocket(null); setIsConnected(false); return; } - // Get socket auth token first fetch('/api/socket-token', { credentials: 'include' }) @@ -39,14 +38,12 @@ export function SocketProvider({ children }: { children: ReactNode }) { }) .then(data => { if (data.token) { - // Conn with token const newSocket = io('', { transports: ['websocket'] }); newSocket.on('connect', () => { setIsConnected(true); - // auth when logged in newSocket.emit('join-canvas', data.token); }); @@ -68,11 +65,9 @@ export function SocketProvider({ children }: { children: ReactNode }) { newSocket.on('error', (message: string) => { console.error('Socket error:', message); - // If auth fails we disconnect and retry after delay if (message.includes('Authentication required') || message.includes('expired')) { newSocket.disconnect(); setTimeout(() => { - // Trigger reconn }, 2000); } }); @@ -94,15 +89,74 @@ export function SocketProvider({ children }: { children: ReactNode }) { }); }, [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) => { if (socket && isConnected) { socket.emit('cursor-move', { x, y }); } }; - const sendPaintPixels = (pixels: Pixel[]) => { + const sendPaintPixels = (pixels: Pixel[], onAck?: (ack: PaintAck) => void) => { 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 + }); + } + } + }); } }; diff --git a/client/src/types.ts b/client/src/types.ts index d09179d..8e37dac 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -1,5 +1,4 @@ - export interface Cursor { userId: string; x: number; @@ -13,4 +12,18 @@ export interface Pixel { color: string; userId?: 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; } \ No newline at end of file diff --git a/server/auth.ts b/server/auth.ts index 1dd3bbe..e917331 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -2,6 +2,8 @@ import passport from 'passport'; import { Strategy as GitHubStrategy } from 'passport-github2'; import { db } from './database'; +const callbackURL = process.env.CALLBACK_URL || '/auth/github/callback'; + passport.serializeUser((user: any, done) => { done(null, user.id); }); @@ -19,7 +21,7 @@ passport.deserializeUser((id: number, done) => { passport.use(new GitHubStrategy({ clientID: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, - callbackURL: '/auth/github/callback' + callbackURL: callbackURL }, async (accessToken, refreshToken, profile, done) => { try { let user; diff --git a/server/database.ts b/server/database.ts index f0e13ba..178ea80 100644 --- a/server/database.ts +++ b/server/database.ts @@ -28,29 +28,93 @@ db.exec(` x INTEGER NOT NULL, y INTEGER NOT NULL, color TEXT NOT NULL, + user_id INTEGER, 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); `); +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 { 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( - '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[]) => { 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); + 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 }[] { diff --git a/server/index.ts b/server/index.ts index 20d4358..6edf606 100644 --- a/server/index.ts +++ b/server/index.ts @@ -20,6 +20,7 @@ import { logger } from './logger'; import './auth'; const app = express(); +app.set('trust proxy', 1); const server = http.createServer(app); const io = new SocketIOServer(server, { cors: { diff --git a/server/middleware.ts b/server/middleware.ts index 6f9f1e0..38f5523 100644 --- a/server/middleware.ts +++ b/server/middleware.ts @@ -19,7 +19,7 @@ export const securityHeaders = helmet({ frameSrc: ["'none'"], }, }, - crossOriginEmbedderPolicy: false, // Req. for Socket.io + crossOriginEmbedderPolicy: false, hsts: { maxAge: 31536000, includeSubDomains: true, @@ -37,6 +37,18 @@ export const createRateLimit = rateLimit({ }, standardHeaders: true, 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) => { securityLogger.rateLimitExceeded( req.ip || req.connection.remoteAddress || 'unknown', diff --git a/server/socket.ts b/server/socket.ts index a9d3cf3..db3e3ca 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -1,5 +1,5 @@ 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 passport from 'passport'; @@ -18,7 +18,19 @@ interface UserSocket extends Socket { 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) { try { const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); @@ -30,7 +42,7 @@ function getUserById(userId: number) { } export function setupSocketIO(io: SocketIOServer): void { - const connectedUsers = new Map(); + const connectedUsers = new Map(); io.on('connection', (socket: Socket) => { 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 { 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; } - upsertPixels(pixels); - socket.broadcast.emit('canvas-update', { pixels }); + const { pixels, timestamp } = request; + + 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) { 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); } }); diff --git a/shared/types.ts b/shared/types.ts index 2fad1ae..c6b68dd 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -22,6 +22,19 @@ export interface CanvasUpdate { 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 { username: string; password: string; @@ -40,9 +53,15 @@ export interface AuthResponse { export interface SocketEvents { 'join-canvas': (token: string) => 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; 'cursor-update': (cursors: Cursor[]) => void; 'canvas-state': (pixels: Pixel[]) => void; 'error': (message: string) => void; } + +export interface PendingPixel { + pixel: Pixel; + timestamp: number; + retries: number; +}