Added back Remote Cursors
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }[] {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<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) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user