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 Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Runtime config — managed outside of git
|
||||||
|
users.txt
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
25
INSTALL.md
25
INSTALL.md
@ -7,7 +7,11 @@ build/ ← compiled SvelteKit server (self-contained, no npm
|
|||||||
scripts/
|
scripts/
|
||||||
subtitle_to_markdown.py ← standalone subtitle converter CLI
|
subtitle_to_markdown.py ← standalone subtitle converter CLI
|
||||||
create_zip.py ← ZIP creation helper (used internally by the server)
|
create_zip.py ← ZIP creation helper (used internally by the server)
|
||||||
|
add_user.py ← user management CLI
|
||||||
start.sh ← startup script
|
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
|
README.md
|
||||||
INSTALL.md
|
INSTALL.md
|
||||||
```
|
```
|
||||||
@ -33,6 +37,25 @@ tar -xzf yt-dlf.tar.gz
|
|||||||
cd yt-dlf
|
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
|
## Running
|
||||||
|
|
||||||
```bash
|
```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 |
|
| `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 |
|
| `FFMPEG_PATH` | `ffmpeg` | Full path to ffmpeg binary if not on PATH |
|
||||||
| `DOWNLOAD_DIR` | `~/YouTube` | Directory where downloaded files are stored on the server |
|
| `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
|
### 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>
|
<script>
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children, data } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</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
|
# ZIP_EXTRA_DIR Optional directory whose files are added to every ZIP alongside
|
||||||
# the downloaded files. Subdirectory structure within ZIP_EXTRA_DIR
|
# the downloaded files. Subdirectory structure within ZIP_EXTRA_DIR
|
||||||
# is preserved. Only used when ZIP_AND_SEND=true. (default: none)
|
# 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 PORT="${PORT:-3000}"
|
||||||
export ORIGIN="${ORIGIN:-http://localhost:${PORT}}"
|
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 ZIP_EXTRA_DIR="/path/to/extra/files"
|
||||||
# export DELETE_AFTER_SEND="false"
|
# export DELETE_AFTER_SEND="false"
|
||||||
# export DELETE_DELAY="0"
|
# export DELETE_DELAY="0"
|
||||||
|
# export USERS_FILE="$(dirname "$0")/users.txt"
|
||||||
|
# export ADS_DIR="$(dirname "$0")"
|
||||||
|
|
||||||
exec node build/index.js
|
exec node build/index.js
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user