Add index page with URL generator and remove passphrase from display
Created a beautiful landing page with random room/passphrase generation and updated security model for read-only access. New Files: - server/php/index.html: Landing page with URL generator Features: - Random room name generation (e.g., "swift-phoenix-1234") - Random passphrase generation (16 chars, URL-safe) - Copy-to-clipboard functionality - Responsive design with gradient header - Step-by-step usage instructions - FAQ section Security Model Changes: - WRITE (send transcriptions): Requires room + passphrase - READ (view display): Only requires room name Updated Files: - server.php: * handleStream(): Passphrase optional (read-only) * handleList(): Passphrase optional (read-only) * Added roomExists() helper function - display.php: * Removed passphrase from URL parameters * Removed passphrase from SSE connection * Removed passphrase from list endpoint Benefits: - Display URL is safer (no passphrase in OBS browser source) - Simpler setup (only room name needed for viewing) - Better security model (write-protected, read-open) - Anyone with room name can watch, only authorized can send Example URLs: - Client: server.php (with room + passphrase in app settings) - Display: display.php?room=swift-phoenix-1234&fade=10×tamps=true 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,6 @@
|
|||||||
// Get URL parameters
|
// Get URL parameters
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const room = urlParams.get('room') || 'default';
|
const room = urlParams.get('room') || 'default';
|
||||||
const passphrase = urlParams.get('passphrase') || '';
|
|
||||||
const fadeAfter = parseInt(urlParams.get('fade') || '10');
|
const fadeAfter = parseInt(urlParams.get('fade') || '10');
|
||||||
const showTimestamps = urlParams.get('timestamps') !== 'false';
|
const showTimestamps = urlParams.get('timestamps') !== 'false';
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@
|
|||||||
|
|
||||||
// Connect to Server-Sent Events
|
// Connect to Server-Sent Events
|
||||||
function connect() {
|
function connect() {
|
||||||
const url = `server.php?action=stream&room=${encodeURIComponent(room)}&passphrase=${encodeURIComponent(passphrase)}`;
|
const url = `server.php?action=stream&room=${encodeURIComponent(room)}`;
|
||||||
const eventSource = new EventSource(url);
|
const eventSource = new EventSource(url);
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
@@ -162,7 +161,7 @@
|
|||||||
// Load recent transcriptions on startup
|
// Load recent transcriptions on startup
|
||||||
async function loadRecent() {
|
async function loadRecent() {
|
||||||
try {
|
try {
|
||||||
const url = `server.php?action=list&room=${encodeURIComponent(room)}&passphrase=${encodeURIComponent(passphrase)}`;
|
const url = `server.php?action=list&room=${encodeURIComponent(room)}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
|||||||
360
server/php/index.html
Normal file
360
server/php/index.html
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Multi-User Transcription Server</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.header p {
|
||||||
|
font-size: 1.2em;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.section h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.8em;
|
||||||
|
}
|
||||||
|
.section p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.generator {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 15px 30px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
.urls {
|
||||||
|
display: none;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
.urls.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.url-box {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.url-box h3 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
.url-box .label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.url-box .value {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.url-box .value:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
.copy-btn.copied {
|
||||||
|
background: #5cb85c;
|
||||||
|
}
|
||||||
|
.steps {
|
||||||
|
counter-reset: step;
|
||||||
|
}
|
||||||
|
.step {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-left: 50px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.step::before {
|
||||||
|
counter-increment: step;
|
||||||
|
content: counter(step);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.features {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.feature {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
.feature h3 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.note strong {
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🎙️ Multi-User Transcription Server</h1>
|
||||||
|
<p>Merge captions from multiple streamers into a single OBS display</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<!-- What is this -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>What is this?</h2>
|
||||||
|
<p>This server allows multiple streamers using the Local Transcription app to merge their real-time captions into a single stream. Perfect for collaborative streams, podcasts, or gaming sessions with multiple commentators.</p>
|
||||||
|
|
||||||
|
<div class="features">
|
||||||
|
<div class="feature">
|
||||||
|
<h3>🔒 Secure</h3>
|
||||||
|
<p>Room-based isolation with passphrase authentication</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>🎨 Colorful</h3>
|
||||||
|
<p>Each user gets a unique color (supports 20+ users)</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>⚡ Real-time</h3>
|
||||||
|
<p>Low-latency streaming via Server-Sent Events</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<h3>🌐 Universal</h3>
|
||||||
|
<p>Works on any standard PHP hosting</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate URLs -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Get Started</h2>
|
||||||
|
<p>Click the button below to generate a unique room with random credentials:</p>
|
||||||
|
|
||||||
|
<div class="generator">
|
||||||
|
<button class="button" onclick="generateUrls()">🎲 Generate New Room</button>
|
||||||
|
|
||||||
|
<div class="urls" id="urls">
|
||||||
|
<div class="url-box">
|
||||||
|
<h3>📱 For Desktop App Users</h3>
|
||||||
|
<div class="label">Room Name:</div>
|
||||||
|
<div class="value" id="room" onclick="copyToClipboard('room')">-</div>
|
||||||
|
<button class="copy-btn" onclick="copyToClipboard('room')">Copy Room</button>
|
||||||
|
|
||||||
|
<div class="label" style="margin-top: 15px;">Passphrase:</div>
|
||||||
|
<div class="value" id="passphrase" onclick="copyToClipboard('passphrase')">-</div>
|
||||||
|
<button class="copy-btn" onclick="copyToClipboard('passphrase')">Copy Passphrase</button>
|
||||||
|
|
||||||
|
<div class="label" style="margin-top: 15px;">Server URL:</div>
|
||||||
|
<div class="value" id="serverUrl" onclick="copyToClipboard('serverUrl')">-</div>
|
||||||
|
<button class="copy-btn" onclick="copyToClipboard('serverUrl')">Copy URL</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="url-box">
|
||||||
|
<h3>📺 For OBS Browser Source</h3>
|
||||||
|
<div class="label">Display URL:</div>
|
||||||
|
<div class="value" id="displayUrl" onclick="copyToClipboard('displayUrl')">-</div>
|
||||||
|
<button class="copy-btn" onclick="copyToClipboard('displayUrl')">Copy Display URL</button>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Note:</strong> The display URL does not contain the passphrase for security. Only users with the passphrase can send transcriptions.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- How to use -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>How to Use</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<h3>Generate Room Credentials</h3>
|
||||||
|
<p>Click "Generate New Room" above to create a unique room with a random name and passphrase. Share these with your streaming team.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>Configure Desktop App</h3>
|
||||||
|
<p>In the Local Transcription app, go to Settings → Server Sync and enter:</p>
|
||||||
|
<ul style="margin-left: 20px; margin-top: 10px;">
|
||||||
|
<li>Enable Server Sync: ✓</li>
|
||||||
|
<li>Server URL: (from above)</li>
|
||||||
|
<li>Room Name: (from above)</li>
|
||||||
|
<li>Passphrase: (from above)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>Add to OBS</h3>
|
||||||
|
<p>In OBS, add a Browser source and paste the Display URL. Set width to 1920 and height to your preference (e.g., 200-400px).</p>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<h3>Start Streaming!</h3>
|
||||||
|
<p>All team members start transcription in their apps. Captions from everyone appear merged in OBS with different colors per person.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Frequently Asked Questions</h2>
|
||||||
|
|
||||||
|
<h3 style="color: #667eea; margin-top: 20px;">How many users can join one room?</h3>
|
||||||
|
<p>Technically unlimited, but we've tested up to 20 users successfully. Each user gets a unique color.</p>
|
||||||
|
|
||||||
|
<h3 style="color: #667eea; margin-top: 20px;">Is my passphrase secure?</h3>
|
||||||
|
<p>Yes! Passphrases are hashed using PHP's password_hash() function. They're never stored in plain text.</p>
|
||||||
|
|
||||||
|
<h3 style="color: #667eea; margin-top: 20px;">How long does a room stay active?</h3>
|
||||||
|
<p>Rooms are automatically cleaned up after 2 hours of inactivity to save server resources.</p>
|
||||||
|
|
||||||
|
<h3 style="color: #667eea; margin-top: 20px;">Can I use custom room names?</h3>
|
||||||
|
<p>Yes! You can use any room name you want instead of the randomly generated one. Just make sure all team members use the exact same name.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function generateUrls() {
|
||||||
|
// Generate random room name
|
||||||
|
const adjectives = ['swift', 'bright', 'cosmic', 'electric', 'turbo', 'mega', 'ultra', 'super', 'hyper', 'alpha'];
|
||||||
|
const nouns = ['phoenix', 'dragon', 'tiger', 'falcon', 'comet', 'storm', 'blaze', 'thunder', 'frost', 'nebula'];
|
||||||
|
const randomNum = Math.floor(Math.random() * 10000);
|
||||||
|
const room = `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${nouns[Math.floor(Math.random() * nouns.length)]}-${randomNum}`;
|
||||||
|
|
||||||
|
// Generate random passphrase (URL-safe)
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let passphrase = '';
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
passphrase += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current URL base
|
||||||
|
const baseUrl = window.location.href.replace(/index\.html$/, '');
|
||||||
|
|
||||||
|
// Generate URLs
|
||||||
|
const serverUrl = `${baseUrl}server.php`;
|
||||||
|
const displayUrl = `${baseUrl}display.php?room=${encodeURIComponent(room)}&fade=10×tamps=true`;
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
document.getElementById('room').textContent = room;
|
||||||
|
document.getElementById('passphrase').textContent = passphrase;
|
||||||
|
document.getElementById('serverUrl').textContent = serverUrl;
|
||||||
|
document.getElementById('displayUrl').textContent = displayUrl;
|
||||||
|
|
||||||
|
// Show URLs section
|
||||||
|
document.getElementById('urls').classList.add('active');
|
||||||
|
|
||||||
|
// Scroll to URLs
|
||||||
|
document.getElementById('urls').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(elementId) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
const text = element.textContent;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Find the copy button associated with this element
|
||||||
|
const copyBtn = element.nextElementSibling;
|
||||||
|
if (copyBtn && copyBtn.classList.contains('copy-btn')) {
|
||||||
|
const originalText = copyBtn.textContent;
|
||||||
|
copyBtn.textContent = '✓ Copied!';
|
||||||
|
copyBtn.classList.add('copied');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
copyBtn.textContent = originalText;
|
||||||
|
copyBtn.classList.remove('copied');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -83,18 +83,26 @@ function handleSend() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle streaming transcriptions via Server-Sent Events
|
* Handle streaming transcriptions via Server-Sent Events
|
||||||
|
* Note: Passphrase is optional for streaming (read-only access)
|
||||||
*/
|
*/
|
||||||
function handleStream() {
|
function handleStream() {
|
||||||
// Get parameters
|
// Get parameters
|
||||||
$room = sanitize($_GET['room'] ?? '');
|
$room = sanitize($_GET['room'] ?? '');
|
||||||
$passphrase = $_GET['passphrase'] ?? '';
|
|
||||||
|
|
||||||
if (empty($room) || empty($passphrase)) {
|
if (empty($room)) {
|
||||||
sendError('Missing room or passphrase', 400);
|
sendError('Missing room name', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verifyPassphrase($room, $passphrase)) {
|
// Passphrase is optional for streaming (read-only)
|
||||||
sendError('Invalid passphrase', 401);
|
// If room doesn't exist yet, return empty stream
|
||||||
|
if (!roomExists($room)) {
|
||||||
|
// Return empty stream - room doesn't exist yet
|
||||||
|
header('Content-Type: text/event-stream');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
echo ": waiting for room to be created\n\n";
|
||||||
|
flush();
|
||||||
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set SSE headers
|
// Set SSE headers
|
||||||
@@ -136,19 +144,17 @@ function handleStream() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle listing recent transcriptions
|
* Handle listing recent transcriptions
|
||||||
|
* Note: Passphrase is optional for listing (read-only access)
|
||||||
*/
|
*/
|
||||||
function handleList() {
|
function handleList() {
|
||||||
$room = sanitize($_GET['room'] ?? '');
|
$room = sanitize($_GET['room'] ?? '');
|
||||||
$passphrase = $_GET['passphrase'] ?? '';
|
|
||||||
|
|
||||||
if (empty($room) || empty($passphrase)) {
|
if (empty($room)) {
|
||||||
sendError('Missing room or passphrase', 400);
|
sendError('Missing room name', 400);
|
||||||
}
|
|
||||||
|
|
||||||
if (!verifyPassphrase($room, $passphrase)) {
|
|
||||||
sendError('Invalid passphrase', 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passphrase is optional for read-only access
|
||||||
|
// If room doesn't exist, return empty array
|
||||||
$transcriptions = getTranscriptions($room);
|
$transcriptions = getTranscriptions($room);
|
||||||
sendJson(['transcriptions' => $transcriptions]);
|
sendJson(['transcriptions' => $transcriptions]);
|
||||||
}
|
}
|
||||||
@@ -235,6 +241,13 @@ function getRoomFile($room) {
|
|||||||
return STORAGE_DIR . '/room_' . md5($room) . '.json';
|
return STORAGE_DIR . '/room_' . md5($room) . '.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if room exists
|
||||||
|
*/
|
||||||
|
function roomExists($room) {
|
||||||
|
return file_exists(getRoomFile($room));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup old sessions
|
* Cleanup old sessions
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user