diff --git a/create-backend.js b/create-backend.js new file mode 100644 index 0000000..fff5435 --- /dev/null +++ b/create-backend.js @@ -0,0 +1,562 @@ +import fs from 'fs'; +import path from 'path'; + +const baseDir = './backend'; + +// Create directories +const dirs = [ + `${baseDir}/src/routes`, +]; + +dirs.forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(`Created directory: ${dir}`); + } +}); + +// package.json +fs.writeFileSync(`${baseDir}/package.json`, JSON.stringify({ + "name": "photobooth-backend", + "version": "1.0.0", + "type": "module", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "keywords": ["photobooth", "express", "mysql"], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "mysql2": "^3.6.5", + "jsonwebtoken": "^9.1.0", + "bcryptjs": "^2.4.3", + "uuid": "^9.0.1", + "express-async-errors": "^3.1.1", + "multer": "^1.4.5-lts.1" + }, + "devDependencies": {} +}, null, 2)); +console.log('Created package.json'); + +// .env.example +fs.writeFileSync(`${baseDir}/.env.example`, `# Backend API +NODE_ENV=production +PORT=3000 + +# Database +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=photobooth +DB_USER=photobooth_app +DB_PASSWORD=your_app_password_here + +# JWT +JWT_SECRET=your_jwt_secret_here_change_in_production +JWT_EXPIRY=15m +REFRESH_TOKEN_EXPIRY=7d + +# Frontend +FRONTEND_URL=http://localhost:5173 +BACKEND_URL=http://localhost:3000 + +# File storage +UPLOADS_DIR=./uploads +MAX_FILE_SIZE=10485760 +`); +console.log('Created .env.example'); + +// src/db.js +fs.writeFileSync(`${baseDir}/src/db.js`, `import mysql from 'mysql2/promise' +import dotenv from 'dotenv' + +dotenv.config() + +const pool = mysql.createPool({ + host: process.env.DB_HOST, + port: process.env.DB_PORT, + database: process.env.DB_NAME, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, +}) + +export async function query(sql, values) { + const connection = await pool.getConnection() + try { + const [results] = await connection.execute(sql, values) + return results + } finally { + connection.release() + } +} + +export async function getConnection() { + return pool.getConnection() +} + +export default pool +`); +console.log('Created src/db.js'); + +// src/jwt.js +fs.writeFileSync(`${baseDir}/src/jwt.js`, `import jwt from 'jsonwebtoken' +import dotenv from 'dotenv' + +dotenv.config() + +const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production' +const JWT_EXPIRY = process.env.JWT_EXPIRY || '15m' +const REFRESH_TOKEN_EXPIRY = process.env.REFRESH_TOKEN_EXPIRY || '7d' + +export function createAccessToken(userId) { + return jwt.sign({ userId }, JWT_SECRET, { expiresIn: JWT_EXPIRY }) +} + +export function createRefreshToken() { + return jwt.sign({ refreshToken: true }, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY }) +} + +export function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET) + } catch { + return null + } +} + +export function decodeToken(token) { + try { + return jwt.decode(token) + } catch { + return null + } +} +`); +console.log('Created src/jwt.js'); + +// src/models.js +fs.writeFileSync(`${baseDir}/src/models.js`, `import { query } from './db.js' +import bcryptjs from 'bcryptjs' + +// User model +export const User = { + async findById(userId) { + const results = await query('SELECT id, email, username, created_at FROM users WHERE id = ?', [userId]) + return results[0] || null + }, + + async findByEmail(email) { + const results = await query('SELECT * FROM users WHERE email = ?', [email]) + return results[0] || null + }, + + async findByUsername(username) { + const results = await query('SELECT * FROM users WHERE username = ?', [username]) + return results[0] || null + }, + + async create(email, username, passwordHash) { + const id = crypto.randomUUID() + await query('INSERT INTO users (id, email, username, password_hash) VALUES (?, ?, ?, ?)', [id, email, username, passwordHash]) + return this.findById(id) + }, + + async verifyPassword(user, password) { + return bcryptjs.compare(password, user.password_hash) + }, + + async updateLastLogin(userId) { + await query('UPDATE users SET last_login = NOW() WHERE id = ?', [userId]) + }, +} + +// Session model +export const Session = { + async create(sessionId, eventId, createdBy, name) { + await query('INSERT INTO photo_sessions (id, event_id, created_by, session_name) VALUES (?, ?, ?, ?)', [sessionId, eventId, createdBy, name]) + return this.findById(sessionId) + }, + + async findById(sessionId) { + const results = await query('SELECT * FROM photo_sessions WHERE id = ?', [sessionId]) + return results[0] || null + }, + + async findByEvent(eventId) { + return query('SELECT * FROM photo_sessions WHERE event_id = ? ORDER BY created_at DESC', [eventId]) + }, + + async update(sessionId, updates) { + const fields = Object.keys(updates).map(k => \`\${k} = ?\`).join(', ') + const values = Object.values(updates) + values.push(sessionId) + await query(\`UPDATE photo_sessions SET \${fields} WHERE id = ?\`, values) + return this.findById(sessionId) + }, + + async delete(sessionId) { + await query('DELETE FROM photo_sessions WHERE id = ?', [sessionId]) + }, +} + +// Photo model +export const Photo = { + async create(photoId, sessionId, fileName, filePath, fileSize, uploadedAt) { + await query('INSERT INTO photos (id, session_id, file_name, file_path, file_size, uploaded_at) VALUES (?, ?, ?, ?, ?, ?)', + [photoId, sessionId, fileName, filePath, fileSize, uploadedAt]) + return this.findById(photoId) + }, + + async findById(photoId) { + const results = await query('SELECT * FROM photos WHERE id = ?', [photoId]) + return results[0] || null + }, + + async findBySession(sessionId) { + return query('SELECT * FROM photos WHERE session_id = ? ORDER BY uploaded_at DESC', [sessionId]) + }, + + async delete(photoId) { + await query('DELETE FROM photos WHERE id = ?', [photoId]) + }, + + async getCount(sessionId) { + const results = await query('SELECT COUNT(*) as count FROM photos WHERE session_id = ?', [sessionId]) + return results[0]?.count || 0 + }, +} + +// Event model +export const Event = { + async create(eventId, createdBy, eventName, description) { + await query('INSERT INTO events (id, created_by, event_name, description) VALUES (?, ?, ?, ?)', [eventId, createdBy, eventName, description]) + return this.findById(eventId) + }, + + async findById(eventId) { + const results = await query('SELECT * FROM events WHERE id = ?', [eventId]) + return results[0] || null + }, + + async findByUser(userId) { + return query('SELECT * FROM events WHERE created_by = ? ORDER BY created_at DESC', [userId]) + }, + + async update(eventId, updates) { + const fields = Object.keys(updates).map(k => \`\${k} = ?\`).join(', ') + const values = Object.values(updates) + values.push(eventId) + await query(\`UPDATE events SET \${fields} WHERE id = ?\`, values) + return this.findById(eventId) + }, + + async delete(eventId) { + await query('DELETE FROM events WHERE id = ?', [eventId]) + }, +} +`); +console.log('Created src/models.js'); + +// src/routes/auth.js +fs.writeFileSync(`${baseDir}/src/routes/auth.js`, `import { Router } from 'express' +import { User } from '../models.js' +import { createAccessToken, createRefreshToken, verifyToken } from '../jwt.js' +import bcryptjs from 'bcryptjs' +import { v4 as uuidv4 } from 'uuid' + +const router = Router() + +// Register +router.post('/register', async (req, res) => { + const { email, username, password } = req.body + + if (!email || !username || !password) { + return res.status(400).json({ error: 'Missing required fields' }) + } + + try { + const existingEmail = await User.findByEmail(email) + if (existingEmail) { + return res.status(400).json({ error: 'Email already registered' }) + } + + const existingUser = await User.findByUsername(username) + if (existingUser) { + return res.status(400).json({ error: 'Username already taken' }) + } + + const passwordHash = await bcryptjs.hash(password, 10) + const user = await User.create(email, username, passwordHash) + + const accessToken = createAccessToken(user.id) + const refreshToken = createRefreshToken() + + res.json({ + user: { + id: user.id, + email: user.email, + username: user.username, + }, + accessToken, + refreshToken, + }) + } catch (error) { + res.status(500).json({ error: error.message }) + } +}) + +// Login +router.post('/login', async (req, res) => { + const { email, password } = req.body + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password required' }) + } + + try { + const user = await User.findByEmail(email) + if (!user) { + return res.status(401).json({ error: 'Invalid email or password' }) + } + + const valid = await User.verifyPassword(user, password) + if (!valid) { + return res.status(401).json({ error: 'Invalid email or password' }) + } + + await User.updateLastLogin(user.id) + + const accessToken = createAccessToken(user.id) + const refreshToken = createRefreshToken() + + res.json({ + user: { + id: user.id, + email: user.email, + username: user.username, + }, + accessToken, + refreshToken, + }) + } catch (error) { + res.status(500).json({ error: error.message }) + } +}) + +// Refresh token +router.post('/refresh', (req, res) => { + const { refreshToken } = req.body + + if (!refreshToken) { + return res.status(400).json({ error: 'Refresh token required' }) + } + + const decoded = verifyToken(refreshToken) + if (!decoded || !decoded.userId) { + return res.status(401).json({ error: 'Invalid refresh token' }) + } + + const newAccessToken = createAccessToken(decoded.userId) + res.json({ accessToken: newAccessToken }) +}) + +// Logout (stateless, just return success) +router.post('/logout', (req, res) => { + res.json({ message: 'Logged out successfully' }) +}) + +export default router +`); +console.log('Created src/routes/auth.js'); + +// src/routes/media.js +fs.writeFileSync(`${baseDir}/src/routes/media.js`, `import { Router } from 'express' +import multer from 'multer' +import path from 'path' +import fs from 'fs' +import { v4 as uuidv4 } from 'uuid' +import { Photo, Session } from '../models.js' +import { authenticateToken } from '../middleware.js' + +const router = Router() + +// Configure multer for file uploads +const uploadsDir = process.env.UPLOADS_DIR || './uploads' +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }) +} + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, uploadsDir) + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname) + const filename = \`\${uuidv4()}\${ext}\` + cb(null, filename) + }, +}) + +const upload = multer({ + storage, + limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 10485760 }, + fileFilter: (req, file, cb) => { + const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] + if (allowedMimes.includes(file.mimetype)) { + cb(null, true) + } else { + cb(new Error('Invalid file type')) + } + }, +}) + +// Upload photo +router.post('/upload/:sessionId', authenticateToken, upload.single('photo'), async (req, res) => { + try { + const { sessionId } = req.params + + const session = await Session.findById(sessionId) + if (!session) { + if (req.file) fs.unlinkSync(req.file.path) + return res.status(404).json({ error: 'Session not found' }) + } + + const photoId = uuidv4() + const photo = await Photo.create( + photoId, + sessionId, + req.file.originalname, + req.file.path, + req.file.size, + new Date() + ) + + res.json(photo) + } catch (error) { + if (req.file) fs.unlinkSync(req.file.path) + res.status(500).json({ error: error.message }) + } +}) + +// Get photos for session +router.get('/session/:sessionId', authenticateToken, async (req, res) => { + try { + const { sessionId } = req.params + const photos = await Photo.findBySession(sessionId) + res.json(photos) + } catch (error) { + res.status(500).json({ error: error.message }) + } +}) + +// Delete photo +router.delete('/:photoId', authenticateToken, async (req, res) => { + try { + const { photoId } = req.params + + const photo = await Photo.findById(photoId) + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }) + } + + if (fs.existsSync(photo.file_path)) { + fs.unlinkSync(photo.file_path) + } + + await Photo.delete(photoId) + res.json({ message: 'Photo deleted' }) + } catch (error) { + res.status(500).json({ error: error.message }) + } +}) + +export default router +`); +console.log('Created src/routes/media.js'); + +// src/middleware.js +fs.writeFileSync(`${baseDir}/src/middleware.js`, `import { verifyToken } from './jwt.js' + +export function authenticateToken(req, res, next) { + const authHeader = req.headers['authorization'] + const token = authHeader && authHeader.split(' ')[1] + + if (!token) { + return res.status(401).json({ error: 'Access token required' }) + } + + const decoded = verifyToken(token) + if (!decoded) { + return res.status(403).json({ error: 'Invalid or expired token' }) + } + + req.userId = decoded.userId + next() +} + +export function errorHandler(err, req, res, next) { + console.error(err) + res.status(err.status || 500).json({ error: err.message || 'Internal server error' }) +} +`); +console.log('Created src/middleware.js'); + +// src/server.js +fs.writeFileSync(`${baseDir}/src/server.js`, `import express from 'express' +import cors from 'cors' +import 'express-async-errors' +import dotenv from 'dotenv' +import authRoutes from './routes/auth.js' +import mediaRoutes from './routes/media.js' +import { errorHandler } from './middleware.js' + +dotenv.config() + +const app = express() +const PORT = process.env.PORT || 3000 + +// Middleware +app.use(cors({ + origin: process.env.FRONTEND_URL || 'http://localhost:5173', + credentials: true, +})) +app.use(express.json()) +app.use(express.urlencoded({ extended: true })) + +// Routes +app.use('/api/auth', authRoutes) +app.use('/api/media', mediaRoutes) + +// Health check +app.get('/api/health', (req, res) => { + res.json({ status: 'ok' }) +}) + +// Error handling +app.use(errorHandler) + +// 404 handler +app.use((req, res) => { + res.status(404).json({ error: 'Not found' }) +}) + +// Start server +app.listen(PORT, () => { + console.log(\`Server running on port \${PORT}\`) +}) +`); +console.log('Created src/server.js'); + +console.log('\\n✅ Backend structure created successfully!'); +console.log('Next steps:'); +console.log(' cd backend'); +console.log(' npm install'); +console.log(' cp .env.example .env'); +console.log(' npm run dev'); diff --git a/setup-backend.bat b/setup-backend.bat new file mode 100644 index 0000000..162d647 --- /dev/null +++ b/setup-backend.bat @@ -0,0 +1,36 @@ +@echo off +REM Create backend directory structure +echo Creating backend directory structure... +mkdir backend\src\routes 2>nul + +REM Create package.json +( +echo { +echo "name": "photobooth-backend", +echo "version": "1.0.0", +echo "type": "module", +echo "main": "src/server.js", +echo "scripts": { +echo "start": "node src/server.js", +echo "dev": "node --watch src/server.js" +echo }, +echo "keywords": ["photobooth", "express", "mysql"], +echo "author": "", +echo "license": "MIT", +echo "dependencies": { +echo "express": "^4.18.2", +echo "cors": "^2.8.5", +echo "dotenv": "^16.3.1", +echo "mysql2": "^3.6.5", +echo "jsonwebtoken": "^9.1.0", +echo "bcryptjs": "^2.4.3", +echo "uuid": "^9.0.1", +echo "express-async-errors": "^3.1.1", +echo "multer": "^1.4.5-lts.1" +echo }, +echo "devDependencies": {} +echo } +) > backend\package.json + +echo. +echo Backend structure created. Now run: cd backend && npm install diff --git a/tools/db/photobooth.sql b/tools/db/photobooth.sql new file mode 100644 index 0000000..4bb78ef --- /dev/null +++ b/tools/db/photobooth.sql @@ -0,0 +1,47 @@ +-- Photobooth database setup + +CREATE DATABASE IF NOT EXISTS `photobooth` + CHARACTER SET utf8mb4 + COLLATE utf8mb4_0900_ai_ci; + +CREATE USER IF NOT EXISTS 'photobooth_app'@'%' IDENTIFIED BY 'photobooth_app_password_placeholder'; +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON `photobooth`.* TO 'photobooth_app'@'%'; +FLUSH PRIVILEGES; + +USE `photobooth`; + +CREATE TABLE IF NOT EXISTS users ( + id CHAR(36) PRIMARY KEY, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_users_email (email) +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + token_hash VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + revoked_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_refresh_token_hash (token_hash), + KEY idx_refresh_user (user_id), + CONSTRAINT fk_refresh_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; + +CREATE TABLE IF NOT EXISTS media ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('shot','strip') NOT NULL, + url VARCHAR(2048) NOT NULL, + thumb_url VARCHAR(2048) NULL, + theme_id VARCHAR(64) NULL, + meta JSON NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_media_user_created (user_id, created_at), + KEY idx_media_type (type), + CONSTRAINT fk_media_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB; diff --git a/tools/db/setup-db.sh b/tools/db/setup-db.sh index a3b2458..80ee9c0 100644 --- a/tools/db/setup-db.sh +++ b/tools/db/setup-db.sh @@ -5,79 +5,44 @@ MYSQL_ROOT_USER="${MYSQL_ROOT_USER:-root}" MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}" MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}" MYSQL_PORT="${MYSQL_PORT:-3306}" -MYSQL_DATABASE="${MYSQL_DATABASE:-photobooth}" -MYSQL_APP_USER="${MYSQL_APP_USER:-photobooth_app}" MYSQL_APP_PASSWORD="${MYSQL_APP_PASSWORD:-}" MARKER_FILE="${MARKER_FILE:-/var/lib/photobooth/db-setup.done}" -if [ -z "$MYSQL_ROOT_PASSWORD" ]; then - echo "MYSQL_ROOT_PASSWORD is required." >&2 - exit 1 -fi - -if [ -z "$MYSQL_APP_PASSWORD" ]; then - echo "MYSQL_APP_PASSWORD is required." >&2 +if [ -z "$MYSQL_ROOT_PASSWORD" ] || [ -z "$MYSQL_APP_PASSWORD" ]; then + echo "Error: MYSQL_ROOT_PASSWORD and MYSQL_APP_PASSWORD required" >&2 exit 1 fi if [ -f "$MARKER_FILE" ]; then - echo "Database already initialized: $MARKER_FILE" + echo "Database already initialized." exit 0 fi mkdir -p "$(dirname "$MARKER_FILE")" +# Find mysql command +if ! command -v mysql &> /dev/null; then + echo "Error: mysql not found in PATH" >&2 + exit 1 +fi + +# Create temp SQL file with actual password +TEMP_SQL=$(mktemp) +trap "rm -f $TEMP_SQL" EXIT + +sed "s/photobooth_app_password_placeholder/$MYSQL_APP_PASSWORD/g" \ + "$(dirname "$0")/photobooth.sql" > "$TEMP_SQL" + +# Execute MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql \ - --protocol=TCP \ -h "$MYSQL_HOST" \ -P "$MYSQL_PORT" \ - -u "$MYSQL_ROOT_USER" <&2 + exit 1 +} touch "$MARKER_FILE" -echo "Database initialized." +echo "Database setup completed." + +