From 3c3bd6dcb1e5aa1ef41279671eaf0d6656a41e31 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 3 Apr 2026 22:30:35 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=BF=E7=94=A8VALKEY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 ++++ package.json | 1 + public/js/main.js | 106 +++++++++++------------------ server.js | 168 +++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 213 insertions(+), 75 deletions(-) diff --git a/.env.example b/.env.example index ace6996..6135c22 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,16 @@ S3_FORCE_PATH_STYLE=true # Application Title APP_TITLE=S3 Media Transcoder + +# Valkey / Redis session storage for browser refresh login persistence +# You can use any one of these; VALKEY_URL is preferred. +VALKEY_URL=redis://127.0.0.1:6379 +# REDIS_URL=redis://127.0.0.1:6379 +# VALKEY_ADDRESS=redis://127.0.0.1:6379 + +# Session cookie settings for S3 credential session persistence +S3_SESSION_COOKIE_NAME=media_coding_s3_session +S3_SESSION_TTL_SECONDS=2592000 + +# In production behind HTTPS, set this to true so the session cookie is Secure. +SESSION_COOKIE_SECURE=false diff --git a/package.json b/package.json index 3ffb383..2473a4c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "fluent-ffmpeg": "^2.1.3", + "redis": "^4.7.0", "ws": "^8.13.0" } } diff --git a/public/js/main.js b/public/js/main.js index efe1920..bba2303 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -72,7 +72,7 @@ document.addEventListener('DOMContentLoaded', () => { let s3Username = ''; let s3Password = ''; let s3AuthHeaders = {}; - const SAVED_AUTH_STORAGE_KEY = 'media-coding-web:s3-auth'; + const LEGACY_AUTH_STORAGE_KEY = 'media-coding-web:s3-auth'; const SAVED_BUCKET_STORAGE_KEY = 'media-coding-web:selected-bucket'; // Seek state @@ -770,41 +770,6 @@ document.addEventListener('DOMContentLoaded', () => { if (s3Password) s3AuthHeaders['X-S3-Password'] = s3Password; }; - const saveAuthState = (username, password) => { - try { - window.localStorage.setItem(SAVED_AUTH_STORAGE_KEY, JSON.stringify({ - username, - password - })); - } catch (error) { - console.warn('Failed to persist auth state:', error); - } - }; - - const loadAuthState = () => { - try { - const rawValue = window.localStorage.getItem(SAVED_AUTH_STORAGE_KEY); - if (!rawValue) return null; - const parsed = JSON.parse(rawValue); - if (!parsed || typeof parsed.username !== 'string' || typeof parsed.password !== 'string') { - return null; - } - return parsed; - } catch (error) { - console.warn('Failed to read auth state:', error); - return null; - } - }; - - const clearAuthState = () => { - try { - window.localStorage.removeItem(SAVED_AUTH_STORAGE_KEY); - window.localStorage.removeItem(SAVED_BUCKET_STORAGE_KEY); - } catch (error) { - console.warn('Failed to clear auth state:', error); - } - }; - const saveSelectedBucket = (bucketName) => { try { if (bucketName) { @@ -826,6 +791,12 @@ document.addEventListener('DOMContentLoaded', () => { } }; + try { + window.localStorage.removeItem(LEGACY_AUTH_STORAGE_KEY); + } catch (error) { + console.warn('Failed to clear legacy auth state:', error); + } + const showLogin = () => { if (loginScreen) loginScreen.classList.remove('hidden'); if (appContainer) appContainer.classList.add('hidden'); @@ -859,6 +830,27 @@ document.addEventListener('DOMContentLoaded', () => { loginError.classList.add('hidden'); }; + const applyAuthenticatedState = async (buckets) => { + if (!Array.isArray(buckets) || buckets.length === 0) { + throw new Error('No buckets available for this account'); + } + + renderBuckets(buckets); + showApp(); + const savedBucket = loadSelectedBucket(); + selectedBucket = buckets.some((bucket) => bucket.Name === savedBucket) + ? savedBucket + : buckets[0].Name; + if (bucketSelect) bucketSelect.value = selectedBucket; + saveSelectedBucket(selectedBucket); + loadConfig(); + if (!ws) { + connectWebSocket(); + } + setAuthHeaders('', ''); + await fetchVideos(selectedBucket); + }; + const login = async (options = {}) => { if (!loginUsernameInput || !loginPasswordInput) return false; const username = typeof options.username === 'string' ? options.username.trim() : loginUsernameInput.value.trim(); @@ -882,29 +874,12 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error(data.error || 'Login failed'); } const data = await res.json(); - if (!Array.isArray(data.buckets) || data.buckets.length === 0) { - throw new Error('No buckets available for this account'); - } - saveAuthState(username, password); if (loginUsernameInput) loginUsernameInput.value = username; - if (loginPasswordInput) loginPasswordInput.value = password; - renderBuckets(data.buckets); - showApp(); - const savedBucket = loadSelectedBucket(); - selectedBucket = data.buckets.some((bucket) => bucket.Name === savedBucket) - ? savedBucket - : data.buckets[0].Name; - if (bucketSelect) bucketSelect.value = selectedBucket; - saveSelectedBucket(selectedBucket); - loadConfig(); - if (!ws) { - connectWebSocket(); - } - await fetchVideos(selectedBucket); + if (loginPasswordInput) loginPasswordInput.value = ''; + await applyAuthenticatedState(data.buckets); return true; } catch (err) { console.error('Login error:', err); - clearAuthState(); if (!skipErrorUi) { showLoginError(err.message); } @@ -919,19 +894,18 @@ document.addEventListener('DOMContentLoaded', () => { }; const restoreSavedSession = async () => { - const savedAuth = loadAuthState(); - if (!savedAuth) { + try { + const res = await fetch('/api/buckets'); + if (!res.ok) { + return false; + } + const data = await res.json(); + await applyAuthenticatedState(data.buckets || []); + return true; + } catch (error) { + console.error('Session restore failed:', error); return false; } - - if (loginUsernameInput) loginUsernameInput.value = savedAuth.username; - if (loginPasswordInput) loginPasswordInput.value = savedAuth.password; - return login({ - username: savedAuth.username, - password: savedAuth.password, - isRestoring: true, - skipErrorUi: true - }); }; const clearDownloadCache = async () => { diff --git a/server.js b/server.js index f110723..b894530 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const dotenv = require('dotenv'); const fs = require('fs'); const path = require('path'); const http = require('http'); +const crypto = require('crypto'); const { PassThrough } = require('stream'); const WebSocket = require('ws'); const ffmpeg = require('fluent-ffmpeg'); @@ -50,6 +51,10 @@ const defaultS3ClientConfig = { endpoint: process.env.S3_ENDPOINT || parsedBucketUrl, forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true' }; +const VALKEY_URL = process.env.VALKEY_URL || process.env.REDIS_URL || process.env.VALKEY_ADDRESS || ''; +const SESSION_COOKIE_NAME = process.env.S3_SESSION_COOKIE_NAME || 'media_coding_s3_session'; +const SESSION_TTL_SECONDS = Math.max(300, parseInt(process.env.S3_SESSION_TTL_SECONDS || '2592000', 10) || 2592000); +const isSecureCookie = process.env.SESSION_COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production'; const PROJECT_ROOT = __dirname; const DOWNLOADS_DIR = path.join(PROJECT_ROOT, 'Downloads'); @@ -64,6 +69,9 @@ const ensureDirectory = (dirPath) => { ensureDirectory(DOWNLOADS_DIR); ensureDirectory(CONVERT_DIR); +let valkeyClient = null; +let valkeyEnabled = false; + const createS3Client = (credentials) => { const clientConfig = { ...defaultS3ClientConfig }; if (credentials && credentials.username && credentials.password) { @@ -100,6 +108,126 @@ const SUPPORTED_TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', ' const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/'); const sanitizePathSegment = (value) => value.replace(/[^a-zA-Z0-9._-]/g, '_'); +const getSessionStorageKey = (sessionId) => `media-coding-web:s3-session:${sessionId}`; +const parseCookieHeader = (cookieHeader) => { + if (typeof cookieHeader !== 'string' || !cookieHeader.trim()) { + return {}; + } + + return cookieHeader.split(';').reduce((acc, item) => { + const separatorIndex = item.indexOf('='); + if (separatorIndex === -1) { + return acc; + } + const key = item.slice(0, separatorIndex).trim(); + const value = item.slice(separatorIndex + 1).trim(); + if (key) { + acc[key] = decodeURIComponent(value); + } + return acc; + }, {}); +}; + +const appendSetCookie = (res, cookieValue) => { + const currentValue = res.getHeader('Set-Cookie'); + if (!currentValue) { + res.setHeader('Set-Cookie', cookieValue); + return; + } + const nextValue = Array.isArray(currentValue) ? currentValue.concat(cookieValue) : [currentValue, cookieValue]; + res.setHeader('Set-Cookie', nextValue); +}; + +const setSessionCookie = (res, sessionId) => { + const parts = [ + `${SESSION_COOKIE_NAME}=${encodeURIComponent(sessionId)}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${SESSION_TTL_SECONDS}` + ]; + if (isSecureCookie) { + parts.push('Secure'); + } + appendSetCookie(res, parts.join('; ')); +}; + +const clearSessionCookie = (res) => { + appendSetCookie(res, `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${isSecureCookie ? '; Secure' : ''}`); +}; + +const initializeValkey = async () => { + if (!VALKEY_URL || valkeyClient) { + return; + } + + try { + const redisModule = require('redis'); + valkeyClient = redisModule.createClient({ url: VALKEY_URL }); + valkeyClient.on('error', (error) => { + console.error('[valkey] client error:', error); + valkeyEnabled = false; + }); + await valkeyClient.connect(); + valkeyEnabled = true; + console.log(`[valkey] connected url=${VALKEY_URL}`); + } catch (error) { + valkeyClient = null; + valkeyEnabled = false; + console.warn('[valkey] unavailable, falling back to request-scoped auth only:', error.message); + } +}; + +const createValkeySession = async (res, credentials) => { + if (!valkeyEnabled || !valkeyClient || !credentials?.username || !credentials?.password) { + return null; + } + + const sessionId = crypto.randomUUID(); + await valkeyClient.set(getSessionStorageKey(sessionId), JSON.stringify({ + username: credentials.username, + password: credentials.password + }), { + EX: SESSION_TTL_SECONDS + }); + setSessionCookie(res, sessionId); + console.log(`[auth] session stored sessionId=${sessionId} ttl=${SESSION_TTL_SECONDS}s`); + return sessionId; +}; + +const loadValkeySession = async (req) => { + if (!valkeyEnabled || !valkeyClient) { + return null; + } + + const cookies = parseCookieHeader(req.headers.cookie || ''); + const sessionId = cookies[SESSION_COOKIE_NAME]; + if (!sessionId) { + return null; + } + + const rawValue = await valkeyClient.get(getSessionStorageKey(sessionId)); + if (!rawValue) { + return null; + } + + try { + const parsed = JSON.parse(rawValue); + if (!parsed || typeof parsed.username !== 'string' || typeof parsed.password !== 'string') { + return null; + } + await valkeyClient.expire(getSessionStorageKey(sessionId), SESSION_TTL_SECONDS); + return { + username: parsed.username, + password: parsed.password, + source: 'session', + sessionId + }; + } catch (error) { + console.error(`[auth] invalid session payload sessionId=${sessionId}:`, error); + return null; + } +}; const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const getCachePathParts = (bucket, key) => { @@ -371,14 +499,27 @@ const stopActiveTranscode = (progressKey) => { return true; }; -const extractS3Credentials = (req) => { +const extractS3Credentials = async (req) => { const query = req.query || {}; 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 || ''; - return { + const normalizedCredentials = { username: typeof username === 'string' ? username.trim() : '', - password: typeof password === 'string' ? password : '' + password: typeof password === 'string' ? password : '', + source: 'direct' }; + + if (normalizedCredentials.username && normalizedCredentials.password) { + return normalizedCredentials; + } + + const sessionCredentials = await loadValkeySession(req); + if (sessionCredentials?.username && sessionCredentials?.password) { + console.log(`[auth] using valkey session sessionId=${sessionCredentials.sessionId}`); + return sessionCredentials; + } + + return normalizedCredentials; }; const wss = new WebSocket.Server({ server }); @@ -449,16 +590,23 @@ const clearConvertCache = () => { // Endpoint to list available buckets app.get('/api/buckets', async (req, res) => { + let auth = null; try { - const auth = extractS3Credentials(req); + auth = await extractS3Credentials(req); console.log(`[s3] list buckets start endpoint=${defaultS3ClientConfig.endpoint || 'aws-default'} region=${defaultS3ClientConfig.region} authProvided=${Boolean(auth.username)}`); const s3Client = createS3Client(auth); const command = new ListBucketsCommand({}); const response = await s3Client.send(command); const buckets = response.Buckets || []; + if (auth.source === 'direct' && auth.username && auth.password) { + await createValkeySession(res, auth); + } console.log(`[s3] list buckets complete count=${buckets.length}`); res.json({ buckets }); } catch (error) { + if (auth?.source === 'session') { + clearSessionCookie(res); + } console.error('[s3] list buckets failed:', error); res.status(500).json({ error: 'Failed to list buckets', detail: error.message }); } @@ -472,7 +620,7 @@ app.get('/api/videos', async (req, res) => { return res.status(400).json({ error: 'Bucket name is required' }); } const allObjects = []; - const auth = extractS3Credentials(req); + const auth = await extractS3Credentials(req); const s3Client = createS3Client(auth); let continuationToken; let pageNumber = 0; @@ -543,7 +691,7 @@ app.get('/api/subtitles', async (req, res) => { } const { downloadPath } = getCachePathParts(bucket, key); - const auth = extractS3Credentials(req); + const auth = await extractS3Credentials(req); const s3Client = createS3Client(auth); try { @@ -657,7 +805,7 @@ app.get('/api/stream', async (req, res) => { const { progressKey, downloadPath, convertPath } = getCachePathParts(bucket, key); - const auth = extractS3Credentials(req); + const auth = await extractS3Credentials(req); const s3Client = createS3Client(auth); try { @@ -925,6 +1073,8 @@ app.get('/api/stream', async (req, res) => { } }); -server.listen(PORT, HOST, () => { - console.log(`Server running on http://${HOST}:${PORT}`); +initializeValkey().finally(() => { + server.listen(PORT, HOST, () => { + console.log(`Server running on http://${HOST}:${PORT}`); + }); });