Initial Commit
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
NEXTAUTH_SECRET=your-very-long-secret-here
|
||||||
|
|
||||||
|
# GitHub OAuth Configuration
|
||||||
|
# Create OAuth app at: https://github.com/settings/applications/new
|
||||||
|
# Homepage URL: http://localhost:3000
|
||||||
|
# Callback URL: http://localhost:3000/auth/github/callback
|
||||||
|
GITHUB_CLIENT_ID=your-github-client-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_URL=sqlite:./data/invicanvas.db
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.DS_Store
|
||||||
|
client/node_modules
|
||||||
|
client/dist
|
||||||
|
client/.env
|
||||||
|
client/.env.local
|
||||||
|
client/.env.production
|
||||||
|
server/data
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>InviCanvas - Collaborative Pixel Art</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
client/package.json
Normal file
23
client/package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "invicanvas-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"socket.io-client": "^4.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
client/src/App.tsx
Normal file
65
client/src/App.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { SocketProvider, useSocket } from './context/SocketContext';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import Canvas from './components/Canvas';
|
||||||
|
import Controls from './components/Controls';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { cursors, sendCursorMove } = useSocket();
|
||||||
|
const [brushColor, setBrushColor] = useState('#ffffff');
|
||||||
|
const [brushSize, setBrushSize] = useState(1);
|
||||||
|
const [currentCanvasPosition, setCurrentCanvasPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [targetPosition, setTargetPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
// Parse URL hash for shared location
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash && hash.includes('x=') && hash.includes('y=')) {
|
||||||
|
const params = new URLSearchParams(hash.substring(1));
|
||||||
|
const x = parseInt(params.get('x') || '0');
|
||||||
|
const y = parseInt(params.get('y') || '0');
|
||||||
|
setTargetPosition({ x, y });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Canvas
|
||||||
|
cursors={cursors}
|
||||||
|
onCursorMove={sendCursorMove}
|
||||||
|
onCanvasPositionChange={(x, y) => setCurrentCanvasPosition({ x, y })}
|
||||||
|
targetPosition={targetPosition}
|
||||||
|
brushColor={brushColor}
|
||||||
|
brushSize={brushSize}
|
||||||
|
/>
|
||||||
|
<Controls
|
||||||
|
brushColor={brushColor}
|
||||||
|
brushSize={brushSize}
|
||||||
|
onBrushColorChange={setBrushColor}
|
||||||
|
onBrushSizeChange={setBrushSize}
|
||||||
|
canvasX={currentCanvasPosition.x}
|
||||||
|
canvasY={currentCanvasPosition.y}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
left: '20px',
|
||||||
|
color: '#888',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
zIndex: 100
|
||||||
|
}}>
|
||||||
|
<div>Scroll to zoom • Middle-click to pan • Left-click to paint</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppWrapper() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<SocketProvider>
|
||||||
|
<App />
|
||||||
|
</SocketProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
client/src/components/Canvas.tsx
Normal file
301
client/src/components/Canvas.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSocket } from '../context/SocketContext';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { Pixel } from '@shared/types';
|
||||||
|
|
||||||
|
interface CanvasProps {
|
||||||
|
cursors: any[];
|
||||||
|
onCursorMove: (x: number, y: number) => void;
|
||||||
|
brushColor: string;
|
||||||
|
brushSize: number;
|
||||||
|
onCanvasPositionChange?: (x: number, y: number) => void;
|
||||||
|
targetPosition?: { x: number; y: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Canvas({
|
||||||
|
cursors,
|
||||||
|
onCursorMove,
|
||||||
|
brushColor,
|
||||||
|
brushSize,
|
||||||
|
onCanvasPositionChange
|
||||||
|
}: CanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||||
|
const { sendPaintPixels, socket } = useSocket();
|
||||||
|
const { user, showLoginPrompt } = useAuth();
|
||||||
|
|
||||||
|
const pixelsRef = useRef<Map<string, Pixel>>(new Map());
|
||||||
|
const animationFrameRef = useRef<number>();
|
||||||
|
const mouseScreenRef = useRef({ x: 0, y: 0 });
|
||||||
|
const lastPanRef = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const transformRef = useRef({ x: 0, y: 0, scale: 1 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
|
||||||
|
const cursorsRef = useRef<any[]>(cursors);
|
||||||
|
const brushColorRef = useRef(brushColor);
|
||||||
|
const brushSizeRef = useRef(brushSize);
|
||||||
|
const userRef = useRef(user);
|
||||||
|
|
||||||
|
useEffect(() => { cursorsRef.current = cursors; }, [cursors]);
|
||||||
|
useEffect(() => { brushColorRef.current = brushColor; }, [brushColor]);
|
||||||
|
useEffect(() => { brushSizeRef.current = brushSize; }, [brushSize]);
|
||||||
|
useEffect(() => { userRef.current = user; }, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const previewCanvas = previewCanvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
ctxRef.current = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (previewCanvas) {
|
||||||
|
previewCanvas.width = window.innerWidth;
|
||||||
|
previewCanvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
if (previewCanvas) {
|
||||||
|
previewCanvas.width = window.innerWidth;
|
||||||
|
previewCanvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
socket.on('canvas-state', (pixels: Pixel[]) => {
|
||||||
|
pixelsRef.current.clear();
|
||||||
|
for (const p of pixels) {
|
||||||
|
pixelsRef.current.set(`${p.x},${p.y}`, p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const pixelSize = () => 10 * transformRef.current.scale;
|
||||||
|
|
||||||
|
const screenToWorld = (sx: number, sy: number) => {
|
||||||
|
const { x, y, scale } = transformRef.current;
|
||||||
|
const size = 10 * scale;
|
||||||
|
return {
|
||||||
|
x: Math.floor((sx - x) / size),
|
||||||
|
y: Math.floor((sy - y) / size)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawGrid = (ctx: CanvasRenderingContext2D) => {
|
||||||
|
const size = pixelSize();
|
||||||
|
const { x, y } = transformRef.current;
|
||||||
|
ctx.strokeStyle = '#333';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
const startX = Math.floor(-x / size) * size + x;
|
||||||
|
const startY = Math.floor(-y / size) * size + y;
|
||||||
|
|
||||||
|
for (let gx = startX; gx < ctx.canvas.width; gx += size) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gx, 0);
|
||||||
|
ctx.lineTo(gx, ctx.canvas.height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let gy = startY; gy < ctx.canvas.height; gy += size) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, gy);
|
||||||
|
ctx.lineTo(ctx.canvas.width, gy);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawPixels = (ctx: CanvasRenderingContext2D) => {
|
||||||
|
const size = pixelSize();
|
||||||
|
const { x, y } = transformRef.current;
|
||||||
|
for (const p of pixelsRef.current.values()) {
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
ctx.fillRect(p.x * size + x, p.y * size + y, size, size);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawPreview = (ctx: CanvasRenderingContext2D) => {
|
||||||
|
const size = pixelSize();
|
||||||
|
const { x, y } = transformRef.current;
|
||||||
|
const { x: mx, y: my } = mouseScreenRef.current;
|
||||||
|
const { x: wx, y: wy } = screenToWorld(mx, my);
|
||||||
|
const radius = brushSizeRef.current / 2;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.5;
|
||||||
|
ctx.fillStyle = brushColorRef.current;
|
||||||
|
|
||||||
|
for (let dx = Math.ceil(-radius); dx <= Math.floor(radius); dx++) {
|
||||||
|
for (let dy = Math.ceil(-radius); dy <= Math.floor(radius); dy++) {
|
||||||
|
if (Math.hypot(dx, dy) > radius) continue;
|
||||||
|
ctx.fillRect((wx + dx) * size + x, (wy + dy) * size + y, size, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawCursors = (ctx: CanvasRenderingContext2D) => {
|
||||||
|
const size = pixelSize();
|
||||||
|
const { x, y, scale } = transformRef.current;
|
||||||
|
for (const c of cursorsRef.current) {
|
||||||
|
if (userRef.current && c.userId === userRef.current.id.toString()) continue;
|
||||||
|
const sx = c.x * size + x;
|
||||||
|
const sy = c.y * size + y;
|
||||||
|
ctx.fillStyle = c.color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx, sy);
|
||||||
|
ctx.lineTo(sx + 15 * scale, sy + 5 * scale);
|
||||||
|
ctx.lineTo(sx + 5 * scale, sy + 15 * scale);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const ctx = ctxRef.current;
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const previewCtx = previewCanvasRef.current?.getContext('2d');
|
||||||
|
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
|
|
||||||
|
drawGrid(ctx);
|
||||||
|
drawPixels(ctx);
|
||||||
|
drawCursors(ctx);
|
||||||
|
|
||||||
|
if (previewCtx) {
|
||||||
|
previewCtx.clearRect(0, 0, previewCtx.canvas.width, previewCtx.canvas.height);
|
||||||
|
if (isDragging) {
|
||||||
|
drawPreview(previewCtx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
render();
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const paintPixel = () => {
|
||||||
|
if (!user) {
|
||||||
|
showLoginPrompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x, y } = screenToWorld(mouseScreenRef.current.x, mouseScreenRef.current.y);
|
||||||
|
const pixels: Pixel[] = [];
|
||||||
|
const radius = brushSizeRef.current / 2;
|
||||||
|
|
||||||
|
for (let dx = Math.ceil(-radius); dx <= Math.floor(radius); dx++) {
|
||||||
|
for (let dy = Math.ceil(-radius); dy <= Math.floor(radius); dy++) {
|
||||||
|
if (Math.hypot(dx, dy) > radius) continue;
|
||||||
|
const p = { x: x + dx, y: y + dy, color: brushColorRef.current };
|
||||||
|
pixels.push(p);
|
||||||
|
pixelsRef.current.set(`${p.x},${p.y}`, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendPaintPixels(pixels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (e.button === 1) {
|
||||||
|
setIsPanning(true);
|
||||||
|
lastPanRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button === 0) {
|
||||||
|
setIsDragging(true);
|
||||||
|
paintPixel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
mouseScreenRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
|
const world = screenToWorld(e.clientX, e.clientY);
|
||||||
|
onCursorMove(world.x, world.y);
|
||||||
|
if (onCanvasPositionChange) onCanvasPositionChange(world.x, world.y);
|
||||||
|
|
||||||
|
if (isPanning) {
|
||||||
|
const dx = e.clientX - lastPanRef.current.x;
|
||||||
|
const dy = e.clientY - lastPanRef.current.y;
|
||||||
|
const current = transformRef.current;
|
||||||
|
transformRef.current = {
|
||||||
|
...current,
|
||||||
|
x: current.x + dx,
|
||||||
|
y: current.y + dy
|
||||||
|
};
|
||||||
|
lastPanRef.current = { x: e.clientX, y: e.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
paintPixel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
setIsPanning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const zoom = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
|
const current = transformRef.current;
|
||||||
|
const newScale = Math.min(10, Math.max(0.1, current.scale * zoom));
|
||||||
|
|
||||||
|
const mx = e.clientX;
|
||||||
|
const my = e.clientY;
|
||||||
|
const wx = (mx - current.x) / current.scale;
|
||||||
|
const wy = (my - current.y) / current.scale;
|
||||||
|
|
||||||
|
transformRef.current = {
|
||||||
|
scale: newScale,
|
||||||
|
x: mx - wx * newScale,
|
||||||
|
y: my - wy * newScale
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
cursor: isPanning ? 'grabbing' : 'crosshair'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref={previewCanvasRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
client/src/components/Controls.tsx
Normal file
193
client/src/components/Controls.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { ShareLinkModal } from './ShareLinkModal';
|
||||||
|
|
||||||
|
interface ControlsProps {
|
||||||
|
brushColor: string;
|
||||||
|
brushSize: number;
|
||||||
|
onBrushColorChange: (color: string) => void;
|
||||||
|
onBrushSizeChange: (size: number) => void;
|
||||||
|
canvasX?: number;
|
||||||
|
canvasY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Controls({
|
||||||
|
brushColor,
|
||||||
|
brushSize,
|
||||||
|
onBrushColorChange,
|
||||||
|
onBrushSizeChange,
|
||||||
|
canvasX,
|
||||||
|
canvasY
|
||||||
|
}: ControlsProps) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false);
|
||||||
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
|
||||||
|
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD',
|
||||||
|
'#FFFFFF', '#000000', '#808080', '#FFA500', '#800080', '#008000',
|
||||||
|
'#FFD700', '#FFC0CB', '#A52A2A', '#808000', '#008080', '#800000'
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '20px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: '#2a2a2a',
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
zIndex: 100,
|
||||||
|
maxWidth: '90vw'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setShowColorPicker((v) => !v)}
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: brushColor,
|
||||||
|
border: '2px solid #555',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'transform 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => (e.currentTarget.style.transform = 'scale(1.1)')}
|
||||||
|
onMouseOut={(e) => (e.currentTarget.style.transform = 'scale(1)')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showColorPicker && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '50px',
|
||||||
|
left: 0,
|
||||||
|
background: '#2a2a2a',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||||
|
gap: '0.5rem',
|
||||||
|
zIndex: 101
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{colors.map((color) => (
|
||||||
|
<div
|
||||||
|
key={color}
|
||||||
|
onClick={() => {
|
||||||
|
onBrushColorChange(color);
|
||||||
|
setShowColorPicker(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: color,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: brushColor === color ? '2px solid #fff' : '1px solid #555',
|
||||||
|
transition: 'transform 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => (e.currentTarget.style.transform = 'scale(1.1)')}
|
||||||
|
onMouseOut={(e) => (e.currentTarget.style.transform = 'scale(1)')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
padding: '0 0.5rem',
|
||||||
|
borderLeft: '1px solid #333',
|
||||||
|
borderRight: '1px solid #333'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: '#888', fontSize: '0.75rem' }}>Size:</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={brushSize}
|
||||||
|
onChange={(e) => onBrushSizeChange(parseInt(e.target.value, 10))}
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#fff', fontSize: '0.75rem', minWidth: '20px' }}>
|
||||||
|
{brushSize}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onBrushColorChange('#ffffff');
|
||||||
|
onBrushSizeChange(1);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: '#1a1a1a',
|
||||||
|
border: '1px solid #333',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShareModal(true)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: '#17a2b8',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share Location
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: '#FF6B6B',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ShareLinkModal
|
||||||
|
isOpen={showShareModal}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
x={canvasX ?? Math.floor(window.innerWidth / 20)}
|
||||||
|
y={canvasY ?? Math.floor(window.innerHeight / 20)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
client/src/components/LoginButton.tsx
Normal file
28
client/src/components/LoginButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export const LoginButton: React.FC = () => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<img
|
||||||
|
src={user.avatar_url}
|
||||||
|
alt={user.display_name}
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // this makes the login button not apear when we alr logged in
|
||||||
|
};
|
||||||
94
client/src/components/LoginModal.tsx
Normal file
94
client/src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 9999,
|
||||||
|
backdropFilter: 'blur(4px)'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
padding: '40px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '400px',
|
||||||
|
width: '90%',
|
||||||
|
border: '2px solid #444'
|
||||||
|
}}>
|
||||||
|
<h2 style={{
|
||||||
|
color: '#fff',
|
||||||
|
marginBottom: '16px',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
Login Required to Draw
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: '#ccc',
|
||||||
|
marginBottom: '30px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}>
|
||||||
|
You need to be logged in with GitHub to paint on the canvas. Your drawing attempts won't be saved until you authenticate.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={login}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '16px 24px',
|
||||||
|
backgroundColor: '#24292e',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'transform 0.2s, backgroundColor 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.05)';
|
||||||
|
e.currentTarget.style.backgroundColor = '#1a1a1a';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
e.currentTarget.style.backgroundColor = '#24292e';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login with GitHub
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginTop: '20px',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
This modal will close automatically after you log in
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
158
client/src/components/ShareLinkModal.tsx
Normal file
158
client/src/components/ShareLinkModal.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface ShareLinkModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShareLinkModal: React.FC<ShareLinkModalProps> = ({ isOpen, onClose, x, y }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const shareUrl = `${window.location.origin}${window.location.pathname}#x=${x}&y=${y}`;
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 9998,
|
||||||
|
backdropFilter: 'blur(4px)'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
padding: '30px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
border: '2px solid #444'
|
||||||
|
}}>
|
||||||
|
<h2 style={{
|
||||||
|
color: '#fff',
|
||||||
|
marginBottom: '12px',
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
Share Canvas Location
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: '#ccc',
|
||||||
|
marginBottom: '20px',
|
||||||
|
lineHeight: '1.4',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>
|
||||||
|
Coordinates: ({x}, {y})
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
border: '1px solid #333',
|
||||||
|
wordBreak: 'break-all'
|
||||||
|
}}>
|
||||||
|
<p style={{
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '12px',
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}>
|
||||||
|
{shareUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
backgroundColor: copied ? '#28a745' : '#24292e',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
transition: 'transform 0.2s, backgroundColor 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
if (!copied) {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.05)';
|
||||||
|
e.currentTarget.style.backgroundColor = '#1a1a1a';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
if (!copied) {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
e.currentTarget.style.backgroundColor = '#24292e';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy Link'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
transition: 'transform 0.2s, backgroundColor 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.05)';
|
||||||
|
e.currentTarget.style.backgroundColor = '#5a6268';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
e.currentTarget.style.backgroundColor = '#6c757d';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: '#888',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginTop: '16px',
|
||||||
|
margin: 0
|
||||||
|
}}>
|
||||||
|
Anyone with this link will see the cursor pointing to this location when they visit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
87
client/src/context/AuthContext.tsx
Normal file
87
client/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { LoginModal } from '../components/LoginModal';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
github_id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
login: () => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
showLoginPrompt: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuthStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user', {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const userData = await response.json();
|
||||||
|
setUser(userData);
|
||||||
|
setShowLoginModal(false); // Close modal on auth
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = () => {
|
||||||
|
window.location.href = '/auth/github';
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
setUser(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showLoginPrompt = () => {
|
||||||
|
setShowLoginModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout, loading, showLoginPrompt }}>
|
||||||
|
{children}
|
||||||
|
<LoginModal isOpen={showLoginModal} />
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
122
client/src/context/SocketContext.tsx
Normal file
122
client/src/context/SocketContext.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
import { Pixel, Cursor } from '../types';
|
||||||
|
|
||||||
|
interface SocketContextType {
|
||||||
|
socket: Socket | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
cursors: Cursor[];
|
||||||
|
sendCursorMove: (x: number, y: number) => void;
|
||||||
|
sendPaintPixels: (pixels: Pixel[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SocketContext = createContext<SocketContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SocketProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [cursors, setCursors] = useState<Cursor[]>([]);
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('disconnect', () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('canvas-state', (pixels: Pixel[]) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('canvas-state', { detail: pixels }));
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('canvas-update', (update: { pixels: Pixel[] }) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('canvas-update', { detail: update.pixels }));
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('cursor-update', (updatedCursors: Cursor[]) => {
|
||||||
|
setCursors(updatedCursors);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('auth-success', () => {
|
||||||
|
});
|
||||||
|
|
||||||
|
setSocket(newSocket);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
newSocket.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Failed to get socket token:', error);
|
||||||
|
setSocket(null);
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const sendCursorMove = (x: number, y: number) => {
|
||||||
|
if (socket && isConnected) {
|
||||||
|
socket.emit('cursor-move', { x, y });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendPaintPixels = (pixels: Pixel[]) => {
|
||||||
|
if (socket && isConnected) {
|
||||||
|
socket.emit('paint-pixels', pixels);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={{ socket, isConnected, cursors, sendCursorMove, sendPaintPixels }}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSocket() {
|
||||||
|
const context = useContext(SocketContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSocket must be used within a SocketProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
20
client/src/index.css
Normal file
20
client/src/index.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
16
client/src/types.ts
Normal file
16
client/src/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export interface Cursor {
|
||||||
|
userId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pixel {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: string;
|
||||||
|
userId?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
26
client/tsconfig.json
Normal file
26
client/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
client/tsconfig.node.json
Normal file
10
client/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
22
client/vite.config.ts
Normal file
22
client/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@shared': path.resolve(__dirname, '../shared')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
54
package.json
Normal file
54
package.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "invicanvas",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Collaborative pixel-art web game",
|
||||||
|
"main": "server/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "PORT=3000 concurrently \"PORT=3000 npm run server\" \"npm run client\"",
|
||||||
|
"server": "tsx watch server/index.ts",
|
||||||
|
"client": "cd client && npm run dev",
|
||||||
|
"build": "npm run build:client",
|
||||||
|
"build:client": "cd client && npm run build",
|
||||||
|
"build:all": "npm run build:client && npx tsc",
|
||||||
|
"start": "npx tsx server/index.ts",
|
||||||
|
"start:prod": "node dist/server/index.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"pixel-art",
|
||||||
|
"collaborative",
|
||||||
|
"canvas"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/connect-sqlite3": "^0.9.6",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
|
"@types/express-session": "^1.18.2",
|
||||||
|
"@types/express-socket.io-session": "^1.3.9",
|
||||||
|
"@types/passport": "^1.0.17",
|
||||||
|
"@types/passport-github2": "^1.2.9",
|
||||||
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"connect-sqlite3": "^0.9.16",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"express-session": "^1.18.2",
|
||||||
|
"express-socket.io-session": "^1.3.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-github2": "^0.1.12",
|
||||||
|
"socket.io": "^4.7.2",
|
||||||
|
"winston": "^3.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.10.5",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
53
server/auth.ts
Normal file
53
server/auth.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import passport from 'passport';
|
||||||
|
import { Strategy as GitHubStrategy } from 'passport-github2';
|
||||||
|
import { db } from './database';
|
||||||
|
|
||||||
|
passport.serializeUser((user: any, done) => {
|
||||||
|
done(null, user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
passport.deserializeUser((id: number, done) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
const user = stmt.get(id);
|
||||||
|
done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
done(error, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
passport.use(new GitHubStrategy({
|
||||||
|
clientID: process.env.GITHUB_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
|
callbackURL: '/auth/github/callback'
|
||||||
|
}, async (accessToken, refreshToken, profile, done) => {
|
||||||
|
try {
|
||||||
|
let user;
|
||||||
|
const existingUser = db.prepare('SELECT * FROM users WHERE github_id = ?').get(profile.id);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
user = existingUser;
|
||||||
|
} else {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO users (github_id, username, display_name, avatar_url, email)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const result = stmt.run(
|
||||||
|
profile.id,
|
||||||
|
profile.username,
|
||||||
|
profile.displayName || profile.username,
|
||||||
|
profile._json.avatar_url,
|
||||||
|
profile._json.email
|
||||||
|
);
|
||||||
|
|
||||||
|
user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
return done(error, undefined);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export { passport };
|
||||||
55
server/config.ts
Normal file
55
server/config.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||||
|
|
||||||
|
// Validate required environment variables
|
||||||
|
const requiredEnvVars = [
|
||||||
|
'NEXTAUTH_SECRET',
|
||||||
|
'GITHUB_CLIENT_ID',
|
||||||
|
'GITHUB_CLIENT_SECRET'
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingEnvVars = requiredEnvVars.filter(envVar => !process.env[envVar]);
|
||||||
|
|
||||||
|
if (missingEnvVars.length > 0) {
|
||||||
|
console.error('Missing required environment variables:', missingEnvVars.join(', '));
|
||||||
|
console.error('Please check your .env file and ensure all required variables are set.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about default secrets in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (process.env.NEXTAUTH_SECRET == "") {
|
||||||
|
console.error('CRITICAL: Default session secret detected in production!');
|
||||||
|
console.error('Please change NEXTAUTH_SECRET in your .env file.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
server: {
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development'
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
sessionSecret: process.env.NEXTAUTH_SECRET!,
|
||||||
|
githubClientId: process.env.GITHUB_CLIENT_ID!,
|
||||||
|
githubClientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||||
|
corsOrigin: process.env.NODE_ENV === 'development'
|
||||||
|
? ['http://localhost:3000', 'http://localhost:5173']
|
||||||
|
: 'http://localhost:3000'
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||||
|
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10)
|
||||||
|
},
|
||||||
|
slowDown: {
|
||||||
|
windowMs: parseInt(process.env.SLOW_DOWN_WINDOW_MS || '60000', 10), // 1 minute
|
||||||
|
delayMs: parseInt(process.env.SLOW_DOWN_DELAY_MS || '500', 10) // 500ms delay
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
level: process.env.LOG_LEVEL || 'info'
|
||||||
|
}
|
||||||
|
};
|
||||||
59
server/database.ts
Normal file
59
server/database.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '../data/invicanvas.db');
|
||||||
|
const dbDir = path.dirname(dbPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = new Database(dbPath);
|
||||||
|
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
github_id TEXT UNIQUE,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
email TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pixels (
|
||||||
|
x INTEGER NOT NULL,
|
||||||
|
y INTEGER NOT NULL,
|
||||||
|
color TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (x, y)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS 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 {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO pixels (x, y, color, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const transaction = db.transaction((pixels: any[]) => {
|
||||||
|
for (const pixel of pixels) {
|
||||||
|
stmt.run(pixel.x, pixel.y, pixel.color);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction(pixels);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllPixels(): { x: number; y: number; color: string }[] {
|
||||||
|
const stmt = db.prepare('SELECT x, y, color FROM pixels ORDER BY x, y');
|
||||||
|
return stmt.all() as { x: number; y: number; color: string }[];
|
||||||
|
}
|
||||||
129
server/index.ts
Normal file
129
server/index.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import http from 'http';
|
||||||
|
import { Server as SocketIOServer } from 'socket.io';
|
||||||
|
import cors from 'cors';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import session from 'express-session';
|
||||||
|
import passport from 'passport';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { setupSocketIO } from './socket';
|
||||||
|
import { db } from './database';
|
||||||
|
import { config } from './config';
|
||||||
|
import {
|
||||||
|
securityHeaders,
|
||||||
|
createRateLimit,
|
||||||
|
requestLogger,
|
||||||
|
sanitizeInput
|
||||||
|
} from './middleware';
|
||||||
|
import { logger } from './logger';
|
||||||
|
import './auth';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const io = new SocketIOServer(server, {
|
||||||
|
cors: {
|
||||||
|
origin: config.security.corsOrigin,
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
credentials: true
|
||||||
|
},
|
||||||
|
transports: ['polling', 'websocket']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(securityHeaders);
|
||||||
|
app.use(requestLogger);
|
||||||
|
app.use(sanitizeInput);
|
||||||
|
|
||||||
|
// Cookie parsing
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
app.use('/api', createRateLimit);
|
||||||
|
|
||||||
|
// Session configuration
|
||||||
|
const SQLiteStore = require('connect-sqlite3')(session);
|
||||||
|
const sessionMiddleware = session({
|
||||||
|
store: new SQLiteStore({ db: 'sessions.db', dir: './data' }),
|
||||||
|
secret: process.env.NEXTAUTH_SECRET || 'fallback-secret-change-in-production',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
|
||||||
|
// Passport initialization
|
||||||
|
app.use(passport.initialize());
|
||||||
|
app.use(passport.session());
|
||||||
|
|
||||||
|
// Body parsing
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// CORS with specific origin
|
||||||
|
app.use(cors({
|
||||||
|
origin: config.server.nodeEnv === 'development'
|
||||||
|
? ['http://localhost:3000', 'http://localhost:5173']
|
||||||
|
: config.security.corsOrigin,
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, '../client/dist')));
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auth routes
|
||||||
|
app.get('/auth/github', passport.authenticate('github'));
|
||||||
|
|
||||||
|
app.get('/auth/github/callback',
|
||||||
|
passport.authenticate('github', { failureRedirect: '/login' }),
|
||||||
|
(req, res) => {
|
||||||
|
res.redirect('/');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get('/api/user', (req, res) => {
|
||||||
|
if (req.user) {
|
||||||
|
res.json(req.user);
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/socket-token', (req, res) => {
|
||||||
|
if (req.user) {
|
||||||
|
// Create a token containing user info
|
||||||
|
const user = req.user as any; // Use any to bypass typing issues
|
||||||
|
const token = Buffer.from(JSON.stringify({
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
timestamp: Date.now()
|
||||||
|
})).toString('base64');
|
||||||
|
|
||||||
|
res.json({ token, user: req.user });
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// SPA catch-all route
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '../client/dist/index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
io.use((socket: any, next: any) => {
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
setupSocketIO(io);
|
||||||
|
|
||||||
|
const PORT = config.server.port;
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
85
server/logger.ts
Normal file
85
server/logger.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
import path from 'path';
|
||||||
|
import { config } from './config';
|
||||||
|
|
||||||
|
const logDir = path.join(__dirname, '../logs');
|
||||||
|
|
||||||
|
// Create logs directory if it doesn't exist
|
||||||
|
import fs from 'fs';
|
||||||
|
if (!fs.existsSync(logDir)) {
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.json()
|
||||||
|
);
|
||||||
|
|
||||||
|
const consoleFormat = winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
winston.format.timestamp({ format: 'HH:mm:ss' }),
|
||||||
|
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||||
|
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
|
||||||
|
return `${timestamp} ${level}: ${message}${metaStr}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// log auth attempts and failures
|
||||||
|
export const logger = winston.createLogger({
|
||||||
|
level: config.logging.level,
|
||||||
|
format: logFormat,
|
||||||
|
transports: [
|
||||||
|
// logs auth and security events
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'security.log'),
|
||||||
|
level: 'info',
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.timestamp(),
|
||||||
|
winston.format.json()
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
|
||||||
|
// logs all errors
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'error.log'),
|
||||||
|
level: 'error'
|
||||||
|
}),
|
||||||
|
|
||||||
|
// logs everything
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logDir, 'combined.log')
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// console log in dev
|
||||||
|
if (config.server.nodeEnv === 'development') {
|
||||||
|
logger.add(new winston.transports.Console({
|
||||||
|
format: consoleFormat
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security specific logging
|
||||||
|
export const securityLogger = {
|
||||||
|
rateLimitExceeded: (ip: string, endpoint: string) => {
|
||||||
|
logger.warn('Rate limit exceeded', {
|
||||||
|
event: 'rate_limit_exceeded',
|
||||||
|
ip,
|
||||||
|
endpoint,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
suspiciousActivity: (ip: string, activity: string, details?: any) => {
|
||||||
|
logger.warn('Suspicious activity detected', {
|
||||||
|
event: 'suspicious_activity',
|
||||||
|
ip,
|
||||||
|
activity,
|
||||||
|
details,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
85
server/middleware.ts
Normal file
85
server/middleware.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import helmet from 'helmet';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { config } from './config';
|
||||||
|
import { securityLogger } from './logger';
|
||||||
|
|
||||||
|
// Security headers middleware
|
||||||
|
export const securityHeaders = helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
scriptSrc: ["'self'"],
|
||||||
|
imgSrc: ["'self'", "data:", "blob:"],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
fontSrc: ["'self'"],
|
||||||
|
objectSrc: ["'none'"],
|
||||||
|
mediaSrc: ["'self'"],
|
||||||
|
frameSrc: ["'none'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false, // Req. for Socket.io
|
||||||
|
hsts: {
|
||||||
|
maxAge: 31536000,
|
||||||
|
includeSubDomains: true,
|
||||||
|
preload: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
export const createRateLimit = rateLimit({
|
||||||
|
windowMs: config.rateLimit.windowMs,
|
||||||
|
max: config.rateLimit.maxRequests,
|
||||||
|
message: {
|
||||||
|
error: 'Too many requests from this IP, please try again later.',
|
||||||
|
retryAfter: Math.ceil(config.rateLimit.windowMs / 1000)
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (req: Request, res: Response) => {
|
||||||
|
securityLogger.rateLimitExceeded(
|
||||||
|
req.ip || req.connection.remoteAddress || 'unknown',
|
||||||
|
req.path
|
||||||
|
);
|
||||||
|
res.status(429).json({
|
||||||
|
error: 'Too many requests from this IP, please try again later.',
|
||||||
|
retryAfter: Math.ceil(config.rateLimit.windowMs / 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request logging
|
||||||
|
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanitize input
|
||||||
|
export const sanitizeInput = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
// Recursively sanitize string values
|
||||||
|
const sanitizeValue = (value: any): any => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove script tags
|
||||||
|
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(sanitizeValue);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const sanitized: any = {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
sanitized[key] = sanitizeValue(val);
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
req.body = sanitizeValue(req.body);
|
||||||
|
req.query = sanitizeValue(req.query);
|
||||||
|
req.params = sanitizeValue(req.params);
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
110
server/socket.ts
Normal file
110
server/socket.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Server as SocketIOServer, Socket } from 'socket.io';
|
||||||
|
import { upsertPixels, getAllPixels, db } from './database';
|
||||||
|
import { Cursor, Pixel } from '../shared/types';
|
||||||
|
import passport from 'passport';
|
||||||
|
|
||||||
|
function generateRandomColor(): string {
|
||||||
|
const colors = [
|
||||||
|
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
||||||
|
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2'
|
||||||
|
];
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSocket extends Socket {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
color: string;
|
||||||
|
user?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get user data from db
|
||||||
|
function getUserById(userId: number) {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
return stmt.get(userId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupSocketIO(io: SocketIOServer): void {
|
||||||
|
const connectedUsers = new Map<string, { x: number; y: number; color: string; username: string; userId: number }>();
|
||||||
|
|
||||||
|
io.on('connection', (socket: Socket) => {
|
||||||
|
const userSocket = socket as UserSocket;
|
||||||
|
|
||||||
|
socket.on('join-canvas', (token: string) => {
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(Buffer.from(token, 'base64').toString());
|
||||||
|
|
||||||
|
if (Date.now() - decoded.timestamp > 5 * 60 * 1000) {
|
||||||
|
socket.emit('error', 'Token expired');
|
||||||
|
socket.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = getUserById(decoded.userId);
|
||||||
|
if (userData) {
|
||||||
|
userSocket.user = userData;
|
||||||
|
const userId = `auth_${userData.id}`;
|
||||||
|
const username = userData.username || userData.display_name;
|
||||||
|
const color = generateRandomColor();
|
||||||
|
|
||||||
|
connectedUsers.set(userId, {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
color: color,
|
||||||
|
username: username,
|
||||||
|
userId: userData.id.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.emit('canvas-state', getAllPixels());
|
||||||
|
socket.emit('cursor-update', Array.from(connectedUsers.values()));
|
||||||
|
socket.emit('auth-success', { user: userData });
|
||||||
|
|
||||||
|
socket.broadcast.emit('cursor-update', Array.from(connectedUsers.values()));
|
||||||
|
|
||||||
|
socket.on('cursor-move', (cursor: Cursor) => {
|
||||||
|
const user = connectedUsers.get(userId);
|
||||||
|
if (user) {
|
||||||
|
user.x = cursor.x;
|
||||||
|
user.y = cursor.y;
|
||||||
|
socket.broadcast.emit('cursor-update', Array.from(connectedUsers.values()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('paint-pixels', async (pixels: Pixel[]) => {
|
||||||
|
try {
|
||||||
|
if (!userSocket.user) {
|
||||||
|
socket.emit('error', 'Authentication required to paint pixels');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertPixels(pixels);
|
||||||
|
socket.broadcast.emit('canvas-update', { pixels });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving pixels:', error);
|
||||||
|
socket.emit('error', 'Failed to save pixels');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
connectedUsers.delete(userId);
|
||||||
|
socket.broadcast.emit('cursor-update', Array.from(connectedUsers.values()));
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.error('User not found for token:', decoded.userId);
|
||||||
|
socket.emit('error', 'Invalid user');
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token verification failed:', error);
|
||||||
|
socket.emit('error', 'Invalid token');
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
48
shared/types.ts
Normal file
48
shared/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pixel {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cursor {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CanvasUpdate {
|
||||||
|
pixels: Pixel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocketEvents {
|
||||||
|
'join-canvas': (token: string) => void;
|
||||||
|
'cursor-move': (cursor: Cursor) => void;
|
||||||
|
'paint-pixels': (pixels: Pixel[]) => void;
|
||||||
|
'canvas-update': (update: CanvasUpdate) => void;
|
||||||
|
'cursor-update': (cursors: Cursor[]) => void;
|
||||||
|
'canvas-state': (pixels: Pixel[]) => void;
|
||||||
|
'error': (message: string) => void;
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["server/**/*", "shared/**/*"],
|
||||||
|
"exclude": ["node_modules", "client", "dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user