Added auth support and a helper script, targeting frankenphp
This commit is contained in:
parent
8f545c3f67
commit
0783e08fe9
8 changed files with 655 additions and 4 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.env
|
||||||
50
auth.php
Normal file
50
auth.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
374
index.php
|
|
@ -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,12 +812,78 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
@ -529,6 +891,14 @@
|
||||||
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) => {
|
||||||
console.error('Media playback error:', {
|
console.error('Media playback error:', {
|
||||||
|
|
|
||||||
60
login.php
Normal file
60
login.php
Normal 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
20
logout.php
Normal 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]);
|
||||||
|
|
||||||
|
|
@ -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
144
start-server.sh
Executable 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"
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue