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:
parent
3544a89609
commit
6df26c12e6
3
.gitignore
vendored
3
.gitignore
vendored
@ -15,6 +15,9 @@ Thumbs.db
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
# Runtime config — managed outside of git
|
||||
users.txt
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
25
INSTALL.md
25
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 <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
12
ads-left.html
Normal 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
12
ads-right.html
Normal 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
41
scripts/add_user.py
Executable 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
10
src/hooks.server.js
Normal 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
54
src/lib/server/auth.js
Normal 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);
|
||||
}
|
||||
25
src/routes/+layout.server.js
Normal file
25
src/routes/+layout.server.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
29
src/routes/login/+page.server.js
Normal file
29
src/routes/login/+page.server.js
Normal 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, '/');
|
||||
}
|
||||
};
|
||||
115
src/routes/login/+page.svelte
Normal file
115
src/routes/login/+page.svelte
Normal 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>
|
||||
11
src/routes/logout/+server.js
Normal file
11
src/routes/logout/+server.js
Normal 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, '/');
|
||||
}
|
||||
8
start.sh
8
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 <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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user