diff --git a/.gitignore b/.gitignore index da0e172..4d9342c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ Thumbs.db # Claude Code .claude/ +# Runtime config — managed outside of git +users.txt + # Env .env .env.* diff --git a/INSTALL.md b/INSTALL.md index 178270b..73d3af5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -7,7 +7,11 @@ build/ ← compiled SvelteKit server (self-contained, no npm scripts/ subtitle_to_markdown.py ← standalone subtitle converter CLI create_zip.py ← ZIP creation helper (used internally by the server) + add_user.py ← user management CLI start.sh ← startup script +users.txt ← user database (hashed passwords) +ads-left.html ← ad content for the left column (edit freely) +ads-right.html ← ad content for the right column (edit freely) README.md INSTALL.md ``` @@ -33,6 +37,25 @@ tar -xzf yt-dlf.tar.gz cd yt-dlf ``` +## User management + +Unauthenticated visitors see the app with ad columns. Logged-in users see no ads. +The login page is at `/login` (not linked from the main page). + +Add or update a user: + +```bash +python3 scripts/add_user.py +``` + +Users are stored in `users.txt` (one entry per line, scrypt-hashed passwords). +Sessions are held in memory — they reset when the server restarts. + +## Ad content + +Edit `ads-left.html` and `ads-right.html` to replace the placeholder content with real ads. +The server caches these files at startup — restart the server after editing them. + ## Running ```bash @@ -54,6 +77,8 @@ Edit `start.sh` to configure the variables below, or pass them directly on the c | `YTDLP_PATH` | `yt-dlp` | Full path to yt-dlp binary if not on PATH | | `FFMPEG_PATH` | `ffmpeg` | Full path to ffmpeg binary if not on PATH | | `DOWNLOAD_DIR` | `~/YouTube` | Directory where downloaded files are stored on the server | +| `USERS_FILE` | `users.txt` next to `start.sh` | Path to the user database file | +| `ADS_DIR` | same directory as `start.sh` | Directory containing `ads-left.html` and `ads-right.html` | ### ZIP & Send mode diff --git a/ads-left.html b/ads-left.html new file mode 100644 index 0000000..5c85c68 --- /dev/null +++ b/ads-left.html @@ -0,0 +1,12 @@ +
+
+
📢
+
Advertisement
+
300 × 120
+
+
+
📢
+
Advertisement
+
300 × 250
+
+
diff --git a/ads-right.html b/ads-right.html new file mode 100644 index 0000000..5c85c68 --- /dev/null +++ b/ads-right.html @@ -0,0 +1,12 @@ +
+
+
📢
+
Advertisement
+
300 × 120
+
+
+
📢
+
Advertisement
+
300 × 250
+
+
diff --git a/scripts/add_user.py b/scripts/add_user.py new file mode 100755 index 0000000..9c37e26 --- /dev/null +++ b/scripts/add_user.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Add or update a user in the yt-dlf user database. + +Usage: python3 scripts/add_user.py [users_file] + +Default users_file: users.txt (relative to current directory) +""" +import sys +import os +import hashlib +from pathlib import Path + +if len(sys.argv) < 3: + print(f'Usage: {sys.argv[0]} [users_file]', file=sys.stderr) + sys.exit(1) + +username = sys.argv[1] +password = sys.argv[2].encode() +users_file = Path(sys.argv[3]) if len(sys.argv) >= 4 else Path('users.txt') + +salt = os.urandom(16) +key = hashlib.scrypt(password, salt=salt, n=16384, r=8, p=1, dklen=32) +new_entry = f'{username}:{salt.hex()}:{key.hex()}\n' + +lines = users_file.read_text().splitlines(keepends=True) if users_file.exists() else [] + +updated = False +for i, line in enumerate(lines): + parts = line.strip().split(':') + if parts and parts[0] == username: + lines[i] = new_entry + updated = True + break + +if not updated: + if lines and not lines[-1].endswith('\n'): + lines.append('\n') + lines.append(new_entry) + +users_file.write_text(''.join(lines)) +print(f"{'Updated' if updated else 'Added'} user '{username}' in {users_file}") diff --git a/src/hooks.server.js b/src/hooks.server.js new file mode 100644 index 0000000..263688e --- /dev/null +++ b/src/hooks.server.js @@ -0,0 +1,10 @@ +import { getSession } from '$lib/server/auth.js'; + +export async function handle({ event, resolve }) { + const token = event.cookies.get('session'); + if (token) { + const user = getSession(token); + if (user) event.locals.user = user; + } + return resolve(event); +} diff --git a/src/lib/server/auth.js b/src/lib/server/auth.js new file mode 100644 index 0000000..c7fe004 --- /dev/null +++ b/src/lib/server/auth.js @@ -0,0 +1,54 @@ +import { scryptSync, timingSafeEqual, randomBytes } from 'crypto'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const USERS_FILE = process.env.USERS_FILE ?? join(process.cwd(), 'users.txt'); +const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 }; +const KEY_LEN = 32; + +// token → username +const sessions = new Map(); + +function loadUsers() { + try { + return Object.fromEntries( + readFileSync(USERS_FILE, 'utf-8') + .split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('#')) + .map(l => l.split(':')) + .filter(parts => parts.length === 3) + .map(([user, salt, hash]) => [user, { salt, hash }]) + ); + } catch { + return {}; + } +} + +export function verifyUser(username, password) { + const users = loadUsers(); + const entry = users[username]; + if (!entry) return false; + try { + const salt = Buffer.from(entry.salt, 'hex'); + const stored = Buffer.from(entry.hash, 'hex'); + const derived = scryptSync(password, salt, KEY_LEN, SCRYPT_PARAMS); + return timingSafeEqual(derived, stored); + } catch { + return false; + } +} + +export function createSession(username) { + const token = randomBytes(32).toString('hex'); + sessions.set(token, username); + return token; +} + +export function getSession(token) { + return sessions.get(token) ?? null; +} + +export function deleteSession(token) { + sessions.delete(token); +} diff --git a/src/routes/+layout.server.js b/src/routes/+layout.server.js new file mode 100644 index 0000000..175d33a --- /dev/null +++ b/src/routes/+layout.server.js @@ -0,0 +1,25 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const ADS_DIR = process.env.ADS_DIR ?? process.cwd(); + +function readAd(filename) { + try { + return readFileSync(join(ADS_DIR, filename), 'utf-8'); + } catch { + return ''; + } +} + +// Cache ads at module load time — restart server to pick up changes +const adsLeft = readAd('ads-left.html'); +const adsRight = readAd('ads-right.html'); + +export function load({ locals }) { + const user = locals.user ?? null; + return { + user, + adsLeft: user ? '' : adsLeft, + adsRight: user ? '' : adsRight, + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5c4f0f7..5091cc0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,78 @@ -{@render children()} +{#if data.user} +
+ Logged in as {data.user} + Logout +
+ {@render children()} +{:else} +
+ +
{@render children()}
+ +
+{/if} + + diff --git a/src/routes/login/+page.server.js b/src/routes/login/+page.server.js new file mode 100644 index 0000000..096457f --- /dev/null +++ b/src/routes/login/+page.server.js @@ -0,0 +1,29 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { verifyUser, createSession } from '$lib/server/auth.js'; + +export const actions = { + default: async ({ request, cookies }) => { + const data = await request.formData(); + const username = data.get('username')?.toString().trim() ?? ''; + const password = data.get('password')?.toString() ?? ''; + + if (!username || !password) { + return fail(400, { error: 'Please enter username and password.' }); + } + + const valid = verifyUser(username, password); + if (!valid) { + return fail(401, { error: 'Invalid username or password.' }); + } + + const token = createSession(username); + cookies.set('session', token, { + path: '/', + httpOnly: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7 + }); + + redirect(302, '/'); + } +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..4c8da24 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,115 @@ + + +
+
+

Sign in

+ + {#if form?.error} +

{form.error}

+ {/if} + +
+ + + +
+
+
+ + diff --git a/src/routes/logout/+server.js b/src/routes/logout/+server.js new file mode 100644 index 0000000..45e4e7d --- /dev/null +++ b/src/routes/logout/+server.js @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import { deleteSession } from '$lib/server/auth.js'; + +export function GET({ cookies }) { + const token = cookies.get('session'); + if (token) { + deleteSession(token); + cookies.delete('session', { path: '/' }); + } + redirect(302, '/'); +} diff --git a/start.sh b/start.sh index f526d98..51bb05f 100755 --- a/start.sh +++ b/start.sh @@ -24,6 +24,12 @@ # ZIP_EXTRA_DIR Optional directory whose files are added to every ZIP alongside # the downloaded files. Subdirectory structure within ZIP_EXTRA_DIR # is preserved. Only used when ZIP_AND_SEND=true. (default: none) +# +# USERS_FILE Path to the user database file. (default: users.txt next to start.sh) +# Manage users with: python3 scripts/add_user.py +# +# ADS_DIR Directory containing ads-left.html and ads-right.html. +# (default: same directory as start.sh) export PORT="${PORT:-3000}" export ORIGIN="${ORIGIN:-http://localhost:${PORT}}" @@ -34,5 +40,7 @@ export ORIGIN="${ORIGIN:-http://localhost:${PORT}}" # export ZIP_EXTRA_DIR="/path/to/extra/files" # export DELETE_AFTER_SEND="false" # export DELETE_DELAY="0" +# export USERS_FILE="$(dirname "$0")/users.txt" +# export ADS_DIR="$(dirname "$0")" exec node build/index.js