Initial Commit

This commit is contained in:
ExilProductions
2026-01-14 17:52:35 +01:00
parent 2d3b97c5db
commit cf066ef305
28 changed files with 1922 additions and 0 deletions

53
server/auth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}
});
});
}