Replace master branch with local files

This commit is contained in:
doomtube 2025-08-03 21:53:15 -04:00
commit 875a53f499
60 changed files with 21637 additions and 0 deletions

10
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

41
frontend/Dockerfile Normal file
View file

@ -0,0 +1,41 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source files
COPY . .
# Set environment variables for build
ENV VITE_API_URL=http://localhost/api
ENV VITE_WS_URL=ws://localhost/ws
ENV VITE_STREAM_PORT=8088
# Generate .svelte-kit directory
RUN npx svelte-kit sync
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./
# Install production dependencies only
RUN npm ci --omit=dev
# Expose port
EXPOSE 3000
# Set environment to production
ENV NODE_ENV=production
CMD ["node", "build"]

31
frontend/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "streaming-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"start": "node build"
},
"devDependencies": {
"@sveltejs/adapter-node": "^4.0.1",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/node": "^20.11.0",
"svelte": "^4.2.0",
"svelte-check": "^3.6.0",
"tslib": "^2.6.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"hls.js": "^1.6.7",
"mdb-ui-kit": "^9.1.0",
"openpgp": "^5.11.0",
"ovenplayer": "^0.10.43"
},
"type": "module"
}

323
frontend/src/app.css Normal file
View file

@ -0,0 +1,323 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #561d5e;
--black: #000;
--white: #fff;
--gray: #888;
--light-gray: #f5f5f5;
--error: #dc3545;
--success: #28a745;
--border: #333;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--black);
color: var(--white);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.auth-container {
max-width: 400px;
margin: 4rem auto;
padding: 2rem;
background: #111;
border-radius: 8px;
border: 1px solid var(--border);
}
h1, h2, h3 {
margin-bottom: 1rem;
color: var(--white);
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input[type="text"],
input[type="password"],
textarea,
select {
width: 100%;
padding: 0.75rem;
background: var(--black);
color: var(--white);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 1rem;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--primary);
}
button, .btn {
padding: 0.75rem 1.5rem;
background: var(--primary);
color: var(--white);
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: opacity 0.2s;
}
button:hover:not(:disabled),
.btn:hover {
opacity: 0.9;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--primary);
}
.btn-danger {
background: var(--error);
}
.btn-block {
width: 100%;
display: block;
}
.error {
color: var(--error);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.success {
color: var(--success);
font-size: 0.9rem;
margin-top: 0.5rem;
}
.nav {
background: #111;
border-bottom: 1px solid var(--border);
padding: 1rem 0;
margin-bottom: 2rem;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
font-size: 1.25rem;
font-weight: 600;
color: var(--white);
text-decoration: none;
}
.card {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.avatar-small {
width: 40px;
height: 40px;
}
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.file-input-wrapper input[type="file"] {
position: absolute;
left: -9999px;
}
.pgp-key {
font-family: monospace;
font-size: 0.8rem;
background: rgba(255, 255, 255, 0.05);
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
}
.fingerprint {
font-family: monospace;
font-size: 0.9rem;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
font-weight: 600;
color: var(--primary);
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--primary);
color: var(--white);
border-radius: 12px;
font-size: 0.85rem;
}
.badge-admin {
background: var(--error);
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal-close {
background: none;
border: none;
color: var(--gray);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
}
.modal-close:hover {
color: var(--white);
}
/* Ensure stream pages have black background */
html {
background: var(--black);
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.nav-links {
flex-wrap: wrap;
gap: 1rem;
}
.tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
background: var(--gray);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 600;
color: var(--white);
overflow: hidden;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-small {
width: 40px;
height: 40px;
font-size: 1rem;
}

23
frontend/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,23 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user?: {
id: number;
username: string;
};
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
interface Window {
OvenPlayer: any;
Hls: any;
}
}
export {};

29
frontend/src/app.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Live Streaming Platform</title>
<style>
/* Global reset for better player scaling */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

36
frontend/src/lib/api.js Normal file
View file

@ -0,0 +1,36 @@
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api';
async function fetchAPI(endpoint, options = {}) {
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include', // Always include credentials
});
if (!response.ok) {
throw new Error(`API error: ${response.statusText}`);
}
return response.json();
}
export async function getStreamKey() {
return fetchAPI('/stream/key');
}
export async function regenerateStreamKey() {
return fetchAPI('/stream/key/regenerate', {
method: 'POST',
});
}
export async function validateStreamKey(key) {
return fetchAPI(`/stream/validate/${key}`);
}
export async function getStreamStats(streamKey) {
return fetchAPI(`/stream/stats/${streamKey}`);
}

99
frontend/src/lib/pgp.js Normal file
View file

@ -0,0 +1,99 @@
// Client-side PGP utilities - wraps openpgp for browser-only usage
export async function generateKeyPair(username, passphrase = '') {
if (typeof window === 'undefined') {
throw new Error('PGP operations can only be performed in the browser');
}
const { generateKey, readKey } = await import('openpgp');
const { privateKey, publicKey } = await generateKey({
type: 'rsa',
rsaBits: 2048,
userIDs: [{ name: username }],
passphrase
});
const key = await readKey({ armoredKey: publicKey });
const fingerprint = key.getFingerprint();
return {
privateKey,
publicKey,
fingerprint
};
}
export async function getFingerprint(publicKey) {
if (typeof window === 'undefined') return null;
try {
const { readKey } = await import('openpgp');
const key = await readKey({ armoredKey: publicKey });
return key.getFingerprint();
} catch (error) {
console.error('Error getting fingerprint:', error);
return null;
}
}
export async function signMessage(message, privateKeyArmored, passphrase = '') {
if (typeof window === 'undefined') {
throw new Error('PGP operations can only be performed in the browser');
}
const { decryptKey, readPrivateKey, createMessage, sign } = await import('openpgp');
const privateKey = await decryptKey({
privateKey: await readPrivateKey({ armoredKey: privateKeyArmored }),
passphrase
});
const unsignedMessage = await createMessage({ text: message });
const signature = await sign({
message: unsignedMessage,
signingKeys: privateKey,
detached: true
});
return signature;
}
export async function verifySignature(message, signature, publicKeyArmored) {
if (typeof window === 'undefined') return false;
try {
const { readKey, readSignature, createMessage, verify } = await import('openpgp');
const publicKey = await readKey({ armoredKey: publicKeyArmored });
const signatureObj = await readSignature({ armoredSignature: signature });
const messageObj = await createMessage({ text: message });
const verificationResult = await verify({
message: messageObj,
signature: signatureObj,
verificationKeys: publicKey
});
const { verified } = verificationResult.signatures[0];
return await verified;
} catch (error) {
console.error('Signature verification error:', error);
return false;
}
}
export function storePrivateKey(privateKey) {
if (typeof window === 'undefined') return;
localStorage.setItem('pgp_private_key', privateKey);
}
export function getStoredPrivateKey() {
if (typeof window === 'undefined') return null;
return localStorage.getItem('pgp_private_key');
}
export function removeStoredPrivateKey() {
if (typeof window === 'undefined') return;
localStorage.removeItem('pgp_private_key');
}

View file

@ -0,0 +1,128 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
function createAuthStore() {
const { subscribe, set, update } = writable({
user: null,
token: null,
loading: true
});
return {
subscribe,
async init() {
if (!browser) return;
const token = localStorage.getItem('auth_token');
if (!token) {
set({ user: null, token: null, loading: false });
return;
}
try {
const response = await fetch('/api/user/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
set({ user: data.user, token, loading: false });
} else {
localStorage.removeItem('auth_token');
set({ user: null, token: null, loading: false });
}
} catch (error) {
console.error('Auth init error:', error);
set({ user: null, token: null, loading: false });
}
},
async login(credentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (response.ok && data.success) {
localStorage.setItem('auth_token', data.token);
set({ user: data.user, token: data.token, loading: false });
goto('/');
return { success: true };
}
return { success: false, error: data.error || 'Invalid credentials' };
},
async loginWithPgp(username, signature, challenge) {
const response = await fetch('/api/auth/pgp-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, signature, challenge })
});
const data = await response.json();
if (response.ok && data.success) {
localStorage.setItem('auth_token', data.token);
set({ user: data.user, token: data.token, loading: false });
goto('/');
return { success: true };
}
return { success: false, error: data.error || 'Invalid signature' };
},
async register(userData) {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
const data = await response.json();
if (response.ok && data.success) {
return { success: true, userId: data.userId };
}
return { success: false, error: data.error || 'Registration failed' };
},
updateUser(userData) {
update(state => ({
...state,
user: userData
}));
},
logout() {
localStorage.removeItem('auth_token');
set({ user: null, token: null, loading: false });
goto('/login');
}
};
}
export const auth = createAuthStore();
export const isAuthenticated = derived(
auth,
$auth => !!$auth.user
);
export const isAdmin = derived(
auth,
$auth => $auth.user?.isAdmin || false
);
export const isStreamer = derived(
auth,
$auth => $auth.user?.isStreamer || false
);

View file

@ -0,0 +1,56 @@
let ws = null;
let reconnectTimeout = null;
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost/ws';
export function connectWebSocket(onMessage) {
if (ws?.readyState === WebSocket.OPEN) return;
// WebSocket doesn't support withCredentials, but cookies are sent automatically
// on same-origin requests
ws = new WebSocket(`${WS_URL}/stream`);
ws.onopen = () => {
console.log('WebSocket connected');
ws?.send(JSON.stringify({ type: 'subscribe' }));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessage(data);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
// Reconnect after 5 seconds
reconnectTimeout = setTimeout(() => {
connectWebSocket(onMessage);
}, 5000);
};
}
export function disconnectWebSocket() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (ws) {
ws.close();
ws = null;
}
}
export function sendMessage(message) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}

View file

@ -0,0 +1,228 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated, isAdmin, isStreamer } from '$lib/stores/auth';
import { page } from '$app/stores';
import '../app.css';
let showDropdown = false;
// Close dropdown when route changes
$: if ($page) {
showDropdown = false;
}
onMount(() => {
auth.init();
// Close dropdown when clicking outside
const handleClickOutside = (event) => {
if (!event.target.closest('.user-menu')) {
showDropdown = false;
}
};
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
function toggleDropdown() {
showDropdown = !showDropdown;
}
</script>
<style>
.nav {
background: #111;
border-bottom: 1px solid var(--border);
padding: 1rem 0;
margin-bottom: 2rem;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
font-size: 1.25rem;
font-weight: 600;
color: var(--white);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 1rem;
align-items: center;
}
.nav-link {
color: var(--white);
text-decoration: none;
transition: color 0.2s;
padding: 0.5rem 1rem;
}
.nav-link:hover {
color: var(--primary);
}
.user-menu {
position: relative;
}
.user-avatar-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--gray);
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--white);
font-weight: 600;
transition: border-color 0.2s;
padding: 0;
overflow: hidden;
}
.user-avatar-btn:hover {
border-color: var(--primary);
}
.user-avatar-btn img {
width: 100%;
height: 100%;
object-fit: cover;
border: none;
}
.dropdown {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
min-width: 200px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.dropdown-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.dropdown-username {
font-weight: 600;
margin-bottom: 0.25rem;
color: var(--white);
text-decoration: none;
display: block;
transition: color 0.2s;
}
.dropdown-username:hover {
color: var(--primary);
}
.dropdown-role {
font-size: 0.85rem;
color: var(--gray);
}
.dropdown-item {
display: block;
padding: 0.75rem 1rem;
color: var(--white);
text-decoration: none;
transition: background 0.2s;
}
.dropdown-item:hover {
background: rgba(86, 29, 94, 0.2);
}
.dropdown-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.dropdown-item.logout {
color: var(--error);
}
</style>
<nav class="nav">
<div class="nav-container">
<a href="/" class="nav-brand">Stream</a>
{#if !$auth.loading}
{#if $isAuthenticated}
<div class="user-menu">
<button class="user-avatar-btn" on:click={toggleDropdown}>
{#if $auth.user.avatarUrl}
<img src={$auth.user.avatarUrl} alt={$auth.user.username} />
{:else}
{$auth.user.username.charAt(0).toUpperCase()}
{/if}
</button>
{#if showDropdown}
<div class="dropdown">
<div class="dropdown-header">
<a href="/profile/{$auth.user.username}" class="dropdown-username">
{$auth.user.username}
</a>
<div class="dropdown-role">
{#if $isAdmin}
Admin
{:else if $isStreamer}
Streamer
{:else}
User
{/if}
</div>
</div>
<a href="/settings" class="dropdown-item">
Settings
</a>
{#if $isStreamer}
<a href="/my-realms" class="dropdown-item">
My Realms
</a>
{/if}
{#if $isAdmin}
<a href="/admin" class="dropdown-item">
Admin
</a>
{/if}
<div class="dropdown-divider"></div>
<button class="dropdown-item logout" on:click={() => auth.logout()}>
Logout
</button>
</div>
{/if}
</div>
{:else}
<div class="nav-links">
<a href="/login" class="nav-link">Login</a>
</div>
{/if}
{/if}
</div>
</nav>
<slot />

View file

@ -0,0 +1,204 @@
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let streams = [];
let interval;
let loading = true;
async function loadStreams() {
if (!browser) return;
try {
const res = await fetch('/api/realms/live');
if (res.ok) {
streams = await res.json();
}
} catch (e) {
console.error('Failed to load streams:', e);
} finally {
loading = false;
}
}
onMount(() => {
loadStreams();
// Refresh every 10 seconds
interval = setInterval(loadStreams, 10000);
return () => {
if (interval) clearInterval(interval);
};
});
</script>
<style>
.hero {
text-align: center;
padding: 4rem 0;
margin-bottom: 3rem;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, var(--primary), #8b3a92);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero p {
font-size: 1.25rem;
color: var(--gray);
}
.stream-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.stream-card {
background: #111;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: var(--white);
}
.stream-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(86, 29, 94, 0.3);
border-color: var(--primary);
}
.stream-thumbnail {
width: 100%;
height: 180px;
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.live-badge {
position: absolute;
top: 1rem;
left: 1rem;
background: #ff0000;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
}
.stream-info {
padding: 1.5rem;
}
.stream-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
}
.stream-meta {
display: flex;
align-items: center;
gap: 1rem;
color: var(--gray);
font-size: 0.9rem;
}
.streamer-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.streamer-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gray);
}
.viewer-count {
display: flex;
align-items: center;
gap: 0.25rem;
}
.viewer-count::before {
content: '•';
width: 8px;
height: 8px;
background: #ff0000;
border-radius: 50%;
margin-right: 0.25rem;
}
.no-streams {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-streams-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
</style>
<div class="container">
<div class="hero">
<h1>Live Streams</h1>
<p>Watch your favorite streamers live</p>
</div>
{#if loading}
<div style="text-align: center; padding: 2rem;">
<p>Loading streams...</p>
</div>
{:else if streams.length === 0}
<div class="no-streams">
<div class="no-streams-icon">📺</div>
<h2>No streams live right now</h2>
<p>Check back later or become a streamer yourself!</p>
</div>
{:else}
<div class="stream-grid">
{#each streams as stream}
<a href={`/${stream.name}/live`} class="stream-card">
<div class="stream-thumbnail">
<div class="live-badge">LIVE</div>
<span style="font-size: 3rem; opacity: 0.3;">🎮</span>
</div>
<div class="stream-info">
<h3>{stream.name}</h3>
<div class="stream-meta">
<div class="streamer-info">
{#if stream.avatarUrl}
<img src={stream.avatarUrl} alt={stream.username} class="streamer-avatar" />
{:else}
<div class="streamer-avatar"></div>
{/if}
<span>{stream.username}</span>
</div>
<div class="viewer-count">
{stream.viewerCount} {stream.viewerCount === 1 ? 'viewer' : 'viewers'}
</div>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,669 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { auth } from '$lib/stores/auth';
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
import { goto } from '$app/navigation';
// Import CSS that's safe for SSR
import '@fortawesome/fontawesome-free/css/all.min.css';
import 'mdb-ui-kit/css/mdb.min.css';
// Browser-only imports
let Hls;
let OvenPlayer;
// Only import on client side
if (typeof window !== 'undefined') {
import('hls.js').then(module => {
Hls = module.default;
window.Hls = Hls;
});
import('ovenplayer').then(module => {
OvenPlayer = module.default;
window.OvenPlayer = OvenPlayer;
});
}
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
let player;
let realm = null;
let streamKey = '';
let loading = true;
let error = '';
let message = '';
let isStreaming = false;
let heartbeatInterval;
let viewerToken = null;
let statsInterval;
// Stats
let stats = {
connections: 0,
bitrate: 0,
resolution: 'N/A',
codec: 'N/A',
fps: 0,
isLive: false
};
onMount(async () => {
const realmName = $page.params.realm;
// Load realm info
await loadRealm(realmName);
if (!realm) {
error = 'Realm not found';
loading = false;
return;
}
// Get viewer token
const tokenObtained = await getViewerToken();
if (!tokenObtained) {
loading = false;
return;
}
// Get the actual stream key using the token
const keyObtained = await getStreamKey();
if (!keyObtained) {
loading = false;
return;
}
// Wait for dependencies
const checkDependencies = async () => {
let attempts = 0;
while ((!window.Hls || !window.OvenPlayer) && attempts < 20) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (!window.Hls || !window.OvenPlayer) {
console.error('Failed to load dependencies');
error = 'Failed to load player dependencies';
return false;
}
return true;
};
const depsLoaded = await checkDependencies();
if (!depsLoaded) {
loading = false;
return;
}
// Initialize player after a short delay
setTimeout(initializePlayer, 100);
// Start heartbeat
startHeartbeat();
// Start stats polling
startStatsPolling();
// Connect WebSocket
connectWebSocket((data) => {
if (data.type === 'stats_update' && data.stream_key === streamKey) {
updateStatsFromData(data.stats);
}
});
loading = false;
});
onDestroy(() => {
if (player) {
player.remove();
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
if (statsInterval) {
clearInterval(statsInterval);
}
disconnectWebSocket();
});
async function loadRealm(realmName) {
try {
const response = await fetch(`/api/realms/by-name/${realmName}`);
if (response.ok) {
const data = await response.json();
realm = data.realm;
// Get the stream key from the database
const keyResponse = await fetch(`/api/realms/${realm.id}`);
if (keyResponse.ok && keyResponse.status !== 404) {
const keyData = await keyResponse.json();
if (keyData.success && keyData.realm && keyData.realm.streamKey) {
streamKey = keyData.realm.streamKey;
}
} else {
// If we can't get the key directly, we'll need to rely on the backend
// to validate tokens against the realm
streamKey = 'realm-' + realm.id;
}
} else if (response.status === 404) {
error = 'Realm not found';
}
} catch (e) {
console.error('Failed to load realm:', e);
error = 'Failed to load realm';
}
}
async function getViewerToken() {
if (!realm) return;
try {
const response = await fetch(`/api/realms/${realm.id}/viewer-token`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
viewerToken = data.viewer_token;
console.log('Viewer token obtained');
// Now we need to get the actual stream key for the player
// This will be handled server-side via the token
return true;
} else {
console.error('Failed to get viewer token');
error = 'Failed to authenticate for stream';
return false;
}
} catch (e) {
console.error('Error getting viewer token:', e);
error = 'Failed to authenticate for stream';
return false;
}
}
async function getStreamKey() {
if (!realm || !viewerToken) return false;
try {
const response = await fetch(`/api/realms/${realm.id}/stream-key`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
streamKey = data.streamKey;
console.log('Stream key obtained');
return true;
} else {
console.error('Failed to get stream key');
error = 'Failed to get stream key';
return false;
}
} catch (e) {
console.error('Error getting stream key:', e);
error = 'Failed to get stream key';
return false;
}
}
function startHeartbeat() {
heartbeatInterval = setInterval(async () => {
if (streamKey && viewerToken) {
try {
const response = await fetch(`/api/stream/heartbeat/${streamKey}`, {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
console.error('Heartbeat failed, getting new token');
await getViewerToken();
}
} catch (error) {
console.error('Heartbeat error:', error);
}
}
}, 10000);
}
function startStatsPolling() {
statsInterval = setInterval(async () => {
if (realm) {
try {
const response = await fetch(`/api/realms/${realm.id}/stats`);
const data = await response.json();
if (data.success && data.stats) {
updateStatsFromData(data.stats);
}
} catch (error) {
console.error('Failed to fetch stats:', error);
}
}
}, 2000);
}
function updateStatsFromData(data) {
stats = {
connections: data.connections || 0,
bitrate: data.bitrate || 0,
resolution: data.resolution || 'N/A',
codec: data.codec || 'N/A',
fps: data.fps || 0,
isLive: data.is_live || false
};
isStreaming = stats.isLive;
// Update viewer count in realm info if different
if (realm && realm.viewerCount !== stats.connections) {
realm.viewerCount = stats.connections;
}
}
function initializePlayer() {
const playerElement = document.getElementById('player');
if (!playerElement) {
console.error('Player element not found');
return;
}
if (!viewerToken || !streamKey) {
console.error('No viewer token or stream key, cannot initialize player');
return;
}
const sources = [];
if (streamKey) {
// Add all sources
sources.push(
{
type: 'webrtc',
file: `ws://localhost:3333/app/${streamKey}`,
label: 'WebRTC (Ultra Low Latency)'
},
{
type: 'hls',
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/llhls.m3u8`,
label: 'LLHLS (Low Latency)'
},
{
type: 'hls',
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8`,
label: 'HLS (Standard)'
}
);
}
const config = {
autoStart: true,
autoFallback: true,
controls: true,
showBigPlayButton: true,
watermark: false,
mute: false,
aspectRatio: "16:9",
sources: sources,
webrtcConfig: {
timeoutMaxRetry: 4,
connectionTimeout: 10000
},
hlsConfig: {
debug: false,
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 90,
xhrSetup: function(xhr, url) {
xhr.withCredentials = true;
}
}
};
try {
player = window.OvenPlayer.create('player', config);
player.on('error', (error) => {
console.error('Player error:', error);
isStreaming = false;
if (error.code === 403 || error.code === 401) {
getViewerToken().then(() => {
if (player) {
player.remove();
setTimeout(initializePlayer, 500);
}
});
}
});
player.on('stateChanged', (data) => {
if (data.newstate === 'playing') {
isStreaming = true;
message = '';
} else if (data.newstate === 'error' || data.newstate === 'idle') {
if (!stats.isLive) {
isStreaming = false;
}
}
});
player.on('play', () => {
isStreaming = true;
});
} catch (e) {
console.error('Failed to create player:', e);
error = 'Failed to initialize player';
}
}
function formatBitrate(bitrate) {
if (bitrate > 1000000) {
return (bitrate / 1000000).toFixed(2) + ' Mbps';
} else if (bitrate > 1000) {
return (bitrate / 1000).toFixed(0) + ' Kbps';
} else {
return bitrate + ' bps';
}
}
</script>
<style>
/* Fix the background color issue */
:global(body) {
background: var(--black) !important;
color: var(--white) !important;
}
.stream-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
display: grid;
grid-template-columns: 1fr 320px;
gap: 2rem;
background: var(--black);
color: var(--white);
}
@media (max-width: 1024px) {
.stream-container {
grid-template-columns: 1fr;
}
}
.player-section {
width: 100%;
}
.player-wrapper {
background: #000;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
margin-bottom: 1rem;
}
.player-area {
position: relative;
}
.dummy-player {
padding-bottom: 56.25%; /* 16:9 aspect ratio */
background: #000;
}
.player-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
}
#player {
width: 100%;
height: 100%;
}
.stream-info-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.stream-header {
margin-bottom: 1.5rem;
}
.stream-header h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
color: var(--white);
}
.streamer-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.streamer-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gray);
}
.streamer-name {
font-weight: 600;
color: var(--white);
}
.viewer-count {
font-size: 0.9rem;
color: var(--gray);
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.stats-section {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
.stats-section h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--primary);
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.status-indicator.active {
background: rgba(40, 167, 69, 0.2);
color: var(--success);
}
.status-indicator.inactive {
background: rgba(220, 53, 69, 0.2);
color: var(--error);
}
.stats-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
color: var(--gray);
font-size: 0.9rem;
}
.stat-value {
font-weight: 600;
font-family: monospace;
color: var(--white);
}
.offline-message {
text-align: center;
padding: 4rem 2rem;
color: var(--gray);
}
.offline-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-container {
text-align: center;
padding: 4rem 2rem;
color: var(--white);
}
.loading-container {
text-align: center;
padding: 4rem 2rem;
color: var(--gray);
}
</style>
{#if loading}
<div class="loading-container">
<p>Loading stream...</p>
</div>
{:else if error && !realm}
<div class="error-container">
<h1>Stream Not Found</h1>
<p style="color: var(--gray); margin-top: 1rem;">{error}</p>
<a href="/" class="btn" style="margin-top: 2rem;">Back to Home</a>
</div>
{:else if realm}
<div class="stream-container">
<div class="player-section">
<div class="player-wrapper">
<div class="player-area">
<div class="dummy-player"></div>
<div class="player-container">
<div id="player"></div>
</div>
</div>
</div>
<div class="stream-info-section">
<div class="stream-header">
<h1>{realm.name}</h1>
<div class="streamer-info">
{#if realm.avatarUrl}
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" />
{:else}
<div class="streamer-avatar"></div>
{/if}
<div>
<div class="streamer-name">{realm.username}</div>
<div class="viewer-count">
{realm.viewerCount} {realm.viewerCount === 1 ? 'viewer' : 'viewers'}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="sidebar">
<div class="stats-section">
<h3>Stream Stats</h3>
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
{#if stats.isLive}
<span></span> Live
{:else}
<span></span> Offline
{/if}
</div>
{#if stats.isLive}
<div class="stats-list">
<div class="stat-item">
<span class="stat-label">Viewers</span>
<span class="stat-value">{stats.connections}</span>
</div>
<div class="stat-item">
<span class="stat-label">Bitrate</span>
<span class="stat-value">{formatBitrate(stats.bitrate)}</span>
</div>
{#if stats.resolution !== 'N/A'}
<div class="stat-item">
<span class="stat-label">Resolution</span>
<span class="stat-value">{stats.resolution}</span>
</div>
{/if}
{#if stats.fps > 0}
<div class="stat-item">
<span class="stat-label">Frame Rate</span>
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
</div>
{/if}
{#if stats.codec}
<div class="stat-item">
<span class="stat-label">Codec</span>
<span class="stat-value">{stats.codec}</span>
</div>
{/if}
</div>
{:else}
<div class="offline-message">
<div class="offline-icon">📺</div>
<p>Stream is currently offline</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
{#if message}
<div class="message" style="position: fixed; top: 2rem; right: 2rem; padding: 1rem 2rem; background: var(--primary); color: white; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000;">
{message}
</div>
{/if}

View file

@ -0,0 +1,385 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated, isAdmin } from '$lib/stores/auth';
import { goto } from '$app/navigation';
let users = [];
let streams = [];
let loading = true;
let message = '';
let error = '';
let activeTab = 'users';
onMount(async () => {
await auth.init();
if (!$isAuthenticated) {
goto('/login');
return;
}
if (!$isAdmin) {
goto('/');
return;
}
await loadData();
});
async function loadData() {
loading = true;
await Promise.all([loadUsers(), loadStreams()]);
loading = false;
}
async function loadUsers() {
try {
const response = await fetch('/api/admin/users', {
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
const data = await response.json();
users = data.users;
} else {
error = 'Failed to load users';
}
} catch (e) {
error = 'Error loading users';
console.error(e);
}
}
async function loadStreams() {
try {
const response = await fetch('/api/admin/streams', {
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
const data = await response.json();
streams = data.streams || [];
} else {
error = 'Failed to load streams';
}
} catch (e) {
error = 'Error loading streams';
console.error(e);
}
}
async function promoteToStreamer(userId) {
try {
const response = await fetch(`/api/admin/users/${userId}/promote`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
message = 'User promoted to streamer';
await loadUsers();
} else {
error = 'Failed to promote user';
}
} catch (e) {
error = 'Error promoting user';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
async function demoteFromStreamer(userId) {
if (!confirm('Remove streamer privileges from this user? Their realms will remain but they cannot create new ones.')) {
return;
}
try {
const response = await fetch(`/api/admin/users/${userId}/demote`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
message = 'User demoted from streamer';
await loadUsers();
} else {
error = 'Failed to demote user';
}
} catch (e) {
error = 'Error demoting user';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
async function disconnectStream(streamKey) {
if (!confirm(`Disconnect stream ${streamKey}?`)) return;
try {
const response = await fetch(`/api/admin/streams/${streamKey}/disconnect`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
message = 'Stream disconnected';
await loadStreams();
} else {
error = 'Failed to disconnect stream';
}
} catch (e) {
error = 'Error disconnecting stream';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString();
}
</script>
<style>
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: #111;
border: 1px solid var(--border);
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: var(--primary);
}
.stat-label {
color: var(--gray);
margin-top: 0.5rem;
}
.data-table {
width: 100%;
overflow-x: auto;
}
.data-table table {
width: 100%;
min-width: 800px;
}
.stream-key-cell {
font-family: monospace;
font-size: 0.9rem;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.action-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
}
.role-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
</style>
<div class="container">
<div class="admin-header">
<h1>Admin Dashboard</h1>
<button on:click={loadData} disabled={loading}>
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
{#if message}
<div class="success" style="margin-bottom: 1rem;">{message}</div>
{/if}
{#if error}
<div class="error" style="margin-bottom: 1rem;">{error}</div>
{/if}
<div class="stats-cards">
<div class="stat-card">
<div class="stat-value">{users.length}</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-value">{users.filter(u => u.isAdmin).length}</div>
<div class="stat-label">Admins</div>
</div>
<div class="stat-card">
<div class="stat-value">{users.filter(u => u.isStreamer).length}</div>
<div class="stat-label">Streamers</div>
</div>
<div class="stat-card">
<div class="stat-value">{streams.length}</div>
<div class="stat-label">Active Streams</div>
</div>
</div>
<div class="tab-container">
<div class="tabs">
<button
class="tab"
class:active={activeTab === 'users'}
on:click={() => activeTab = 'users'}
>
Users
</button>
<button
class="tab"
class:active={activeTab === 'streams'}
on:click={() => activeTab = 'streams'}
>
Active Streams
</button>
</div>
</div>
{#if loading}
<p>Loading...</p>
{:else if activeTab === 'users'}
<div class="card">
<div class="data-table">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Roles</th>
<th>Realms</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>{user.id}</td>
<td>
<a href="/profile/{user.username}" style="color: var(--primary);">
{user.username}
</a>
</td>
<td>
<div class="role-badges">
{#if user.isAdmin}
<span class="badge badge-admin">Admin</span>
{/if}
{#if user.isStreamer}
<span class="badge" style="background: #28a745;">Streamer</span>
{/if}
{#if !user.isAdmin && !user.isStreamer}
<span class="badge" style="background: #6c757d;">User</span>
{/if}
</div>
</td>
<td>{user.realmCount || 0}</td>
<td>{formatDate(user.createdAt)}</td>
<td>
<div class="actions">
<a href="/profile/{user.username}" class="btn btn-secondary action-btn">
View
</a>
{#if !user.isAdmin}
{#if !user.isStreamer}
<button
class="btn action-btn"
style="background: #28a745;"
on:click={() => promoteToStreamer(user.id)}
>
Make Streamer
</button>
{:else}
<button
class="btn btn-danger action-btn"
on:click={() => demoteFromStreamer(user.id)}
>
Remove Streamer
</button>
{/if}
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else if activeTab === 'streams'}
<div class="card">
{#if streams.length > 0}
<div class="data-table">
<table class="table">
<thead>
<tr>
<th>Realm</th>
<th>Streamer</th>
<th>Stream Key</th>
<th>Viewers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each streams as stream}
<tr>
<td>
<a href="/{stream.name}/live" style="color: var(--primary);">
{stream.name}
</a>
</td>
<td>{stream.username}</td>
<td class="stream-key-cell" title={stream.streamKey}>
{stream.streamKey}
</td>
<td>{stream.viewerCount}</td>
<td>
<button
class="btn btn-danger action-btn"
on:click={() => disconnectStream(stream.streamKey)}
>
Disconnect
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p style="color: var(--gray);">No active streams</p>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,534 @@
<script>
import { onMount } from 'svelte';
import { auth, isAuthenticated } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import * as pgp from '$lib/pgp';
import '../../app.css';
let mode = 'login';
let username = '';
let password = '';
let confirmPassword = '';
let error = '';
let loading = false;
let pgpLoading = false;
// PGP login
let pgpChallenge = '';
let pgpPublicKey = '';
let pgpSignature = '';
// For displaying generated keys
let showGeneratedKeys = false;
let generatedPrivateKey = '';
let generatedPublicKey = '';
// Show PGP command example
let showPgpExample = false;
onMount(async () => {
await auth.init();
if ($isAuthenticated) {
goto('/');
}
});
function validatePassword(pass) {
if (pass.length < 8) {
return 'Password must be at least 8 characters';
}
if (!/[0-9]/.test(pass)) {
return 'Password must contain at least one number';
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(pass)) {
return 'Password must contain at least one symbol';
}
return '';
}
async function handleLogin() {
error = '';
loading = true;
const result = await auth.login({ username, password });
if (!result.success) {
error = result.error;
// If it's a PGP-only error, automatically switch to PGP login
if (error && error.includes('PGP-only login enabled')) {
// Clear the error and initiate PGP login
error = '';
loading = false;
await initiatePgpLogin();
return;
}
}
loading = false;
}
async function handleRegister() {
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
const passwordError = validatePassword(password);
if (passwordError) {
error = passwordError;
return;
}
loading = true;
try {
// Generate PGP key pair
const keyPair = await pgp.generateKeyPair(username);
const result = await auth.register({
username,
password,
publicKey: keyPair.publicKey,
fingerprint: keyPair.fingerprint
});
if (result.success) {
// Store private key locally
pgp.storePrivateKey(keyPair.privateKey);
// Show keys
generatedPrivateKey = keyPair.privateKey;
generatedPublicKey = keyPair.publicKey;
showGeneratedKeys = true;
} else {
error = result.error;
}
} catch (e) {
error = 'Failed to generate PGP keys';
console.error(e);
}
loading = false;
}
async function initiatePgpLogin() {
error = '';
pgpLoading = true;
try {
const response = await fetch('/api/auth/pgp-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const data = await response.json();
if (response.ok && data.success) {
pgpChallenge = data.challenge;
pgpPublicKey = data.publicKey;
// Clear the signature field
pgpSignature = '';
} else {
error = data.error || 'User not found or PGP not enabled';
}
} catch (e) {
error = 'Failed to initiate PGP login';
console.error(e);
}
pgpLoading = false;
}
async function handlePgpLogin() {
error = '';
loading = true;
try {
if (!pgpSignature) {
error = 'Please provide the signed message';
loading = false;
return;
}
const result = await auth.loginWithPgp(username, pgpSignature, pgpChallenge);
if (!result.success) {
error = result.error;
}
} catch (e) {
error = 'Failed to verify signature';
console.error(e);
}
loading = false;
}
function resetPgpLogin() {
pgpChallenge = '';
pgpPublicKey = '';
pgpSignature = '';
error = '';
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
alert('Copied to clipboard!');
}
function downloadKey(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function proceedAfterKeys() {
// Auto-login after registration
loading = true;
const result = await auth.login({ username, password });
if (result.success) {
goto('/');
} else {
showGeneratedKeys = false;
mode = 'login';
error = 'Registration successful. Please login.';
}
loading = false;
}
</script>
<div class="auth-container">
{#if showGeneratedKeys}
<h1>Your PGP Keys</h1>
<p style="color: var(--error); margin-bottom: 1rem;">
<strong>Important:</strong> Save your private key securely. You will need it to login with PGP.
</p>
<div class="form-group">
<label>Public Key</label>
<textarea
readonly
rows="10"
value={generatedPublicKey}
style="font-family: monospace; font-size: 0.8rem;"
/>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="button" on:click={() => copyToClipboard(generatedPublicKey)}>
Copy
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPublicKey, `${username}-public-key.asc`)}>
Download
</button>
</div>
</div>
<div class="form-group">
<label>Private Key</label>
<textarea
readonly
rows="10"
value={generatedPrivateKey}
style="font-family: monospace; font-size: 0.8rem;"
/>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="button" on:click={() => copyToClipboard(generatedPrivateKey)}>
Copy
</button>
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${username}-private-key.asc`)}>
Download
</button>
</div>
</div>
<button class="btn-block" on:click={proceedAfterKeys} disabled={loading}>
{loading ? 'Logging in...' : 'Continue'}
</button>
{:else}
<h1>{mode === 'login' ? 'Login' : 'Register'}</h1>
{#if mode === 'login' && !pgpChallenge}
<form on:submit|preventDefault={handleLogin}>
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
bind:value={username}
required
disabled={loading}
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
bind:value={password}
required
disabled={loading}
/>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<button type="submit" class="btn-block" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
<div style="margin: 1rem 0; text-align: center; color: var(--gray);">
OR
</div>
<button
type="button"
class="btn btn-secondary btn-block"
on:click={initiatePgpLogin}
disabled={loading || pgpLoading || !username}
>
{pgpLoading ? 'Loading...' : 'Login with PGP'}
</button>
</form>
{:else if mode === 'login' && pgpChallenge}
<form on:submit|preventDefault={handlePgpLogin}>
<h3 style="margin-bottom: 1rem;">PGP Authentication</h3>
<div class="instruction-box">
<p><strong>Step 1:</strong> Copy this challenge text:</p>
<div class="pgp-key" style="margin: 0.5rem 0;">
{pgpChallenge}
</div>
<button
type="button"
class="btn btn-secondary"
style="margin-bottom: 1rem;"
on:click={() => copyToClipboard(pgpChallenge)}
>
Copy Challenge
</button>
<p><strong>Step 2:</strong> Sign it with your private key using GPG or another PGP tool</p>
<p style="color: var(--gray); font-size: 0.85rem; margin-top: 0.5rem;">
Note: For security, we never handle your private keys. All signing must be done on your device.
</p>
</div>
<button
type="button"
class="btn-link"
style="margin-bottom: 1rem; font-size: 0.9rem;"
on:click={() => showPgpExample = !showPgpExample}
>
{showPgpExample ? 'Hide' : 'Show'} how to sign
</button>
{#if showPgpExample}
<div class="example-section">
<h4>Using GPG command line:</h4>
<pre class="command-example">
# Save the challenge to a file
echo "{pgpChallenge}" > challenge.txt
# Sign with your private key
gpg --armor --detach-sign challenge.txt
# This creates challenge.txt.asc with the signature
# Copy the entire contents including BEGIN/END lines</pre>
<h4>Using Kleopatra (Windows):</h4>
<ol style="font-size: 0.9rem; margin: 0.5rem 0;">
<li>Save the challenge text to a file</li>
<li>Right-click the file → Sign</li>
<li>Select your key and choose "Create detached signature"</li>
<li>Open the .asc file and copy its contents</li>
</ol>
<h4>Using GPG Suite (Mac):</h4>
<ol style="font-size: 0.9rem; margin: 0.5rem 0;">
<li>Save the challenge text to a file</li>
<li>Right-click the file → Services → OpenPGP: Sign File</li>
<li>Choose "Create Detached Signature"</li>
<li>Open the .sig file and copy its contents</li>
</ol>
</div>
{/if}
<div style="margin: 1.5rem 0; text-align: center; color: var(--gray);">
<strong>Step 3:</strong> Paste your signature below
</div>
<div class="form-group">
<label for="signature">Signed Message</label>
<textarea
id="signature"
bind:value={pgpSignature}
rows="10"
required
disabled={loading}
placeholder="-----BEGIN PGP SIGNATURE-----
Version: GnuPG v2
iQEcBAABCAAGBQJe...
...
-----END PGP SIGNATURE-----"
/>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<button type="submit" class="btn-block" disabled={loading || !pgpSignature}>
{loading ? 'Verifying...' : 'Verify and Login'}
</button>
<button
type="button"
class="btn btn-secondary btn-block"
style="margin-top: 1rem;"
on:click={resetPgpLogin}
disabled={loading}
>
Back
</button>
</form>
{:else}
<form on:submit|preventDefault={handleRegister}>
<div class="form-group">
<label for="reg-username">Username</label>
<input
type="text"
id="reg-username"
bind:value={username}
required
disabled={loading}
pattern="[a-zA-Z0-9_]+"
title="Letters, numbers, and underscores only"
/>
</div>
<div class="form-group">
<label for="reg-password">Password</label>
<input
type="password"
id="reg-password"
bind:value={password}
required
disabled={loading}
/>
<small style="color: var(--gray);">
Must be 8+ characters with at least one number and symbol
</small>
</div>
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<input
type="password"
id="confirm-password"
bind:value={confirmPassword}
required
disabled={loading}
/>
</div>
{#if error}
<div class="error">{error}</div>
{/if}
<button type="submit" class="btn-block" disabled={loading}>
{loading ? 'Creating account...' : 'Register'}
</button>
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--gray);">
A PGP key pair will be generated for your account. You'll be able to save both keys after registration.
</p>
</form>
{/if}
<div style="margin-top: 2rem; text-align: center;">
{#if mode === 'login'}
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; }}>
Need an account? Register
</button>
{:else}
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; }}>
Already have an account? Login
</button>
{/if}
</div>
{/if}
</div>
<style>
.btn-link {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
text-decoration: underline;
padding: 0;
font-size: inherit;
}
.btn-link:hover {
opacity: 0.8;
}
.instruction-box {
background: rgba(86, 29, 94, 0.05);
border: 1px solid rgba(86, 29, 94, 0.2);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.instruction-box p {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
}
.example-section {
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.example-section h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--primary);
}
.command-example {
background: rgba(0, 0, 0, 0.5);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1rem;
font-family: monospace;
font-size: 0.8rem;
overflow-x: auto;
white-space: pre;
margin: 0.5rem 0 1rem 0;
}
.example-section ol {
padding-left: 1.5rem;
}
.example-section ol li {
margin-bottom: 0.25rem;
}
</style>

View file

@ -0,0 +1,504 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { auth, isAuthenticated, isStreamer } from '$lib/stores/auth';
import { goto } from '$app/navigation';
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
let realms = [];
let loading = true;
let error = '';
let message = '';
let showCreateModal = false;
let statsInterval;
// Create form
let newRealmName = '';
onMount(async () => {
await auth.init();
if (!$isAuthenticated) {
goto('/login');
return;
}
if (!$isStreamer) {
goto('/');
return;
}
await loadRealms();
// Start polling for stats
statsInterval = setInterval(async () => {
for (const realm of realms) {
if (realm.isLive) {
await updateRealmStats(realm.id);
}
}
}, 2000);
// Connect WebSocket for real-time updates
connectWebSocket((data) => {
if (data.type === 'stats_update') {
const realm = realms.find(r => r.streamKey === data.stream_key);
if (realm && data.stats) {
realm.isLive = data.stats.is_live;
realm.viewerCount = data.stats.connections || 0;
realm.stats = data.stats;
realms = realms;
}
}
});
});
onDestroy(() => {
if (statsInterval) {
clearInterval(statsInterval);
}
disconnectWebSocket();
});
async function loadRealms() {
loading = true;
try {
const response = await fetch('/api/realms', {
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
const data = await response.json();
realms = data.realms;
// Load stats for each realm
for (const realm of realms) {
await updateRealmStats(realm.id);
}
} else {
error = 'Failed to load realms';
}
} catch (e) {
error = 'Error loading realms';
console.error(e);
} finally {
loading = false;
}
}
async function updateRealmStats(realmId) {
try {
const response = await fetch(`/api/realms/${realmId}/stats`, {
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
const data = await response.json();
const realm = realms.find(r => r.id === realmId);
if (realm && data.success && data.stats) {
realm.isLive = data.stats.is_live;
realm.viewerCount = data.stats.connections || 0;
realm.stats = data.stats;
realms = realms;
}
}
} catch (e) {
console.error('Failed to fetch stats:', e);
}
}
async function createRealm() {
error = '';
if (!validateRealmName(newRealmName)) {
error = 'Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only';
return;
}
try {
const response = await fetch('/api/realms', {
method: 'POST',
headers: {
'Authorization': `Bearer ${$auth.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: newRealmName })
});
const data = await response.json();
if (response.ok && data.success) {
message = 'Realm created successfully';
showCreateModal = false;
newRealmName = '';
await loadRealms();
} else {
error = data.error || 'Failed to create realm';
}
} catch (e) {
error = 'Error creating realm';
console.error(e);
}
setTimeout(() => { message = ''; }, 3000);
}
async function deleteRealm(realm) {
if (!confirm(`Delete realm "${realm.name}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/realms/${realm.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
if (response.ok) {
message = 'Realm deleted successfully';
await loadRealms();
} else {
error = 'Failed to delete realm';
}
} catch (e) {
error = 'Error deleting realm';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
async function regenerateKey(realm) {
if (!confirm('Regenerate stream key? This will disconnect any active streams.')) {
return;
}
try {
const response = await fetch(`/api/realms/${realm.id}/regenerate-key`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${$auth.token}` }
});
const data = await response.json();
if (response.ok && data.success) {
message = 'Stream key regenerated successfully';
await loadRealms();
} else {
error = 'Failed to regenerate stream key';
}
} catch (e) {
error = 'Error regenerating stream key';
console.error(e);
}
setTimeout(() => { message = ''; error = ''; }, 3000);
}
function validateRealmName(name) {
return /^[a-z0-9-]{3,30}$/.test(name);
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
message = 'Copied to clipboard!';
setTimeout(() => message = '', 2000);
}
function formatBitrate(bitrate) {
if (bitrate > 1000000) {
return (bitrate / 1000000).toFixed(2) + ' Mbps';
} else if (bitrate > 1000) {
return (bitrate / 1000).toFixed(0) + ' Kbps';
} else {
return bitrate + ' bps';
}
}
</script>
<style>
.realms-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.realms-grid {
display: grid;
gap: 2rem;
}
.realm-card {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
}
.realm-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 1.5rem;
}
.realm-title h3 {
margin: 0 0 0.5rem 0;
}
.realm-url {
color: var(--gray);
font-size: 0.9rem;
}
.realm-actions {
display: flex;
gap: 0.5rem;
}
.stream-info {
background: rgba(86, 29, 94, 0.1);
border: 1px solid rgba(86, 29, 94, 0.3);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.stream-info-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.stream-info-row:last-child {
margin-bottom: 0;
}
.stream-info-label {
font-weight: 600;
min-width: 100px;
}
.stream-key {
font-family: monospace;
background: rgba(0, 0, 0, 0.3);
padding: 0.25rem 0.5rem;
border-radius: 4px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.status-row {
display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 1rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
font-size: 0.9rem;
}
.status-indicator.active {
background: rgba(40, 167, 69, 0.2);
color: var(--success);
}
.status-indicator.inactive {
background: rgba(220, 53, 69, 0.2);
color: var(--error);
}
.stats-mini {
display: flex;
gap: 2rem;
font-size: 0.9rem;
}
.stat-mini {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stat-mini-label {
color: var(--gray);
}
.form-hint {
font-size: 0.85rem;
color: var(--gray);
margin-top: 0.25rem;
}
.no-realms {
text-align: center;
padding: 4rem 0;
color: var(--gray);
}
.no-realms-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
</style>
<div class="container">
<div class="realms-header">
<h1>My Realms</h1>
<button
class="btn"
on:click={() => showCreateModal = true}
disabled={realms.length >= 5}
>
Create Realm
</button>
</div>
{#if message}
<div class="success" style="margin-bottom: 1rem;">{message}</div>
{/if}
{#if error && !showCreateModal}
<div class="error" style="margin-bottom: 1rem;">{error}</div>
{/if}
{#if loading}
<p>Loading realms...</p>
{:else if realms.length === 0}
<div class="no-realms">
<div class="no-realms-icon">🏰</div>
<h2>No realms yet</h2>
<p>Create your first realm to start streaming!</p>
<button
class="btn"
style="margin-top: 1rem;"
on:click={() => showCreateModal = true}
>
Create Your First Realm
</button>
</div>
{:else}
<div class="realms-grid">
{#each realms as realm}
<div class="realm-card">
<div class="realm-header">
<div class="realm-title">
<h3>{realm.name}</h3>
<p class="realm-url">/{realm.name}/live</p>
</div>
<div class="realm-actions">
<button
class="btn btn-danger"
style="padding: 0.5rem 1rem;"
on:click={() => deleteRealm(realm)}
>
Delete
</button>
</div>
</div>
<div class="status-row">
<div class="status-indicator" class:active={realm.isLive} class:inactive={!realm.isLive}>
{#if realm.isLive}
<span></span> Live
{:else}
<span></span> Offline
{/if}
</div>
{#if realm.isLive && realm.stats}
<div class="stats-mini">
<div class="stat-mini">
<span class="stat-mini-label">Viewers:</span>
<span>{realm.viewerCount}</span>
</div>
<div class="stat-mini">
<span class="stat-mini-label">Bitrate:</span>
<span>{formatBitrate(realm.stats.bitrate)}</span>
</div>
{#if realm.stats.resolution !== 'N/A'}
<div class="stat-mini">
<span class="stat-mini-label">Resolution:</span>
<span>{realm.stats.resolution}</span>
</div>
{/if}
</div>
{/if}
</div>
<div class="stream-info">
<div class="stream-info-row">
<span class="stream-info-label">Stream Key:</span>
<span class="stream-key">{realm.streamKey}</span>
<button on:click={() => copyToClipboard(realm.streamKey)}>Copy</button>
</div>
<div class="stream-info-row">
<span class="stream-info-label">RTMP URL:</span>
<span class="stream-key">rtmp://localhost:1935/app/{realm.streamKey}</span>
<button on:click={() => copyToClipboard(`rtmp://localhost:1935/app/${realm.streamKey}`)}>Copy</button>
</div>
<div class="stream-info-row">
<span class="stream-info-label">SRT URL:</span>
<span class="stream-key">srt://localhost:9999?streamid={encodeURIComponent(`srt://localhost:9999/app/${realm.streamKey}`)}</span>
<button on:click={() => copyToClipboard(`srt://localhost:9999?streamid=${encodeURIComponent(`srt://localhost:9999/app/${realm.streamKey}`)}`)}>Copy</button>
</div>
</div>
<button
class="btn btn-danger"
style="width: 100%;"
on:click={() => regenerateKey(realm)}
>
Regenerate Stream Key
</button>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Realm Modal -->
{#if showCreateModal}
<div class="modal" on:click={() => showCreateModal = false}>
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<h2>Create New Realm</h2>
<button class="modal-close" on:click={() => showCreateModal = false}>×</button>
</div>
{#if error}
<div class="error" style="margin-bottom: 1rem;">{error}</div>
{/if}
<form on:submit|preventDefault={createRealm}>
<div class="form-group">
<label for="realm-name">Realm Name</label>
<input
type="text"
id="realm-name"
bind:value={newRealmName}
required
pattern="[a-z0-9-]{3,30}"
placeholder="my-awesome-realm"
/>
<p class="form-hint">
3-30 characters, lowercase letters, numbers, and hyphens only.
This will be your realm's URL: /{newRealmName || 'realm-name'}/live
</p>
</div>
<button type="submit" class="btn btn-block">Create Realm</button>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,448 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { auth, isAuthenticated } from '$lib/stores/auth';
let profile = null;
let pgpKeys = [];
let loading = true;
let error = '';
let isOwnProfile = false;
let activeTab = 'bio';
let expandedKeys = {}; // Track which keys are expanded
onMount(async () => {
// No authentication required - profile is public
const username = $page.params.username;
// Check if viewing own profile (only if authenticated)
if ($isAuthenticated && $auth.user) {
isOwnProfile = $auth.user.username === username;
}
await loadProfile(username);
await loadPgpKeys(username);
loading = false;
});
async function loadProfile(username) {
try {
// Public endpoint - no auth header needed
const response = await fetch(`/api/users/${username}`);
if (response.ok) {
const data = await response.json();
profile = data.profile;
// Check if the profile has pgpOnlyEnabledAt set
if (profile && profile.pgpOnlyEnabledAt) {
console.log('Profile has PGP-only enabled at:', profile.pgpOnlyEnabledAt);
}
} else if (response.status === 404) {
error = 'User not found';
} else {
error = 'Failed to load profile';
}
} catch (e) {
error = 'Error loading profile';
console.error(e);
}
}
async function loadPgpKeys(username) {
try {
// Public endpoint - no auth header needed
const response = await fetch(`/api/users/${username}/pgp-keys`);
if (response.ok) {
const data = await response.json();
pgpKeys = data.keys;
// Initialize all keys as collapsed
pgpKeys.forEach(key => {
expandedKeys[key.fingerprint] = false;
});
}
} catch (e) {
console.error('Failed to load PGP keys:', e);
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text);
alert('Copied to clipboard!');
}
function toggleKey(fingerprint) {
expandedKeys[fingerprint] = !expandedKeys[fingerprint];
}
function formatDateTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
timeZoneName: 'short'
});
}
</script>
<style>
.profile-header {
display: flex;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
}
.profile-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--gray);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 600;
color: var(--white);
overflow: hidden;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-info h1 {
margin-bottom: 0.5rem;
}
.member-since {
color: var(--gray);
font-size: 0.9rem;
}
.pgp-only-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(40, 167, 69, 0.2);
color: var(--success);
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
margin-top: 0.5rem;
}
.tab-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 2rem;
}
.tab-button {
padding: 1rem 2rem;
background: none;
border: none;
color: var(--gray);
cursor: pointer;
font-size: 1rem;
font-weight: 500;
position: relative;
transition: color 0.2s;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
}
.tab-button:hover {
color: var(--white);
}
.tab-button.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.bio-section {
padding: 1.5rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
}
.bio-section h3 {
margin-bottom: 1rem;
}
.no-bio {
color: var(--gray);
font-style: italic;
}
.pgp-section {
padding: 1.5rem;
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
}
.pgp-status-info {
background: rgba(86, 29, 94, 0.1);
border: 1px solid rgba(86, 29, 94, 0.3);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.pgp-status-info.pgp-only {
background: rgba(40, 167, 69, 0.1);
border-color: rgba(40, 167, 69, 0.3);
}
.pgp-status-info p {
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.pgp-status-info p:last-child {
margin-bottom: 0;
}
.pgp-status-info .icon {
font-size: 1.2rem;
}
.pgp-key-item {
margin-bottom: 1rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.pgp-key-item:last-child {
margin-bottom: 0;
}
.pgp-key-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.pgp-key-header:hover {
background: rgba(86, 29, 94, 0.1);
margin: -0.5rem;
padding: 0.5rem;
border-radius: 4px;
}
.pgp-key-info {
flex: 1;
}
.fingerprint-display {
font-family: monospace;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.key-date {
color: var(--gray);
font-size: 0.85rem;
}
.expand-icon {
color: var(--gray);
transition: transform 0.2s;
font-size: 1.2rem;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
.pgp-key-content {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.pgp-key {
font-family: monospace;
font-size: 0.8rem;
background: rgba(255, 255, 255, 0.05);
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
.copy-button {
padding: 0.25rem 0.75rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
margin-top: 0.5rem;
}
.copy-button:hover {
opacity: 0.9;
}
.no-keys {
color: var(--gray);
text-align: center;
padding: 2rem;
}
.pgp-enabled-date {
font-size: 0.85rem;
color: var(--gray);
font-style: italic;
}
</style>
<div class="container">
{#if loading}
<p>Loading...</p>
{:else if error}
<div class="error">{error}</div>
{:else if profile}
<div class="profile-header">
<div class="profile-avatar">
{#if profile.avatarUrl}
<img src={profile.avatarUrl} alt="{profile.username}" />
{:else}
{profile.username.charAt(0).toUpperCase()}
{/if}
</div>
<div class="profile-info">
<h1>{profile.username}</h1>
<p class="member-since">
Member since {new Date(profile.createdAt).toLocaleDateString()}
</p>
{#if profile.isPgpOnly}
<div class="pgp-only-badge">
<span>🔒</span>
<span>PGP-Only Authentication</span>
</div>
{/if}
</div>
</div>
<div class="tab-nav">
<button
class="tab-button"
class:active={activeTab === 'bio'}
on:click={() => activeTab = 'bio'}
>
Bio
</button>
<button
class="tab-button"
class:active={activeTab === 'pgp'}
on:click={() => activeTab = 'pgp'}
>
PGP Keys ({pgpKeys.length})
</button>
</div>
{#if activeTab === 'bio'}
<div class="bio-section">
<h3>About</h3>
{#if profile.bio}
<p>{profile.bio}</p>
{:else}
<p class="no-bio">No bio yet</p>
{/if}
</div>
{:else if activeTab === 'pgp'}
<div class="pgp-section">
<h3>PGP Keys</h3>
{#if profile.isPgpOnly}
<div class="pgp-status-info pgp-only">
<p>
<span class="icon"></span>
<strong>PGP-Only Authentication Enabled</strong>
</p>
{#if profile.pgpOnlyEnabledAt}
<p class="pgp-enabled-date">
Enabled: {formatDateTime(profile.pgpOnlyEnabledAt)}
</p>
{/if}
<p style="font-size: 0.85rem;">
This user requires PGP signature verification to login.
</p>
</div>
{:else}
<div class="pgp-status-info">
<p>
<span class="icon">🔑</span>
Standard authentication (password + optional PGP)
</p>
</div>
{/if}
{#if pgpKeys.length > 0}
{#each pgpKeys as key}
<div class="pgp-key-item">
<div
class="pgp-key-header"
on:click={() => toggleKey(key.fingerprint)}
on:keypress={(e) => e.key === 'Enter' && toggleKey(key.fingerprint)}
role="button"
tabindex="0"
>
<div class="pgp-key-info">
<div class="fingerprint-display">{key.fingerprint}</div>
<div class="key-date">Added {new Date(key.createdAt).toLocaleDateString()}</div>
</div>
<span class="expand-icon" class:expanded={expandedKeys[key.fingerprint]}>
</span>
</div>
{#if expandedKeys[key.fingerprint]}
<div class="pgp-key-content">
<div class="pgp-key">
{key.publicKey}
</div>
<button
class="copy-button"
on:click={() => copyToClipboard(key.publicKey)}
>
Copy Public Key
</button>
</div>
{/if}
</div>
{/each}
{:else}
<div class="no-keys">
<p>No PGP keys added</p>
</div>
{/if}
</div>
{/if}
{/if}
</div>

File diff suppressed because it is too large Load diff

56
frontend/svelte.config.js Normal file
View file

@ -0,0 +1,56 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: false
}),
// Security improvements
csp: {
mode: 'auto',
directives: {
'default-src': ["'self'"],
'script-src': ["'self'", "'unsafe-inline'"],
'style-src': ["'self'", "'unsafe-inline'", 'https://cdnjs.cloudflare.com'],
'img-src': ["'self'", 'data:', 'blob:'],
'font-src': ["'self'", 'https://cdnjs.cloudflare.com'],
'connect-src': ["'self'", 'ws://localhost', 'wss://localhost', 'http://localhost:*'],
'media-src': ["'self'", 'blob:', 'http://localhost:*'],
'object-src': ["'none'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
'base-uri': ["'self'"]
}
},
// Enable CSRF protection (default is true)
csrf: {
checkOrigin: true
},
// Environment variable configuration
env: {
publicPrefix: 'VITE_' // This is already correct
},
// Ensure default appDir is used (don't override)
// appDir: '_app' // This is the default, no need to set
// Performance: prerender error pages
prerender: {
entries: ['/'],
handleHttpError: ({ path, referrer, message }) => {
// Log errors but don't fail build
console.warn(`${path} (${referrer}) - ${message}`);
}
}
}
};
export default config;

18
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/**/*", ".svelte-kit/ambient.d.ts"],
"exclude": ["node_modules/*", ".svelte-kit/*"]
}

10
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 3000,
host: true
}
});