Added auth support and a helper script, targeting frankenphp

This commit is contained in:
markmental 2026-02-02 14:05:21 -05:00
commit 0783e08fe9
8 changed files with 655 additions and 4 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

50
auth.php Normal file
View file

@ -0,0 +1,50 @@
<?php
// auth.php
declare(strict_types=1);
function auth_session_start(): void
{
//$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
}
/**
* Require a valid login.
* IMPORTANT: releases the session lock immediately to avoid deadlocks/timeouts.
*/
function require_auth(bool $json = true): void
{
auth_session_start();
$ok = (isset($_SESSION['authed']) && $_SESSION['authed'] === true);
// Release the session file lock ASAP.
// This prevents other requests from blocking on session_start().
session_write_close();
if (!$ok) {
http_response_code(401);
if ($json) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Unauthorized']);
} else {
header('Content-Type: text/plain; charset=utf-8');
echo "Unauthorized";
}
exit;
}
}

View file

@ -2,8 +2,11 @@
/** /**
* get_files.php - Recursively scans media directories and returns file structure as JSON * get_files.php - Recursively scans media directories and returns file structure as JSON
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/auth.php';
require_auth(true);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');

374
index.php
View file

@ -342,6 +342,49 @@
font-weight: 600; font-weight: 600;
} }
.player-controls-row {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
z-index: 1;
}
.audio-track-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
font-family: 'Orbitron', sans-serif;
}
.audio-track-select {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.55rem 0.75rem;
border-radius: 8px;
outline: none;
min-width: 220px;
font-family: 'Archivo', sans-serif;
}
.audio-track-select:focus {
border-color: var(--accent-primary);
box-shadow: var(--shadow-glow);
}
.audio-track-hint {
color: var(--text-secondary);
font-size: 0.8rem;
margin-top: 0.5rem;
z-index: 1;
text-align: center;
}
.loading { .loading {
display: inline-block; display: inline-block;
width: 20px; width: 20px;
@ -352,6 +395,119 @@
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(6px);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 420px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: 0 10px 40px rgba(0,0,0,0.6);
padding: 1.75rem;
}
.login-title {
font-family: 'Orbitron', sans-serif;
font-size: 1.4rem;
color: var(--accent-primary);
margin-bottom: 0.35rem;
}
.login-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.login-row {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.9rem;
}
.login-row label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-secondary);
font-family: 'Orbitron', sans-serif;
}
.login-row input {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.75rem 0.85rem;
border-radius: 10px;
outline: none;
font-family: 'Archivo', sans-serif;
}
.login-row input:focus {
border-color: var(--accent-primary);
box-shadow: var(--shadow-glow);
}
.login-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
align-items: center;
justify-content: space-between;
}
.btn {
cursor: pointer;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 0.75rem 1rem;
font-family: 'Orbitron', sans-serif;
letter-spacing: 0.05em;
text-transform: uppercase;
font-size: 0.85rem;
transition: all 0.2s ease;
background: var(--bg-tertiary);
color: var(--text-primary);
}
.btn-primary {
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%);
border-color: var(--accent-primary);
color: var(--bg-primary);
font-weight: 700;
box-shadow: var(--shadow-glow);
}
.btn:hover {
transform: translateY(-1px);
}
.login-error {
color: #ff6b6b;
margin-top: 0.75rem;
font-size: 0.9rem;
display: none;
}
.topbar {
display: flex;
justify-content: flex-end;
margin-bottom: 1rem;
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@ -392,12 +548,22 @@
</aside> </aside>
<main class="player-section"> <main class="player-section">
<div class="topbar">
<button class="btn" id="logoutBtn" style="display:none;">Logout</button>
</div>
<div class="player-container" id="playerContainer"> <div class="player-container" id="playerContainer">
<div class="player-placeholder"> <div class="player-placeholder">
<div class="player-placeholder-icon">🎵</div> <div class="player-placeholder-icon">🎵</div>
<p>Select a file to start playing</p> <p>Select a file to start playing</p>
</div> </div>
</div> </div>
<div class="player-controls-row" id="audioControls" style="display:none;">
<div class="audio-track-label">Audio Track</div>
<select id="audioSelect" class="audio-track-select"></select>
</div>
<div class="audio-track-hint" id="audioHint" style="display:none;"></div>
<div class="now-playing" id="nowPlaying" style="display: none;"> <div class="now-playing" id="nowPlaying" style="display: none;">
<div class="now-playing-label">Now Playing</div> <div class="now-playing-label">Now Playing</div>
<div class="now-playing-title" id="nowPlayingTitle"></div> <div class="now-playing-title" id="nowPlayingTitle"></div>
@ -406,7 +572,70 @@
</div> </div>
</div> </div>
<div class="overlay" id="loginOverlay" style="display:none;">
<div class="login-card">
<div class="login-title">Login</div>
<div class="login-subtitle">Sign in to access your media library.</div>
<form id="loginForm">
<div class="login-row">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required />
</div>
<div class="login-row">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required />
</div>
<div class="login-actions">
<button class="btn btn-primary" type="submit" id="loginBtn">Login</button>
<span id="loginSpinner" class="loading" style="display:none;"></span>
</div>
<div class="login-error" id="loginError"></div>
</form>
</div>
</div>
<script> <script>
function showLogin(message = '') {
document.getElementById('loginOverlay').style.display = 'flex';
document.getElementById('logoutBtn').style.display = 'none';
const err = document.getElementById('loginError');
if (message) {
err.textContent = message;
err.style.display = 'block';
} else {
err.textContent = '';
err.style.display = 'none';
}
}
function hideLogin() {
document.getElementById('loginOverlay').style.display = 'none';
document.getElementById('loginError').style.display = 'none';
document.getElementById('logoutBtn').style.display = 'inline-block';
}
async function authedFetch(url, options = {}) {
// Ensure cookies (session) are sent
const resp = await fetch(url, {
...options,
credentials: 'same-origin',
});
if (resp.status === 401) {
// Session missing/expired
showLogin('Please log in to continue.');
throw new Error('Unauthorized');
}
return resp;
}
const state = { const state = {
currentType: 'videos', currentType: 'videos',
currentFile: null, currentFile: null,
@ -415,10 +644,77 @@
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadFiles('videos');
setupTabListeners(); setupTabListeners();
setupLoginHandlers();
// Try loading files; if 401, overlay will appear.
loadFiles('videos');
}); });
function setupLoginHandlers() {
const form = document.getElementById('loginForm');
const spinner = document.getElementById('loginSpinner');
const err = document.getElementById('loginError');
const logoutBtn = document.getElementById('logoutBtn');
// Show overlay until proven authed
showLogin('');
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.style.display = 'none';
spinner.style.display = 'inline-block';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const resp = await fetch('login.php', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.error || `Login failed (${resp.status})`);
}
hideLogin();
// Reload file browser after login
loadFiles(state.currentType);
} catch (ex) {
err.textContent = ex.message || 'Login failed';
err.style.display = 'block';
} finally {
spinner.style.display = 'none';
}
});
logoutBtn.addEventListener('click', async () => {
try {
await fetch('logout.php', { method: 'POST', credentials: 'same-origin' });
} catch (_) {
// ignore
}
showLogin('Logged out.');
// Clear UI
document.getElementById('fileBrowser').innerHTML = '';
document.getElementById('playerContainer').innerHTML = `
<div class="player-placeholder">
<div class="player-placeholder-icon">🎵</div>
<p>Select a file to start playing</p>
</div>
`;
document.getElementById('nowPlaying').style.display = 'none';
});
}
function setupTabListeners() { function setupTabListeners() {
document.querySelectorAll('.tab-btn').forEach(btn => { document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
@ -436,7 +732,7 @@
browser.innerHTML = '<div class="loading"></div>'; browser.innerHTML = '<div class="loading"></div>';
try { try {
const response = await fetch(`get_files.php?type=${type}`); const response = await authedFetch(`get_files.php?type=${type}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
@ -516,18 +812,92 @@
} }
} }
function resetAudioTrackUI() {
const audioControls = document.getElementById('audioControls');
const audioSelect = document.getElementById('audioSelect');
const audioHint = document.getElementById('audioHint');
audioControls.style.display = 'none';
audioHint.style.display = 'none';
audioHint.textContent = '';
audioSelect.innerHTML = '';
}
function setupAudioTrackSelector(videoEl) {
const audioControls = document.getElementById('audioControls');
const audioSelect = document.getElementById('audioSelect');
const audioHint = document.getElementById('audioHint');
// Not all browsers expose audioTracks (and MKV often won't)
const tracks = videoEl.audioTracks;
if (!tracks) {
audioControls.style.display = 'none';
audioHint.style.display = 'block';
audioHint.textContent = 'Audio track switching not supported for this file/browser, open in VLC to change audio track if needed.';
return;
}
if (tracks.length <= 1) {
audioControls.style.display = 'none';
audioHint.style.display = 'none';
return;
}
// Build dropdown
audioSelect.innerHTML = '';
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i];
const opt = document.createElement('option');
opt.value = String(i);
// label is often empty; language may exist depending on browser/container
const label = (t.label && t.label.trim()) ? t.label.trim() : null;
const lang = (t.language && t.language.trim()) ? t.language.trim() : null;
opt.textContent = label || (lang ? `Track ${i + 1} (${lang})` : `Track ${i + 1}`);
audioSelect.appendChild(opt);
// Set currently enabled track as selected
if (t.enabled) {
audioSelect.value = String(i);
}
}
audioSelect.onchange = () => {
const idx = parseInt(audioSelect.value, 10);
for (let i = 0; i < tracks.length; i++) {
tracks[i].enabled = (i === idx);
}
};
audioHint.style.display = 'none';
audioControls.style.display = 'flex';
}
function playFile(path, name) { function playFile(path, name) {
const playerContainer = document.getElementById('playerContainer'); const playerContainer = document.getElementById('playerContainer');
const nowPlaying = document.getElementById('nowPlaying'); const nowPlaying = document.getElementById('nowPlaying');
const nowPlayingTitle = document.getElementById('nowPlayingTitle'); const nowPlayingTitle = document.getElementById('nowPlayingTitle');
state.currentFile = { path, name }; state.currentFile = { path, name };
resetAudioTrackUI();
const isVideo = state.currentType === 'videos'; const isVideo = state.currentType === 'videos';
const mediaElement = document.createElement(isVideo ? 'video' : 'audio'); const mediaElement = document.createElement(isVideo ? 'video' : 'audio');
mediaElement.controls = true; mediaElement.controls = true;
mediaElement.autoplay = true; mediaElement.autoplay = true;
if (isVideo) {
// Some browsers only populate audioTracks after metadata is available
mediaElement.addEventListener('loadedmetadata', () => {
setupAudioTrackSelector(mediaElement);
});
}
// Add error handling // Add error handling
mediaElement.addEventListener('error', (e) => { mediaElement.addEventListener('error', (e) => {

60
login.php Normal file
View file

@ -0,0 +1,60 @@
<?php
// login.php
declare(strict_types=1);
require_once __DIR__ . '/auth.php';
auth_session_start();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
// Read JSON body
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '', true);
$username = is_array($data) ? (string)($data['username'] ?? '') : '';
$password = is_array($data) ? (string)($data['password'] ?? '') : '';
if ($username === '' || $password === '') {
http_response_code(400);
echo json_encode(['error' => 'Missing username or password']);
exit;
}
// === Simple credential check ===
// Recommended: set these via environment variables
$expectedUser = getenv('MEDIA_USER') ?: 'admin';
// Store bcrypt hash in env: MEDIA_PASS_HASH="$2y$10$..."
$expectedHash = getenv('MEDIA_PASS_HASH');
// If no hash provided, fall back to a plain password for quick LAN-only testing.
// Strongly recommend switching to MEDIA_PASS_HASH once you verify flow works.
$plainFallbackPass = getenv('MEDIA_PASS_PLAIN') ?: 'changeme';
$ok = false;
if ($expectedHash) {
$ok = hash_equals($expectedUser, $username) && password_verify($password, $expectedHash);
} else {
$ok = hash_equals($expectedUser, $username) && hash_equals($plainFallbackPass, $password);
}
if (!$ok) {
// Slight delay makes brute forcing annoying
usleep(250000);
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
exit;
}
// Success: mark session authed, rotate session id
session_regenerate_id(true);
$_SESSION['authed'] = true;
$_SESSION['user'] = $username;
echo json_encode(['ok' => true, 'user' => $username]);

20
logout.php Normal file
View file

@ -0,0 +1,20 @@
<?php
// logout.php
declare(strict_types=1);
require_once __DIR__ . '/auth.php';
auth_session_start();
header('Content-Type: application/json; charset=utf-8');
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
echo json_encode(['ok' => true]);

View file

@ -2,8 +2,11 @@
/** /**
* serve_media.php - Serves media files with proper headers for streaming + Range * serve_media.php - Serves media files with proper headers for streaming + Range
*/ */
declare(strict_types=1); declare(strict_types=1);
require_once __DIR__ . '/auth.php';
require_auth(false); // plain text is fine for media endpoint
set_time_limit(0);
// ---- Config (prefer env vars in production) ---- // ---- Config (prefer env vars in production) ----
$homeDir = getenv('HOME') ?: (getenv('USERPROFILE') ?: ('/home/' . get_current_user())); $homeDir = getenv('HOME') ?: (getenv('USERPROFILE') ?: ('/home/' . get_current_user()));

144
start-server.sh Executable file
View file

@ -0,0 +1,144 @@
#!/usr/bin/env bash
set -euo pipefail
# start-media-server.sh
#
# Starts frankenphp with MEDIA_USER and MEDIA_PASS_HASH set.
# Hash is generated via:
# - system php (preferred) OR
# - frankenphp php-cli (fallback)
#
# Usage:
# ./start-media-server.sh --user admin --prompt [--port 9000] [--dir /path/to/docroot]
#
# Security:
# Avoid --pass on command line; use --prompt or --pass-stdin to keep it out of `ps`/history.
PORT="9000"
USER=""
PASS=""
DOCROOT=""
read_pass_stdin=false
prompt=false
usage() {
cat <<'EOF'
Usage:
start-media-server.sh --user <username> [--pass <password> | --pass-stdin | --prompt] [--port <port>] [--dir <docroot>]
Options:
--user <u> Username to set in MEDIA_USER (required)
--pass <p> Password to hash (insecure: visible in process list & shell history)
--pass-stdin Read password from stdin (safer)
--prompt Prompt for password (safer; hidden input)
--port <p> Listen port (default: 9000)
--dir <path> cd into docroot before starting (optional)
-h, --help Show help
Examples:
./start-media-server.sh --user admin --prompt --port 9000 --dir /home/me/samba-serv
printf '%s\n' 'test123' | ./start-media-server.sh --user admin --pass-stdin
EOF
}
have_cmd() { command -v "$1" >/dev/null 2>&1; }
# Pick a PHP hashing command:
# - If `php` exists: use it.
# - Else if `frankenphp` exists and supports php-cli: use it.
pick_php_runner() {
if have_cmd php; then
echo "php"
return 0
fi
if have_cmd frankenphp; then
# Verify php-cli is available (frankenphp subcommand)
if frankenphp php-cli -v >/dev/null 2>&1; then
echo "frankenphp php-cli"
return 0
fi
fi
return 1
}
while (($#)); do
case "$1" in
--user) USER="${2-}"; shift 2 ;;
--pass) PASS="${2-}"; shift 2 ;;
--pass-stdin) read_pass_stdin=true; shift ;;
--prompt) prompt=true; shift ;;
--port) PORT="${2-}"; shift 2 ;;
--dir) DOCROOT="${2-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*)
echo "Unknown argument: $1" >&2
usage
exit 2
;;
esac
done
if [[ -z "$USER" ]]; then
echo "Error: --user is required" >&2
usage
exit 2
fi
if $read_pass_stdin && $prompt; then
echo "Error: choose only one of --pass-stdin or --prompt" >&2
exit 2
fi
if [[ -z "$PASS" ]]; then
if $read_pass_stdin; then
IFS= read -r PASS
elif $prompt; then
read -r -s -p "Password: " PASS
echo
else
echo "Error: provide --pass, --pass-stdin, or --prompt" >&2
usage
exit 2
fi
fi
if ! [[ "$PORT" =~ ^[0-9]+$ ]] || ((PORT < 1 || PORT > 65535)); then
echo "Error: invalid --port '$PORT'" >&2
exit 2
fi
if ! have_cmd frankenphp; then
echo "Error: frankenphp not found in PATH" >&2
exit 127
fi
PHP_RUNNER="$(pick_php_runner)" || {
echo "Error: neither 'php' nor 'frankenphp php-cli' is available for hashing" >&2
echo "Install php OR ensure frankenphp supports 'php-cli'." >&2
exit 127
}
# Generate bcrypt hash without needing to escape $.
# We pass the password via an env var P to avoid quoting surprises in -r strings.
MEDIA_PASS_HASH="$(
P="$PASS" $PHP_RUNNER -r 'echo password_hash(getenv("P"), PASSWORD_BCRYPT), PHP_EOL;'
)"
export MEDIA_USER="$USER"
export MEDIA_PASS_HASH
if [[ -n "$DOCROOT" ]]; then
cd "$DOCROOT"
fi
echo "Using PHP runner: $PHP_RUNNER"
echo "Starting frankenphp on :$PORT"
echo "MEDIA_USER=$MEDIA_USER"
echo "MEDIA_PASS_HASH set (bcrypt)"
echo
exec frankenphp php-server --listen ":$PORT"