使用VALKEY缓存登录装唐

This commit is contained in:
CN-JS-HuiBai
2026-04-04 11:49:38 +08:00
parent a55c0cc30e
commit 1195a16cf1
7 changed files with 305 additions and 75 deletions

View File

@@ -11,3 +11,7 @@ S3_FORCE_PATH_STYLE=true
# Application Title # Application Title
APP_TITLE=S3 Media Transcoder APP_TITLE=S3 Media Transcoder
# Session Cache (Valkey / Redis)
VALKEY_URL=redis://localhost:6379
VALKEY_DB=0

View File

@@ -17,7 +17,9 @@ module.exports = {
S3_BUCKET_ADDRESS: 'https://s3.example.com/your_bucket_name', S3_BUCKET_ADDRESS: 'https://s3.example.com/your_bucket_name',
S3_ENDPOINT: 'http://127.0.0.1:9000', S3_ENDPOINT: 'http://127.0.0.1:9000',
S3_FORCE_PATH_STYLE: 'true', S3_FORCE_PATH_STYLE: 'true',
APP_TITLE: 'S3 Media Transcoder' APP_TITLE: 'S3 Media Transcoder',
VALKEY_URL: 'redis://localhost:6379',
VALKEY_DB: 0
} }
} }
] ]

View File

@@ -14,6 +14,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"ioredis": "^5.3.0",
"ws": "^8.13.0" "ws": "^8.13.0"
} }
} }

View File

@@ -10,6 +10,17 @@
--font-main: 'Inter', sans-serif; --font-main: 'Inter', sans-serif;
} }
:root[data-theme="dark"] {
--bg-dark: #0f172a;
--panel-bg: rgba(30, 41, 59, 0.85);
--panel-border: rgba(148, 163, 184, 0.15);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--accent-glow: rgba(59, 130, 246, 0.35);
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -247,10 +258,37 @@ header p {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: var(--text-primary); color: var(--text-primary);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: space-between;
}
.banner-title {
font-weight: 700;
}
.user-controls {
display: flex;
align-items: center;
gap: 1.5rem;
font-size: 0.95rem;
font-weight: 500;
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
#theme-selector {
background: var(--panel-bg);
color: var(--text-primary);
border: 1px solid var(--panel-border);
padding: 0.35rem 0.5rem;
border-radius: 6px;
font-size: 0.9rem;
outline: none;
} }
.top-banner.hidden { .top-banner.hidden {
@@ -699,8 +737,15 @@ header p {
} }
@keyframes statusPulse { @keyframes statusPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); }
50% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); } 0%,
100% {
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35);
}
50% {
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
}
} }
.control-btn { .control-btn {
@@ -907,8 +952,15 @@ header p {
} }
@keyframes phaseIn { @keyframes phaseIn {
from { opacity: 0; transform: translateY(12px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.phase-container h3 { .phase-container h3 {
@@ -940,8 +992,15 @@ header p {
} }
@keyframes pulse-download { @keyframes pulse-download {
0%, 100% { box-shadow: 0 0 0 0 rgba(6, 182, 212, 0.3); }
50% { box-shadow: 0 0 0 12px rgba(6, 182, 212, 0); } 0%,
100% {
box-shadow: 0 0 0 0 rgba(6, 182, 212, 0.3);
}
50% {
box-shadow: 0 0 0 12px rgba(6, 182, 212, 0);
}
} }
.phase-icon.transcode-icon { .phase-icon.transcode-icon {
@@ -951,8 +1010,15 @@ header p {
} }
@keyframes pulse-transcode { @keyframes pulse-transcode {
0%, 100% { box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.3); }
50% { box-shadow: 0 0 0 12px rgba(139, 92, 246, 0); } 0%,
100% {
box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.3);
}
50% {
box-shadow: 0 0 0 12px rgba(139, 92, 246, 0);
}
} }
/* Transcode stats */ /* Transcode stats */

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh"> <html lang="zh">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -10,6 +11,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css?v=2"> <link rel="stylesheet" href="/css/style.css?v=2">
</head> </head>
<body> <body>
<div class="background-effects"> <div class="background-effects">
<div class="glow-orb orb-1"></div> <div class="glow-orb orb-1"></div>
@@ -34,7 +36,21 @@
</div> </div>
<div class="container hidden" id="app-container"> <div class="container hidden" id="app-container">
<div id="top-banner" class="top-banner hidden"></div> <div id="top-banner" class="top-banner hidden">
<div id="top-banner-title" class="banner-title"></div>
<div class="user-controls">
<label>页面主题:
<select id="theme-selector">
<option value="light">白天模式</option>
<option value="dark">深夜模式</option>
</select>
</label>
<div class="user-info">
<span id="current-username"></span>
<button id="logout-btn" class="action-btn">退出登录</button>
</div>
</div>
</div>
<main class="dashboard"> <main class="dashboard">
<section class="video-list-section glass-panel"> <section class="video-list-section glass-panel">
@@ -75,7 +91,13 @@
<!-- Download Phase --> <!-- Download Phase -->
<div id="download-phase" class="phase-container"> <div id="download-phase" class="phase-container">
<div class="phase-icon download-icon"> <div class="phase-icon download-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</div> </div>
<h3>正在从 S3 下载源文件...</h3> <h3>正在从 S3 下载源文件...</h3>
<p id="download-size-text" class="phase-detail">准备下载...</p> <p id="download-size-text" class="phase-detail">准备下载...</p>
@@ -89,7 +111,12 @@
<!-- Transcode Phase --> <!-- Transcode Phase -->
<div id="transcode-phase" class="phase-container hidden"> <div id="transcode-phase" class="phase-container hidden">
<div class="phase-icon transcode-icon"> <div class="phase-icon transcode-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<polygon points="23 7 16 12 23 17 23 7" />
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
</svg>
</div> </div>
<h3>正在转码并流式传输...</h3> <h3>正在转码并流式传输...</h3>
<p id="transcode-detail-text" class="phase-detail">FFmpeg 转码中...</p> <p id="transcode-detail-text" class="phase-detail">FFmpeg 转码中...</p>
@@ -106,7 +133,8 @@
</div> </div>
</div> </div>
</div> </div>
<video id="video-player" preload="auto" controls playsinline webkit-playsinline class="hidden"></video> <video id="video-player" preload="auto" controls playsinline webkit-playsinline
class="hidden"></video>
<div id="seeking-overlay" class="seeking-overlay hidden"> <div id="seeking-overlay" class="seeking-overlay hidden">
<div class="spinner"></div> <div class="spinner"></div>
<span>跳转中...</span> <span>跳转中...</span>
@@ -125,4 +153,5 @@
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="/js/main.js?v=2"></script> <script src="/js/main.js?v=2"></script>
</body> </body>
</html> </html>

View File

@@ -19,6 +19,10 @@ document.addEventListener('DOMContentLoaded', () => {
const currentVideoTitle = document.getElementById('current-video-title'); const currentVideoTitle = document.getElementById('current-video-title');
const transcodeBtn = document.getElementById('transcode-btn'); const transcodeBtn = document.getElementById('transcode-btn');
const stopTranscodeBtn = document.getElementById('stop-transcode-btn'); const stopTranscodeBtn = document.getElementById('stop-transcode-btn');
const themeSelector = document.getElementById('theme-selector');
const currentUsername = document.getElementById('current-username');
const logoutBtn = document.getElementById('logout-btn');
const topBannerTitle = document.getElementById('top-banner-title');
const playBtn = document.getElementById('play-btn'); const playBtn = document.getElementById('play-btn');
const topBanner = document.getElementById('top-banner'); const topBanner = document.getElementById('top-banner');
const customControls = document.getElementById('custom-controls'); const customControls = document.getElementById('custom-controls');
@@ -138,8 +142,13 @@ document.addEventListener('DOMContentLoaded', () => {
const decoder = 'auto'; const decoder = 'auto';
const encoder = encoderSelect?.value || 'h264_rkmpp'; const encoder = encoderSelect?.value || 'h264_rkmpp';
let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`; let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`;
const sessionId = localStorage.getItem('sessionId');
if (sessionId) {
streamUrl += `&sessionId=${encodeURIComponent(sessionId)}`;
} else {
if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`; if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`;
if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`; if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`;
}
return streamUrl; return streamUrl;
}; };
@@ -531,7 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!res.ok) throw new Error('Failed to load config'); if (!res.ok) throw new Error('Failed to load config');
const data = await res.json(); const data = await res.json();
const title = data.title || 'S3 Media Transcoder'; const title = data.title || 'S3 Media Transcoder';
topBanner.textContent = title; if (topBannerTitle) topBannerTitle.textContent = title;
topBanner.classList.remove('hidden'); topBanner.classList.remove('hidden');
document.title = title; document.title = title;
populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp'); populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp');
@@ -572,6 +581,21 @@ document.addEventListener('DOMContentLoaded', () => {
if (s3Password) s3AuthHeaders['X-S3-Password'] = s3Password; if (s3Password) s3AuthHeaders['X-S3-Password'] = s3Password;
}; };
const setSessionAuth = (sessionId, username) => {
s3AuthHeaders = { 'X-Session-ID': sessionId };
localStorage.setItem('sessionId', sessionId);
if (username) {
localStorage.setItem('username', username);
if (currentUsername) currentUsername.textContent = username;
}
};
const clearSessionAuth = () => {
s3AuthHeaders = {};
localStorage.removeItem('sessionId');
localStorage.removeItem('username');
};
const showLogin = () => { const showLogin = () => {
if (loginScreen) loginScreen.classList.remove('hidden'); if (loginScreen) loginScreen.classList.remove('hidden');
if (appContainer) appContainer.classList.add('hidden'); if (appContainer) appContainer.classList.add('hidden');
@@ -618,16 +642,19 @@ document.addEventListener('DOMContentLoaded', () => {
clearLoginError(); clearLoginError();
try { try {
setAuthHeaders(username, password); const res = await fetch('/api/login', {
const res = await fetch('/api/buckets', { headers: s3AuthHeaders }); method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Login failed'); throw new Error(data.error || 'Login failed');
} }
const data = await res.json();
if (!Array.isArray(data.buckets) || data.buckets.length === 0) { if (!Array.isArray(data.buckets) || data.buckets.length === 0) {
throw new Error('No buckets available for this account'); throw new Error('No buckets available for this account');
} }
setSessionAuth(data.sessionId, data.username);
renderBuckets(data.buckets); renderBuckets(data.buckets);
showApp(); showApp();
selectedBucket = data.buckets[0].Name; selectedBucket = data.buckets[0].Name;
@@ -638,7 +665,7 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (err) { } catch (err) {
console.error('Login error:', err); console.error('Login error:', err);
showLoginError(err.message); showLoginError(err.message);
setAuthHeaders('', ''); clearSessionAuth();
} finally { } finally {
if (loginBtn) { if (loginBtn) {
loginBtn.disabled = false; loginBtn.disabled = false;
@@ -1088,6 +1115,61 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// Initial state: require login before loading data if (logoutBtn) {
showLogin(); logoutBtn.addEventListener('click', async () => {
const sessionId = localStorage.getItem('sessionId');
if (sessionId) {
try {
await fetch('/api/logout', {
method: 'POST',
headers: { 'X-Session-ID': sessionId }
});
} catch(e){}
}
clearSessionAuth();
location.reload();
});
}
if (themeSelector) {
themeSelector.addEventListener('change', (e) => {
const theme = e.target.value;
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
});
}
const initApp = async () => {
const savedSession = localStorage.getItem('sessionId');
const savedUsername = localStorage.getItem('username');
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
if (themeSelector) themeSelector.value = savedTheme;
if (savedSession) {
setSessionAuth(savedSession, savedUsername);
try {
const res = await fetch('/api/buckets', { headers: s3AuthHeaders });
if (!res.ok) throw new Error('Session invalid');
const data = await res.json();
renderBuckets(data.buckets);
showApp();
selectedBucket = data.buckets[0].Name;
if (bucketSelect) bucketSelect.value = selectedBucket;
loadConfig();
connectWebSocket();
await fetchVideos(selectedBucket);
} catch (err) {
console.error('Auto login failed:', err);
clearSessionAuth();
showLogin();
}
} else {
showLogin();
}
};
// Initial state: attempt auto login
initApp();
}); });

View File

@@ -8,6 +8,8 @@ const http = require('http');
const WebSocket = require('ws'); const WebSocket = require('ws');
const ffmpeg = require('fluent-ffmpeg'); const ffmpeg = require('fluent-ffmpeg');
const { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3'); const { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3');
const crypto = require('crypto');
const Redis = require('ioredis');
dotenv.config(); dotenv.config();
@@ -29,6 +31,12 @@ if (typeof ffmpeg.setFfprobePath === 'function') {
ffmpeg.setFfprobePath(JELLYFIN_FFPROBE_PATH); ffmpeg.setFfprobePath(JELLYFIN_FFPROBE_PATH);
} }
const redisUrl = process.env.VALKEY_URL || process.env.REDIS_URL || 'redis://localhost:6379';
const redisDb = parseInt(process.env.VALKEY_DB || process.env.REDIS_DB || '0', 10);
const redisClient = new Redis(redisUrl, {
db: isNaN(redisDb) ? 0 : redisDb
});
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(express.static('public')); app.use(express.static('public'));
@@ -264,8 +272,20 @@ const stopActiveTranscode = (progressKey) => {
return true; return true;
}; };
const extractS3Credentials = (req) => { const extractS3Credentials = async (req) => {
const query = req.query || {}; const query = req.query || {};
const sessionId = req.headers['x-session-id'] || req.body?.sessionId || query.sessionId || '';
if (sessionId) {
try {
const cachedCreds = await redisClient.get(`session:${sessionId}`);
if (cachedCreds) {
const creds = JSON.parse(cachedCreds);
return { username: creds.username, password: creds.password };
}
} catch (e) {
console.error('Session retrieval error:', e);
}
}
const username = req.headers['x-s3-username'] || req.body?.username || query.username || query.accessKeyId || ''; const username = req.headers['x-s3-username'] || req.body?.username || query.username || query.accessKeyId || '';
const password = req.headers['x-s3-password'] || req.body?.password || query.password || query.secretAccessKey || ''; const password = req.headers['x-s3-password'] || req.body?.password || query.password || query.secretAccessKey || '';
return { return {
@@ -328,10 +348,9 @@ const clearDownloadCache = () => {
}; };
// Endpoint to list available buckets
app.get('/api/buckets', async (req, res) => { app.get('/api/buckets', async (req, res) => {
try { try {
const auth = extractS3Credentials(req); const auth = await extractS3Credentials(req);
const s3Client = createS3Client(auth); const s3Client = createS3Client(auth);
const command = new ListBucketsCommand({}); const command = new ListBucketsCommand({});
const response = await s3Client.send(command); const response = await s3Client.send(command);
@@ -351,7 +370,7 @@ app.get('/api/videos', async (req, res) => {
return res.status(400).json({ error: 'Bucket name is required' }); return res.status(400).json({ error: 'Bucket name is required' });
} }
const allObjects = []; const allObjects = [];
const auth = extractS3Credentials(req); const auth = await extractS3Credentials(req);
const s3Client = createS3Client(auth); const s3Client = createS3Client(auth);
let continuationToken; let continuationToken;
@@ -387,6 +406,33 @@ app.get('/api/videos', async (req, res) => {
} }
}); });
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' });
try {
const s3Client = createS3Client({ username, password });
const command = new ListBucketsCommand({});
const response = await s3Client.send(command);
const buckets = response.Buckets || [];
const sessionId = crypto.randomBytes(32).toString('hex');
await redisClient.set(`session:${sessionId}`, JSON.stringify({ username, password }), 'EX', 7 * 24 * 3600);
res.json({ success: true, sessionId, username, buckets });
} catch (error) {
console.error('Login error:', error);
res.status(401).json({ error: 'Login failed', detail: error.message });
}
});
app.post('/api/logout', async (req, res) => {
const sessionId = req.headers['x-session-id'] || req.body?.sessionId;
if (sessionId) {
try { await redisClient.del(`session:${sessionId}`); } catch (e) { }
}
res.json({ success: true });
});
app.get('/api/config', (req, res) => { app.get('/api/config', (req, res) => {
const title = process.env.APP_TITLE || 'S3 Media Transcoder'; const title = process.env.APP_TITLE || 'S3 Media Transcoder';
res.json({ res.json({
@@ -467,7 +513,7 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
const auth = extractS3Credentials(req); const auth = await extractS3Credentials(req);
const s3Client = createS3Client(auth); const s3Client = createS3Client(auth);
let totalBytes = 0; let totalBytes = 0;
const progressKey = getProgressKey(key); const progressKey = getProgressKey(key);
@@ -734,7 +780,7 @@ app.get('/api/stream', async (req, res) => {
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
const cacheExists = fs.existsSync(tmpInputPath); const cacheExists = fs.existsSync(tmpInputPath);
const auth = extractS3Credentials(req); const auth = await extractS3Credentials(req);
const s3Client = createS3Client(auth); const s3Client = createS3Client(auth);
try { try {