diff --git a/Dockerfile b/Dockerfile index d8dcb82..3738a68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ COPY server/ ./server/ COPY --from=builder /app/dist ./dist # Create uploads directory -RUN mkdir -p uploads/avatars +RUN mkdir -p uploads/avatars uploads/branding ENV NODE_ENV=production ENV PORT=3001 diff --git a/package-lock.json b/package-lock.json index bcc554a..058ccac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "redlight", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "redlight", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "axios": "^1.7.0", "bcryptjs": "^2.4.3", @@ -17,6 +17,7 @@ "express": "^4.21.0", "jsonwebtoken": "^9.0.0", "lucide-react": "^0.460.0", + "multer": "^2.0.2", "pg": "^8.18.0", "react": "^18.3.0", "react-dom": "^18.3.0", @@ -1333,6 +1334,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1599,6 +1606,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1794,6 +1818,21 @@ "node": ">= 6" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -3022,6 +3061,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -3034,6 +3085,24 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4168,6 +4237,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4469,6 +4546,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index a1b983e..c82082f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "express": "^4.21.0", "jsonwebtoken": "^9.0.0", "lucide-react": "^0.460.0", + "multer": "^2.0.2", "pg": "^8.18.0", "react": "^18.3.0", "react-dom": "^18.3.0", diff --git a/server/config/database.js b/server/config/database.js index 6ad340c..bc4ab58 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -170,6 +170,12 @@ export async function initDatabase() { CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id); CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT NOW() + ); `); } else { await db.exec(` @@ -222,6 +228,12 @@ export async function initDatabase() { CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id); CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); `); } diff --git a/server/index.js b/server/index.js index 5a3e5e7..8a0178e 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,7 @@ import authRoutes from './routes/auth.js'; import roomRoutes from './routes/rooms.js'; import recordingRoutes from './routes/recordings.js'; import adminRoutes from './routes/admin.js'; +import brandingRoutes from './routes/branding.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -31,6 +32,7 @@ async function start() { app.use('/api/rooms', roomRoutes); app.use('/api/recordings', recordingRoutes); app.use('/api/admin', adminRoutes); + app.use('/api/branding', brandingRoutes); // Serve static files in production if (process.env.NODE_ENV === 'production') { diff --git a/server/routes/branding.js b/server/routes/branding.js new file mode 100644 index 0000000..f50c20a --- /dev/null +++ b/server/routes/branding.js @@ -0,0 +1,160 @@ +import { Router } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { getDb } from '../config/database.js'; +import { authenticateToken, requireAdmin } from '../middleware/auth.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const router = Router(); + +// Ensure uploads/branding directory exists +const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding'); +if (!fs.existsSync(brandingDir)) { + fs.mkdirSync(brandingDir, { recursive: true }); +} + +// Multer config for logo upload +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, brandingDir), + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() || '.png'; + cb(null, `logo${ext}`); + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: 2 * 1024 * 1024 }, // 2MB + fileFilter: (req, file, cb) => { + const allowed = /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i; + const mimeAllowed = /^image\/(jpeg|png|gif|svg\+xml|webp|x-icon|vnd\.microsoft\.icon)$/; + if (allowed.test(path.extname(file.originalname)) && mimeAllowed.test(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Only image files are allowed')); + } + }, +}); + +// Helper: get setting from DB +async function getSetting(key) { + const db = getDb(); + const row = await db.get('SELECT value FROM settings WHERE key = ?', [key]); + return row?.value || null; +} + +// Helper: set setting in DB +async function setSetting(key, value) { + const db = getDb(); + // Upsert + const existing = await db.get('SELECT key FROM settings WHERE key = ?', [key]); + if (existing) { + await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]); + } else { + await db.run('INSERT INTO settings (key, value) VALUES (?, ?)', [key, value]); + } +} + +// Helper: delete setting from DB +async function deleteSetting(key) { + const db = getDb(); + await db.run('DELETE FROM settings WHERE key = ?', [key]); +} + +// Helper: find current logo file on disk +function findLogoFile() { + if (!fs.existsSync(brandingDir)) return null; + const files = fs.readdirSync(brandingDir); + const logo = files.find(f => f.startsWith('logo.')); + return logo ? path.join(brandingDir, logo) : null; +} + +// GET /api/branding - Get branding settings (public) +router.get('/', async (req, res) => { + try { + const appName = await getSetting('app_name'); + const logoFile = findLogoFile(); + + res.json({ + appName: appName || 'Redlight', + hasLogo: !!logoFile, + logoUrl: logoFile ? '/api/branding/logo' : null, + }); + } catch (err) { + console.error('Get branding error:', err); + res.status(500).json({ error: 'Could not load branding' }); + } +}); + +// GET /api/branding/logo - Serve logo file (public) +router.get('/logo', (req, res) => { + const logoFile = findLogoFile(); + if (!logoFile) { + return res.status(404).json({ error: 'No logo found' }); + } + res.sendFile(logoFile); +}); + +// POST /api/branding/logo - Upload logo (admin only) +router.post('/logo', authenticateToken, requireAdmin, (req, res) => { + upload.single('logo')(req, res, async (err) => { + if (err) { + if (err instanceof multer.MulterError) { + return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 2MB)' : err.message }); + } + return res.status(400).json({ error: err.message }); + } + + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + // Remove old logo files that don't match the new extension + const files = fs.readdirSync(brandingDir); + for (const f of files) { + if (f.startsWith('logo.') && f !== req.file.filename) { + fs.unlinkSync(path.join(brandingDir, f)); + } + } + + res.json({ + logoUrl: '/api/branding/logo', + message: 'Logo uploaded', + }); + }); +}); + +// DELETE /api/branding/logo - Remove logo (admin only) +router.delete('/logo', authenticateToken, requireAdmin, async (req, res) => { + try { + const logoFile = findLogoFile(); + if (logoFile) { + fs.unlinkSync(logoFile); + } + res.json({ message: 'Logo removed' }); + } catch (err) { + console.error('Delete logo error:', err); + res.status(500).json({ error: 'Could not remove logo' }); + } +}); + +// PUT /api/branding/name - Update app name (admin only) +router.put('/name', authenticateToken, requireAdmin, async (req, res) => { + try { + const { appName } = req.body; + if (!appName || !appName.trim()) { + return res.status(400).json({ error: 'App name is required' }); + } + await setSetting('app_name', appName.trim()); + res.json({ appName: appName.trim() }); + } catch (err) { + console.error('Update app name error:', err); + res.status(500).json({ error: 'Could not update app name' }); + } +}); + +export default router; diff --git a/src/App.jsx b/src/App.jsx index 29674ab..15fcc5e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from './contexts/AuthContext'; import { useLanguage } from './contexts/LanguageContext'; +import { useBranding } from './contexts/BrandingContext'; import Layout from './components/Layout'; import ProtectedRoute from './components/ProtectedRoute'; import Home from './pages/Home'; @@ -16,6 +17,7 @@ import GuestJoin from './pages/GuestJoin'; export default function App() { const { user, loading } = useAuth(); const { setLanguage } = useLanguage(); + const { appName } = useBranding(); // Sync language from server when user loads useEffect(() => { @@ -24,6 +26,11 @@ export default function App() { } }, [user?.language, setLanguage]); + // Update document title with branding + useEffect(() => { + document.title = `${appName} - BigBlueButton Frontend`; + }, [appName]); + if (loading) { return (
diff --git a/src/components/BrandLogo.jsx b/src/components/BrandLogo.jsx new file mode 100644 index 0000000..cd6e92a --- /dev/null +++ b/src/components/BrandLogo.jsx @@ -0,0 +1,35 @@ +import { Video } from 'lucide-react'; +import { useBranding } from '../contexts/BrandingContext'; + +const sizes = { + sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' }, + md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' }, + lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' }, +}; + +export default function BrandLogo({ size = 'md', className = '' }) { + const { appName, hasLogo, logoUrl } = useBranding(); + const s = sizes[size] || sizes.md; + + if (hasLogo && logoUrl) { + return ( +
+ {appName} + {appName} +
+ ); + } + + return ( +
+
+
+ {appName} +
+ ); +} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 935ed55..67b6712 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,5 +1,6 @@ import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, Settings, Shield, Video, X, Palette } from 'lucide-react'; +import { LayoutDashboard, Settings, Shield, X, Palette } from 'lucide-react'; +import BrandLogo from './BrandLogo'; import { useAuth } from '../contexts/AuthContext'; import { useLanguage } from '../contexts/LanguageContext'; import ThemeSelector from './ThemeSelector'; @@ -36,14 +37,7 @@ export default function Sidebar({ open, onClose }) {
{/* Logo */}
-
-
-
-
-

Redlight

-
-
+
+ {/* Branding */} +
+
+ +

{t('admin.brandingTitle')}

+
+

{t('admin.brandingDescription')}

+ +
+ {/* Logo upload */} +
+ +
+ {hasLogo && logoUrl ? ( +
+ Logo + +
+ ) : ( +
+ +
+ )} +
+ + +

{t('admin.logoHint')}

+
+
+
+ + {/* App name */} +
+ +
+
+ + setEditAppName(e.target.value)} + className="input-field pl-9 text-sm" + placeholder="Redlight" + maxLength={30} + /> +
+ +
+
+
+
+ {/* Search */}
diff --git a/src/pages/GuestJoin.jsx b/src/pages/GuestJoin.jsx index b7e0730..6ae814b 100644 --- a/src/pages/GuestJoin.jsx +++ b/src/pages/GuestJoin.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react'; +import BrandLogo from '../components/BrandLogo'; import api from '../services/api'; import toast from 'react-hot-toast'; import { useLanguage } from '../contexts/LanguageContext'; @@ -132,11 +133,8 @@ export default function GuestJoin() {
{/* Logo */} -
-
-
- Redlight +
+
{/* Room info */} diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index f9b4a65..173d38b 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom'; import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react'; +import BrandLogo from '../components/BrandLogo'; import { useLanguage } from '../contexts/LanguageContext'; export default function Home() { @@ -48,12 +49,7 @@ export default function Home() { {/* Navbar */}