Initial Commit
This commit is contained in:
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user