// Copyright (c) 2026 Gordon Bolton. MIT License. const axios = require('axios'); const crypto = require('crypto'); const ipaddr = require('ipaddr.js'); function getEmbyUrl() { return process.env.EMBY_URL; } function isIpAllowed(clientIp, allowedSubnetsStr) { if (!allowedSubnetsStr) return true; try { const clientIpParsed = ipaddr.parse(clientIp); const subnets = allowedSubnetsStr.split(',').map(s => s.trim()).filter(Boolean); for (const subnet of subnets) { let rangeStr = subnet; let bits = null; if (subnet.includes('/')) { const parts = subnet.split('/'); rangeStr = parts[0]; bits = parseInt(parts[1], 10); } const rangeIpParsed = ipaddr.parse(rangeStr); if (bits === null) { // Exact IP match if (clientIpParsed.toString() === rangeIpParsed.toString()) { return true; } // Handle IPv4 mapped IPv6 address case if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') { if (clientIpParsed.toIPv4Address().toString() === rangeIpParsed.toString()) { return true; } } continue; } // Match with subnet bits if (clientIpParsed.kind() === rangeIpParsed.kind()) { if (clientIpParsed.match(rangeIpParsed, bits)) { return true; } } else if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') { // Handle IPv4 mapped IPv6 address case matching IPv4 range if (clientIpParsed.toIPv4Address().match(rangeIpParsed, bits)) { return true; } } } } catch (err) { console.error(`[logStreamAuth] IP parsing error for IP ${clientIp}:`, err.message); } return false; } async function logStreamAuth(req, res, next) { // 1. Subnet IP Filtering (First Priority) const allowedSubnets = process.env.LOG_ALLOW_SUBNETS; if (allowedSubnets && !isIpAllowed(req.ip, allowedSubnets)) { console.warn(`[logStreamAuth] Access denied from unauthorized IP: ${req.ip}`); return res.status(403).json({ error: `Access denied from IP: ${req.ip}` }); } // 2. Webhook Secret Bypass (High Priority) const secretHeader = req.headers['x-webhook-secret']; const configuredSecret = process.env.SOFARR_WEBHOOK_SECRET; if (configuredSecret && secretHeader === configuredSecret) { return next(); } // 3. Session Cookie const signed = !!process.env.COOKIE_SECRET; const rawCookie = signed ? req.signedCookies.emby_user : req.cookies.emby_user; if (rawCookie && rawCookie !== false) { try { const u = JSON.parse(rawCookie); if (u && typeof u.id === 'string' && u.id && u.isAdmin === true) { req.user = u; return next(); } } catch { // Ignore JSON parse errors, fallback to basic auth } } // 4. Basic Authentication Fallback const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith('Basic ')) { try { const credentialsBase64 = authHeader.substring(6); const credentialsStr = Buffer.from(credentialsBase64, 'base64').toString('utf8'); const colonIdx = credentialsStr.indexOf(':'); if (colonIdx !== -1) { const username = credentialsStr.substring(0, colonIdx).trim(); const password = credentialsStr.substring(colonIdx + 1); if (username && password) { const embyUrl = getEmbyUrl(); if (!embyUrl) { console.error('[logStreamAuth] Emby auth fallback failed: EMBY_URL is not configured'); res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"'); return res.status(401).json({ error: 'Authentication service unavailable' }); } // Authenticate with Emby using stable DeviceId derived from username const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16); console.log(`[logStreamAuth] Attempting Basic Auth login for: ${username}`); const authResponse = await axios.post(`${embyUrl}/Users/authenticatebyname`, { Username: username, Pw: password }, { headers: { 'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"` }, timeout: 5000 }); const authData = authResponse.data; const userId = authData.User.Id || authData.User.id; // Fetch detailed profile to verify administrator status const userResponse = await axios.get(`${embyUrl}/Users/${userId}`, { headers: { 'X-MediaBrowser-Token': authData.AccessToken }, timeout: 5000 }); const user = userResponse.data; const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); if (isAdmin) { console.log(`[logStreamAuth] Basic Auth successful for administrator: ${user.Name}`); req.user = { id: user.Id, name: user.Name, isAdmin: true }; return next(); } else { console.warn(`[logStreamAuth] Basic Auth rejected: user ${user.Name} is not an administrator`); } } } } catch (err) { console.error('[logStreamAuth] Emby authentication error:', err.message); } } // 5. Unauthorized / Access Denied res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"'); return res.status(401).json({ error: 'Unauthorized' }); } module.exports = logStreamAuth;