Add login, ad columns and user management

Unauthenticated visitors see the app with left/right ad columns (loaded
from ads-left.html and ads-right.html). Logged-in users see no ads and
get a logout link in the top bar.

Login is at /login (not linked). Users are managed in users.txt via
scripts/add_user.py (scrypt-hashed passwords). users.txt is gitignored.

New env vars: USERS_FILE, ADS_DIR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Stefan Waidele 2026-05-16 21:21:48 +02:00
parent 3544a89609
commit 6df26c12e6
13 changed files with 414 additions and 2 deletions

3
.gitignore vendored
View File

@ -15,6 +15,9 @@ Thumbs.db
# Claude Code
.claude/
# Runtime config — managed outside of git
users.txt
# Env
.env
.env.*

View File

@ -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 <username> <password>
```
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

12
ads-left.html Normal file
View File

@ -0,0 +1,12 @@
<div style="display:flex;flex-direction:column;gap:1rem;">
<div style="border:2px dashed #ccc;border-radius:8px;padding:1rem;text-align:center;color:#aaa;font-size:0.85rem;min-height:120px;display:flex;align-items:center;justify-content:center;flex-direction:column;">
<div style="font-size:1.5rem;margin-bottom:0.5rem;">📢</div>
<div>Advertisement</div>
<div style="font-size:0.75rem;margin-top:0.25rem;">300 × 120</div>
</div>
<div style="border:2px dashed #ccc;border-radius:8px;padding:1rem;text-align:center;color:#aaa;font-size:0.85rem;min-height:250px;display:flex;align-items:center;justify-content:center;flex-direction:column;">
<div style="font-size:1.5rem;margin-bottom:0.5rem;">📢</div>
<div>Advertisement</div>
<div style="font-size:0.75rem;margin-top:0.25rem;">300 × 250</div>
</div>
</div>

12
ads-right.html Normal file
View File

@ -0,0 +1,12 @@
<div style="display:flex;flex-direction:column;gap:1rem;">
<div style="border:2px dashed #ccc;border-radius:8px;padding:1rem;text-align:center;color:#aaa;font-size:0.85rem;min-height:120px;display:flex;align-items:center;justify-content:center;flex-direction:column;">
<div style="font-size:1.5rem;margin-bottom:0.5rem;">📢</div>
<div>Advertisement</div>
<div style="font-size:0.75rem;margin-top:0.25rem;">300 × 120</div>
</div>
<div style="border:2px dashed #ccc;border-radius:8px;padding:1rem;text-align:center;color:#aaa;font-size:0.85rem;min-height:250px;display:flex;align-items:center;justify-content:center;flex-direction:column;">
<div style="font-size:1.5rem;margin-bottom:0.5rem;">📢</div>
<div>Advertisement</div>
<div style="font-size:0.75rem;margin-top:0.25rem;">300 × 250</div>
</div>
</div>

41
scripts/add_user.py Executable file
View File

@ -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 <username> <password> [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]} <username> <password> [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}")

10
src/hooks.server.js Normal file
View File

@ -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);
}

54
src/lib/server/auth.js Normal file
View File

@ -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);
}

View File

@ -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,
};
}

View File

@ -1,11 +1,78 @@
<script>
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
let { children, data } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
{#if data.user}
<div class="topbar">
<span>Logged in as <strong>{data.user}</strong></span>
<a href="/logout">Logout</a>
</div>
{@render children()}
{:else}
<div class="page-grid">
<aside class="ad-col">{@html data.adsLeft}</aside>
<div class="content-col">{@render children()}</div>
<aside class="ad-col">{@html data.adsRight}</aside>
</div>
{/if}
<style>
:global(*, *::before, *::after) {
box-sizing: border-box;
}
.topbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
padding: 0.5rem 1.5rem;
background: #fff;
border-bottom: 1px solid #d0d0c8;
font-size: 0.85rem;
color: #555;
}
.topbar a {
color: #65d000;
text-decoration: none;
font-weight: 600;
}
.topbar a:hover {
text-decoration: underline;
}
.page-grid {
display: grid;
grid-template-columns: 220px 1fr 220px;
gap: 1.5rem;
max-width: 1280px;
margin: 0 auto;
padding: 1.5rem;
}
.ad-col {
padding-top: 3rem;
}
.content-col {
min-width: 0;
}
@media (max-width: 900px) {
.page-grid {
grid-template-columns: 1fr;
}
.ad-col {
display: none;
}
}
</style>

View File

@ -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, '/');
}
};

View File

@ -0,0 +1,115 @@
<script>
let { form } = $props();
</script>
<main>
<div class="card">
<h1>Sign in</h1>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form method="POST">
<label>
Username
<input type="text" name="username" autocomplete="username" required />
</label>
<label>
Password
<input type="password" name="password" autocomplete="current-password" required />
</label>
<button type="submit">Sign in</button>
</form>
</div>
</main>
<style>
:global(body) {
margin: 0;
background: #f5f5f0;
color: #1a1a1a;
font-family: system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
main {
width: 100%;
padding: 1.5rem;
}
.card {
max-width: 360px;
margin: 0 auto;
background: #fff;
border: 1px solid #d0d0c8;
border-radius: 12px;
padding: 2rem;
}
h1 {
font-size: 1.5rem;
font-weight: 800;
margin: 0 0 1.5rem;
letter-spacing: -0.02em;
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
color: #555;
}
input {
padding: 0.65rem 0.85rem;
border: 1px solid #d0d0c8;
border-radius: 6px;
font-size: 1rem;
outline: none;
transition: border-color 0.15s;
background: #fafaf8;
color: #1a1a1a;
}
input:focus {
border-color: #65d000;
}
button {
margin-top: 0.5rem;
padding: 0.7rem;
background: #65d000;
color: #1a1a1a;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
button:hover {
background: #54b800;
}
.error {
margin: 0 0 1rem;
padding: 0.6rem 0.85rem;
background: #fff0f0;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #b91c1c;
font-size: 0.875rem;
}
</style>

View File

@ -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, '/');
}

View File

@ -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 <username> <password>
#
# 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