diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1710adb --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0676ae --- /dev/null +++ b/.gitignore @@ -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 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..6923ea8 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + InviCanvas - Collaborative Pixel Art + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..157cf21 --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..2d72faf --- /dev/null +++ b/client/src/App.tsx @@ -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 ( + <> + setCurrentCanvasPosition({ x, y })} + targetPosition={targetPosition} + brushColor={brushColor} + brushSize={brushSize} + /> + +
+
Scroll to zoom • Middle-click to pan • Left-click to paint
+
+ + ); +} + +export default function AppWrapper() { + return ( + + + + + + ); +} diff --git a/client/src/components/Canvas.tsx b/client/src/components/Canvas.tsx new file mode 100644 index 0000000..ee218f2 --- /dev/null +++ b/client/src/components/Canvas.tsx @@ -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(null); + const previewCanvasRef = useRef(null); + const ctxRef = useRef(null); + const { sendPaintPixels, socket } = useSocket(); + const { user, showLoginPrompt } = useAuth(); + + const pixelsRef = useRef>(new Map()); + const animationFrameRef = useRef(); + 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(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 ( + <> + + + + ); +} \ No newline at end of file diff --git a/client/src/components/Controls.tsx b/client/src/components/Controls.tsx new file mode 100644 index 0000000..6262a2c --- /dev/null +++ b/client/src/components/Controls.tsx @@ -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 ( + <> +
+
+
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 && ( +
+ {colors.map((color) => ( +
{ + 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)')} + /> + ))} +
+ )} +
+ +
+ Size: + onBrushSizeChange(parseInt(e.target.value, 10))} + style={{ width: '80px' }} + /> + + {brushSize} + +
+ +
+ + + + + {user && ( + + )} +
+
+ + setShowShareModal(false)} + x={canvasX ?? Math.floor(window.innerWidth / 20)} + y={canvasY ?? Math.floor(window.innerHeight / 20)} + /> + + ); +} diff --git a/client/src/components/LoginButton.tsx b/client/src/components/LoginButton.tsx new file mode 100644 index 0000000..54dcf74 --- /dev/null +++ b/client/src/components/LoginButton.tsx @@ -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
Loading...
; + } + + if (user) { + return ( +
+ {user.display_name} +
+ ); + } + + return null; // this makes the login button not apear when we alr logged in +}; \ No newline at end of file diff --git a/client/src/components/LoginModal.tsx b/client/src/components/LoginModal.tsx new file mode 100644 index 0000000..e641f0f --- /dev/null +++ b/client/src/components/LoginModal.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { useAuth } from '../context/AuthContext'; + +interface LoginModalProps { + isOpen: boolean; +} + +export const LoginModal: React.FC = ({ isOpen }) => { + const { login } = useAuth(); + + if (!isOpen) return null; + + return ( +
+
+

+ Login Required to Draw +

+ +

+ You need to be logged in with GitHub to paint on the canvas. Your drawing attempts won't be saved until you authenticate. +

+ + + +

+ This modal will close automatically after you log in +

+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/components/ShareLinkModal.tsx b/client/src/components/ShareLinkModal.tsx new file mode 100644 index 0000000..07aa321 --- /dev/null +++ b/client/src/components/ShareLinkModal.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; + +interface ShareLinkModalProps { + isOpen: boolean; + onClose: () => void; + x: number; + y: number; +} + +export const ShareLinkModal: React.FC = ({ 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 ( +
+
+

+ Share Canvas Location +

+ +

+ Coordinates: ({x}, {y}) +

+ +
+

+ {shareUrl} +

+
+ +
+ + + +
+ +

+ Anyone with this link will see the cursor pointing to this location when they visit +

+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/context/AuthContext.tsx b/client/src/context/AuthContext.tsx new file mode 100644 index 0000000..d291355 --- /dev/null +++ b/client/src/context/AuthContext.tsx @@ -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; + loading: boolean; + showLoginPrompt: () => void; +} + +const AuthContext = createContext(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(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 ( + + {children} + + + ); +}; \ No newline at end of file diff --git a/client/src/context/SocketContext.tsx b/client/src/context/SocketContext.tsx new file mode 100644 index 0000000..634a58c --- /dev/null +++ b/client/src/context/SocketContext.tsx @@ -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(undefined); + +export function SocketProvider({ children }: { children: ReactNode }) { + const [socket, setSocket] = useState(null); + const [isConnected, setIsConnected] = useState(false); + const [cursors, setCursors] = useState([]); + 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 ( + + {children} + + ); +} + +export function useSocket() { + const context = useContext(SocketContext); + if (context === undefined) { + throw new Error('useSocket must be used within a SocketProvider'); + } + return context; +} diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..8a5914b --- /dev/null +++ b/client/src/index.css @@ -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; +} diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/client/src/main.tsx @@ -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( + + + , +) diff --git a/client/src/types.ts b/client/src/types.ts new file mode 100644 index 0000000..d09179d --- /dev/null +++ b/client/src/types.ts @@ -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; +} \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..710a011 --- /dev/null +++ b/client/tsconfig.json @@ -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" }] +} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..c6ef9ee --- /dev/null +++ b/client/vite.config.ts @@ -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 + } + } + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..06f4c15 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..1dd3bbe --- /dev/null +++ b/server/auth.ts @@ -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 }; \ No newline at end of file diff --git a/server/config.ts b/server/config.ts new file mode 100644 index 0000000..be5118f --- /dev/null +++ b/server/config.ts @@ -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' + } +}; \ No newline at end of file diff --git a/server/database.ts b/server/database.ts new file mode 100644 index 0000000..f0e13ba --- /dev/null +++ b/server/database.ts @@ -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 }[]; +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..20d4358 --- /dev/null +++ b/server/index.ts @@ -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}`); +}); diff --git a/server/logger.ts b/server/logger.ts new file mode 100644 index 0000000..d0867d5 --- /dev/null +++ b/server/logger.ts @@ -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; \ No newline at end of file diff --git a/server/middleware.ts b/server/middleware.ts new file mode 100644 index 0000000..6f9f1e0 --- /dev/null +++ b/server/middleware.ts @@ -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>/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(); +}; \ No newline at end of file diff --git a/server/socket.ts b/server/socket.ts new file mode 100644 index 0000000..a9d3cf3 --- /dev/null +++ b/server/socket.ts @@ -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(); + + 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(); + } + }); + }); +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..2fad1ae --- /dev/null +++ b/shared/types.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..44a7978 --- /dev/null +++ b/tsconfig.json @@ -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"] +}