Compare commits
4 Commits
2e1a511089
...
b5556a78ac
Author | SHA1 | Date | |
---|---|---|---|
b5556a78ac
|
|||
90e7e26f79
|
|||
0cc49109da
|
|||
5ac64cdcb3 |
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# SFTP Configuration for Jellyfin Database Access
|
||||||
|
SFTP_HOST=your.jellyfin.server.com
|
||||||
|
SFTP_PORT=22
|
||||||
|
SFTP_USER=your_username
|
||||||
|
SFTP_PASSWORD=your_password
|
||||||
|
SFTP_PATH=/var/lib/jellyfin/data/jellyfin.db
|
||||||
|
|
||||||
|
# Jellyfin Server Configuration
|
||||||
|
JELLYFIN_URL=http://your.jellyfin.server.com:8096
|
||||||
|
JELLYFIN_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# Power Management Commands
|
||||||
|
JELLYFIN_POWER_ON_COMMAND=wakeonlan aa:bb:cc:dd:ee:ff
|
||||||
|
JELLYFIN_HIBERNATE_COMMAND=ssh user@server "sudo systemctl hibernate"
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
.idea
|
||||||
|
|
||||||
|
jellyfin.db
|
||||||
|
system_info.json
|
||||||
|
.env
|
2492
Cargo.lock
generated
Normal file
2492
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
Cargo.toml
Normal file
30
Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "AutoJellyProxy"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
|
tower = "0.4"
|
||||||
|
tower-http = { version = "0.5", features = ["fs", "cors"] }
|
||||||
|
tokio-tungstenite = "0.21"
|
||||||
|
futures-util = "0.3"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
ssh2 = "0.9"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
pbkdf2 = { version = "0.12", features = ["simple"] }
|
||||||
|
hmac = "0.12"
|
||||||
|
dotenv = "0.15"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
anyhow = "1.0"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
url = "2.5"
|
||||||
|
urlencoding = "2.1"
|
664
login.html
Normal file
664
login.html
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Jellyfin</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: #0d0d0d;
|
||||||
|
color: white;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-background {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, rgba(0,0,0,0.8) 30%, rgba(0,0,0,0.4) 60%, rgba(0,0,0,0.8) 100%),
|
||||||
|
linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%),
|
||||||
|
url('https://cdn.pixabay.com/photo/2021/12/12/20/00/popcorn-6865976_1280.jpg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
animation: slowZoom 20s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slowZoom {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
100% { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-overlay {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-section {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 600px;
|
||||||
|
padding-right: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background: #e50914;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #cccccc;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
list-style: none;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #b3b3b3;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li::before {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: #00D4FF;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section {
|
||||||
|
width: 450px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 60px 68px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
background: #333333;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: white;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
background: #454545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input.error {
|
||||||
|
background: #e87c03;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: #e50914;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover {
|
||||||
|
background: #f40612;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:active {
|
||||||
|
background: #d40812;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button.loading {
|
||||||
|
background: #e50914;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button.loading::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: auto;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s ease infinite;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #e87c03;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message {
|
||||||
|
background: rgba(46, 125, 50, 0.1);
|
||||||
|
border: 1px solid #4caf50;
|
||||||
|
color: #81c784;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-message.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: #737373;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.floating-particles {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: float 8s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
transform: translateY(100vh) rotate(0deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-10vh) rotate(360deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 950px) {
|
||||||
|
.content-overlay {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
padding: 48px 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-section {
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="hero-background"></div>
|
||||||
|
|
||||||
|
<div class="floating-particles" id="particles"></div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="content-overlay">
|
||||||
|
<div class="branding-section">
|
||||||
|
<div class="logo-container">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="logo-text">Jellyfin</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="hero-title">Your personal media universe</h1>
|
||||||
|
<p class="hero-subtitle" id="serverStatusText">Server is offline - will start automatically after login</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-section">
|
||||||
|
<h2 class="login-title">Sign In</h2>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="error-message" id="errorMessage"></div>
|
||||||
|
<div class="success-message" id="successMessage"></div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" class="form-input" id="username" placeholder="Username" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" class="form-input" id="password" placeholder="Password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="login-button" id="loginButton">
|
||||||
|
<span id="buttonText">Sign In</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="help-text">
|
||||||
|
Secure access to your personal media server
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Create floating particles
|
||||||
|
function createParticles() {
|
||||||
|
const container = document.getElementById('particles');
|
||||||
|
const particleCount = 30;
|
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
const particle = document.createElement('div');
|
||||||
|
particle.className = 'particle';
|
||||||
|
particle.style.left = Math.random() * 100 + '%';
|
||||||
|
particle.style.animationDelay = Math.random() * 8 + 's';
|
||||||
|
particle.style.animationDuration = (Math.random() * 4 + 6) + 's';
|
||||||
|
container.appendChild(particle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server status management
|
||||||
|
let serverStatus = 'offline';
|
||||||
|
const serverStatusText = document.getElementById('serverStatusText');
|
||||||
|
|
||||||
|
function updateServerStatus(status) {
|
||||||
|
serverStatus = status;
|
||||||
|
|
||||||
|
switch(status) {
|
||||||
|
case 'offline':
|
||||||
|
serverStatusText.textContent = 'Server is offline - will start automatically after login';
|
||||||
|
break;
|
||||||
|
case 'starting':
|
||||||
|
serverStatusText.textContent = 'Starting media server - please wait...';
|
||||||
|
break;
|
||||||
|
case 'running':
|
||||||
|
serverStatusText.textContent = 'Server is running - redirecting to your media library';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate device ID based on browser fingerprint
|
||||||
|
function generateDeviceId() {
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
const timestamp = Date.now().toString();
|
||||||
|
const combined = userAgent + '|' + timestamp;
|
||||||
|
// Base64 encode the combined string
|
||||||
|
return btoa(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get browser/device information
|
||||||
|
function getBrowserInfo() {
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
let browser = 'Unknown';
|
||||||
|
let device = 'Unknown';
|
||||||
|
|
||||||
|
if (userAgent.includes('Chrome')) {
|
||||||
|
browser = 'Jellyfin Web';
|
||||||
|
device = 'Chrome';
|
||||||
|
} else if (userAgent.includes('Firefox')) {
|
||||||
|
browser = 'Jellyfin Web';
|
||||||
|
device = 'Firefox';
|
||||||
|
} else if (userAgent.includes('Safari')) {
|
||||||
|
browser = 'Jellyfin Web';
|
||||||
|
device = 'Safari';
|
||||||
|
} else if (userAgent.includes('Edge')) {
|
||||||
|
browser = 'Jellyfin Web';
|
||||||
|
device = 'Edge';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { browser, device };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form handling
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const loginButton = document.getElementById('loginButton');
|
||||||
|
const buttonText = document.getElementById('buttonText');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
const successMessage = document.getElementById('successMessage');
|
||||||
|
const usernameInput = document.getElementById('username');
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
|
||||||
|
// Clear previous states
|
||||||
|
errorMessage.classList.remove('show');
|
||||||
|
successMessage.classList.remove('show');
|
||||||
|
usernameInput.classList.remove('error');
|
||||||
|
passwordInput.classList.remove('error');
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (!username || !password) {
|
||||||
|
errorMessage.textContent = 'Please enter both username and password.';
|
||||||
|
errorMessage.classList.add('show');
|
||||||
|
if (!username) usernameInput.classList.add('error');
|
||||||
|
if (!password) passwordInput.classList.add('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
loginButton.classList.add('loading');
|
||||||
|
buttonText.style.opacity = '0';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate authorization header
|
||||||
|
const { browser, device } = getBrowserInfo();
|
||||||
|
const deviceId = generateDeviceId();
|
||||||
|
const version = "10.10.5";
|
||||||
|
const authHeader = `MediaBrowser Client="${browser}", Device="${device}", DeviceId="${deviceId}", Version="${version}"`;
|
||||||
|
|
||||||
|
// Authenticate with backend
|
||||||
|
const response = await fetch('/Users/AuthenticateByName', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': authHeader
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
Username: username,
|
||||||
|
Pw: password
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Authentication failed. Please check your credentials.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData = await response.json();
|
||||||
|
|
||||||
|
// Store credentials in localStorage in Jellyfin Web Client format
|
||||||
|
// Use the same device info that was sent in the request
|
||||||
|
const jellyfinCredentials = {
|
||||||
|
"Servers": [{
|
||||||
|
"ManualAddress": "http://localhost:8096",
|
||||||
|
"manualAddressOnly": true,
|
||||||
|
"AccessToken": authData.AccessToken,
|
||||||
|
"UserId": authData.User.Id,
|
||||||
|
"DeviceId": deviceId,
|
||||||
|
"Client": browser,
|
||||||
|
"Device": device,
|
||||||
|
"Version": version
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('jellyfin_credentials', JSON.stringify(jellyfinCredentials));
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
successMessage.textContent = 'Authentication successful! Initializing media server...';
|
||||||
|
successMessage.classList.add('show');
|
||||||
|
|
||||||
|
// Update server status
|
||||||
|
updateServerStatus('starting');
|
||||||
|
|
||||||
|
// Wait a moment then check if server is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
updateServerStatus('running');
|
||||||
|
successMessage.textContent = 'Welcome to your media library! Redirecting...';
|
||||||
|
|
||||||
|
// Redirect to root which should now proxy to Jellyfin
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1500);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Show error
|
||||||
|
errorMessage.textContent = error.message || 'Authentication failed. Please check your credentials.';
|
||||||
|
errorMessage.classList.add('show');
|
||||||
|
usernameInput.classList.add('error');
|
||||||
|
passwordInput.classList.add('error');
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
loginButton.classList.remove('loading');
|
||||||
|
buttonText.style.opacity = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for existing authentication
|
||||||
|
async function checkExistingAuth() {
|
||||||
|
const credentials = localStorage.getItem('jellyfin_credentials');
|
||||||
|
if (credentials) {
|
||||||
|
try {
|
||||||
|
const parsedCreds = JSON.parse(credentials);
|
||||||
|
if (parsedCreds.Servers && parsedCreds.Servers.length > 0) {
|
||||||
|
const server = parsedCreds.Servers[0];
|
||||||
|
if (server.AccessToken && server.DeviceId) {
|
||||||
|
// Show that we're checking authentication
|
||||||
|
successMessage.textContent = 'Found existing credentials, validating...';
|
||||||
|
successMessage.classList.add('show');
|
||||||
|
updateServerStatus('starting');
|
||||||
|
|
||||||
|
// Use stored device information
|
||||||
|
const authHeaderWithToken = `MediaBrowser Client="${server.Client || 'Jellyfin Web'}", Device="${server.Device || 'Chrome'}", DeviceId="${server.DeviceId}", Version="${server.Version || '10.10.5'}", Token="${server.AccessToken}"`;
|
||||||
|
|
||||||
|
// Test if the token is still valid by making any authenticated request
|
||||||
|
// This will trigger server startup if needed and validate the token
|
||||||
|
const testResponse = await fetch('/', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': authHeaderWithToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (testResponse.ok || testResponse.status === 503) {
|
||||||
|
// Token is valid (or server is starting up), wait for full startup
|
||||||
|
successMessage.textContent = 'Authentication successful! Starting media server...';
|
||||||
|
updateServerStatus('starting');
|
||||||
|
|
||||||
|
// Wait for server to fully start up and then redirect
|
||||||
|
setTimeout(async () => {
|
||||||
|
// Check if we can access the main page now
|
||||||
|
try {
|
||||||
|
const finalCheck = await fetch('/', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': authHeaderWithToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (finalCheck.ok) {
|
||||||
|
successMessage.textContent = 'Media server ready! Redirecting...';
|
||||||
|
updateServerStatus('running');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// Server might still be starting, try one more time
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Network error, just try to redirect anyway
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
} else if (testResponse.status === 401) {
|
||||||
|
// Token is invalid, clear it and show login form
|
||||||
|
console.log('Token invalid, clearing credentials');
|
||||||
|
localStorage.removeItem('jellyfin_credentials');
|
||||||
|
successMessage.classList.remove('show');
|
||||||
|
updateServerStatus('offline');
|
||||||
|
} else {
|
||||||
|
// Other error, maybe server startup failed, but keep credentials for now
|
||||||
|
console.log('Server error, status:', testResponse.status);
|
||||||
|
successMessage.textContent = 'Server error occurred. Please try again.';
|
||||||
|
successMessage.classList.remove('show');
|
||||||
|
updateServerStatus('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid credentials, clear them
|
||||||
|
console.log('Error parsing credentials:', e);
|
||||||
|
localStorage.removeItem('jellyfin_credentials');
|
||||||
|
successMessage.classList.remove('show');
|
||||||
|
updateServerStatus('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
createParticles();
|
||||||
|
updateServerStatus('offline');
|
||||||
|
|
||||||
|
// Check if user is already authenticated
|
||||||
|
checkExistingAuth();
|
||||||
|
|
||||||
|
// Add input focus effects
|
||||||
|
document.querySelectorAll('.form-input').forEach(input => {
|
||||||
|
input.addEventListener('focus', function() {
|
||||||
|
this.classList.remove('error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
390
src/auth.rs
Normal file
390
src/auth.rs
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use pbkdf2::{
|
||||||
|
password_hash::{PasswordHash, PasswordVerifier},
|
||||||
|
Pbkdf2,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AuthRequest {
|
||||||
|
#[serde(rename = "Username")]
|
||||||
|
pub username: String,
|
||||||
|
#[serde(rename = "Pw")]
|
||||||
|
pub pw: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
#[serde(rename = "User")]
|
||||||
|
pub user: AuthUser,
|
||||||
|
#[serde(rename = "SessionInfo")]
|
||||||
|
pub session_info: SessionInfo,
|
||||||
|
#[serde(rename = "AccessToken")]
|
||||||
|
pub access_token: String,
|
||||||
|
#[serde(rename = "ServerId")]
|
||||||
|
pub server_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AuthUser {
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "ServerId")]
|
||||||
|
pub server_id: String,
|
||||||
|
#[serde(rename = "Id")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "HasPassword")]
|
||||||
|
pub has_password: bool,
|
||||||
|
#[serde(rename = "HasConfiguredPassword")]
|
||||||
|
pub has_configured_password: bool,
|
||||||
|
#[serde(rename = "HasConfiguredEasyPassword")]
|
||||||
|
pub has_configured_easy_password: bool,
|
||||||
|
#[serde(rename = "EnableAutoLogin")]
|
||||||
|
pub enable_auto_login: bool,
|
||||||
|
#[serde(rename = "LastLoginDate")]
|
||||||
|
pub last_login_date: Option<String>,
|
||||||
|
#[serde(rename = "LastActivityDate")]
|
||||||
|
pub last_activity_date: Option<String>,
|
||||||
|
#[serde(rename = "Configuration")]
|
||||||
|
pub configuration: UserConfiguration,
|
||||||
|
#[serde(rename = "Policy")]
|
||||||
|
pub policy: UserPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UserConfiguration {
|
||||||
|
#[serde(rename = "PlayDefaultAudioTrack")]
|
||||||
|
pub play_default_audio_track: bool,
|
||||||
|
#[serde(rename = "SubtitleLanguagePreference")]
|
||||||
|
pub subtitle_language_preference: String,
|
||||||
|
#[serde(rename = "DisplayMissingEpisodes")]
|
||||||
|
pub display_missing_episodes: bool,
|
||||||
|
#[serde(rename = "GroupedFolders")]
|
||||||
|
pub grouped_folders: Vec<String>,
|
||||||
|
#[serde(rename = "SubtitleMode")]
|
||||||
|
pub subtitle_mode: String,
|
||||||
|
#[serde(rename = "DisplayCollectionsView")]
|
||||||
|
pub display_collections_view: bool,
|
||||||
|
#[serde(rename = "EnableLocalPassword")]
|
||||||
|
pub enable_local_password: bool,
|
||||||
|
#[serde(rename = "OrderedViews")]
|
||||||
|
pub ordered_views: Vec<String>,
|
||||||
|
#[serde(rename = "LatestItemsExcludes")]
|
||||||
|
pub latest_items_excludes: Vec<String>,
|
||||||
|
#[serde(rename = "MyMediaExcludes")]
|
||||||
|
pub my_media_excludes: Vec<String>,
|
||||||
|
#[serde(rename = "HidePlayedInLatest")]
|
||||||
|
pub hide_played_in_latest: bool,
|
||||||
|
#[serde(rename = "RememberAudioSelections")]
|
||||||
|
pub remember_audio_selections: bool,
|
||||||
|
#[serde(rename = "RememberSubtitleSelections")]
|
||||||
|
pub remember_subtitle_selections: bool,
|
||||||
|
#[serde(rename = "EnableNextEpisodeAutoPlay")]
|
||||||
|
pub enable_next_episode_auto_play: bool,
|
||||||
|
#[serde(rename = "CastReceiverId")]
|
||||||
|
pub cast_receiver_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UserPolicy {
|
||||||
|
#[serde(rename = "IsAdministrator")]
|
||||||
|
pub is_administrator: bool,
|
||||||
|
#[serde(rename = "IsHidden")]
|
||||||
|
pub is_hidden: bool,
|
||||||
|
#[serde(rename = "EnableCollectionManagement")]
|
||||||
|
pub enable_collection_management: bool,
|
||||||
|
#[serde(rename = "EnableSubtitleManagement")]
|
||||||
|
pub enable_subtitle_management: bool,
|
||||||
|
#[serde(rename = "EnableLyricManagement")]
|
||||||
|
pub enable_lyric_management: bool,
|
||||||
|
#[serde(rename = "IsDisabled")]
|
||||||
|
pub is_disabled: bool,
|
||||||
|
#[serde(rename = "MaxParentalRating")]
|
||||||
|
pub max_parental_rating: Option<i32>,
|
||||||
|
#[serde(rename = "BlockedTags")]
|
||||||
|
pub blocked_tags: Vec<String>,
|
||||||
|
#[serde(rename = "AllowedTags")]
|
||||||
|
pub allowed_tags: Vec<String>,
|
||||||
|
#[serde(rename = "EnableUserPreferenceAccess")]
|
||||||
|
pub enable_user_preference_access: bool,
|
||||||
|
#[serde(rename = "AccessSchedules")]
|
||||||
|
pub access_schedules: Vec<String>,
|
||||||
|
#[serde(rename = "BlockUnratedItems")]
|
||||||
|
pub block_unrated_items: Vec<String>,
|
||||||
|
#[serde(rename = "EnableRemoteControlOfOtherUsers")]
|
||||||
|
pub enable_remote_control_of_other_users: bool,
|
||||||
|
#[serde(rename = "EnableSharedDeviceControl")]
|
||||||
|
pub enable_shared_device_control: bool,
|
||||||
|
#[serde(rename = "EnableRemoteAccess")]
|
||||||
|
pub enable_remote_access: bool,
|
||||||
|
#[serde(rename = "EnableLiveTvManagement")]
|
||||||
|
pub enable_live_tv_management: bool,
|
||||||
|
#[serde(rename = "EnableLiveTvAccess")]
|
||||||
|
pub enable_live_tv_access: bool,
|
||||||
|
#[serde(rename = "EnableMediaPlayback")]
|
||||||
|
pub enable_media_playback: bool,
|
||||||
|
#[serde(rename = "EnableAudioPlaybackTranscoding")]
|
||||||
|
pub enable_audio_playback_transcoding: bool,
|
||||||
|
#[serde(rename = "EnableVideoPlaybackTranscoding")]
|
||||||
|
pub enable_video_playback_transcoding: bool,
|
||||||
|
#[serde(rename = "EnablePlaybackRemuxing")]
|
||||||
|
pub enable_playback_remuxing: bool,
|
||||||
|
#[serde(rename = "ForceRemoteSourceTranscoding")]
|
||||||
|
pub force_remote_source_transcoding: bool,
|
||||||
|
#[serde(rename = "EnableContentDeletion")]
|
||||||
|
pub enable_content_deletion: bool,
|
||||||
|
#[serde(rename = "EnableContentDeletionFromFolders")]
|
||||||
|
pub enable_content_deletion_from_folders: Vec<String>,
|
||||||
|
#[serde(rename = "EnableContentDownloading")]
|
||||||
|
pub enable_content_downloading: bool,
|
||||||
|
#[serde(rename = "EnableSyncTranscoding")]
|
||||||
|
pub enable_sync_transcoding: bool,
|
||||||
|
#[serde(rename = "EnableMediaConversion")]
|
||||||
|
pub enable_media_conversion: bool,
|
||||||
|
#[serde(rename = "EnabledDevices")]
|
||||||
|
pub enabled_devices: Vec<String>,
|
||||||
|
#[serde(rename = "EnableAllDevices")]
|
||||||
|
pub enable_all_devices: bool,
|
||||||
|
#[serde(rename = "EnabledChannels")]
|
||||||
|
pub enabled_channels: Vec<String>,
|
||||||
|
#[serde(rename = "EnableAllChannels")]
|
||||||
|
pub enable_all_channels: bool,
|
||||||
|
#[serde(rename = "EnabledFolders")]
|
||||||
|
pub enabled_folders: Vec<String>,
|
||||||
|
#[serde(rename = "EnableAllFolders")]
|
||||||
|
pub enable_all_folders: bool,
|
||||||
|
#[serde(rename = "InvalidLoginAttemptCount")]
|
||||||
|
pub invalid_login_attempt_count: i32,
|
||||||
|
#[serde(rename = "LoginAttemptsBeforeLockout")]
|
||||||
|
pub login_attempts_before_lockout: i32,
|
||||||
|
#[serde(rename = "MaxActiveSessions")]
|
||||||
|
pub max_active_sessions: i32,
|
||||||
|
#[serde(rename = "EnablePublicSharing")]
|
||||||
|
pub enable_public_sharing: bool,
|
||||||
|
#[serde(rename = "BlockedMediaFolders")]
|
||||||
|
pub blocked_media_folders: Vec<String>,
|
||||||
|
#[serde(rename = "BlockedChannels")]
|
||||||
|
pub blocked_channels: Vec<String>,
|
||||||
|
#[serde(rename = "RemoteClientBitrateLimit")]
|
||||||
|
pub remote_client_bitrate_limit: i32,
|
||||||
|
#[serde(rename = "AuthenticationProviderId")]
|
||||||
|
pub authentication_provider_id: String,
|
||||||
|
#[serde(rename = "PasswordResetProviderId")]
|
||||||
|
pub password_reset_provider_id: String,
|
||||||
|
#[serde(rename = "SyncPlayAccess")]
|
||||||
|
pub sync_play_access: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
#[serde(rename = "PlayState")]
|
||||||
|
pub play_state: PlayState,
|
||||||
|
#[serde(rename = "AdditionalUsers")]
|
||||||
|
pub additional_users: Vec<String>,
|
||||||
|
#[serde(rename = "Capabilities")]
|
||||||
|
pub capabilities: Capabilities,
|
||||||
|
#[serde(rename = "RemoteEndPoint")]
|
||||||
|
pub remote_end_point: String,
|
||||||
|
#[serde(rename = "Id")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "UserId")]
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(rename = "UserName")]
|
||||||
|
pub user_name: String,
|
||||||
|
#[serde(rename = "Client")]
|
||||||
|
pub client: String,
|
||||||
|
#[serde(rename = "LastActivityDate")]
|
||||||
|
pub last_activity_date: String,
|
||||||
|
#[serde(rename = "LastPlaybackCheckIn")]
|
||||||
|
pub last_playback_check_in: String,
|
||||||
|
#[serde(rename = "DeviceName")]
|
||||||
|
pub device_name: String,
|
||||||
|
#[serde(rename = "DeviceType")]
|
||||||
|
pub device_type: String,
|
||||||
|
#[serde(rename = "NowPlayingItem")]
|
||||||
|
pub now_playing_item: Option<String>,
|
||||||
|
#[serde(rename = "DeviceId")]
|
||||||
|
pub device_id: String,
|
||||||
|
#[serde(rename = "ApplicationVersion")]
|
||||||
|
pub application_version: String,
|
||||||
|
#[serde(rename = "IsActive")]
|
||||||
|
pub is_active: bool,
|
||||||
|
#[serde(rename = "SupportsMediaControl")]
|
||||||
|
pub supports_media_control: bool,
|
||||||
|
#[serde(rename = "SupportsRemoteControl")]
|
||||||
|
pub supports_remote_control: bool,
|
||||||
|
#[serde(rename = "HasCustomDeviceName")]
|
||||||
|
pub has_custom_device_name: bool,
|
||||||
|
#[serde(rename = "ServerId")]
|
||||||
|
pub server_id: String,
|
||||||
|
#[serde(rename = "SupportedCommands")]
|
||||||
|
pub supported_commands: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PlayState {
|
||||||
|
#[serde(rename = "CanSeek")]
|
||||||
|
pub can_seek: bool,
|
||||||
|
#[serde(rename = "IsPaused")]
|
||||||
|
pub is_paused: bool,
|
||||||
|
#[serde(rename = "IsMuted")]
|
||||||
|
pub is_muted: bool,
|
||||||
|
#[serde(rename = "RepeatMode")]
|
||||||
|
pub repeat_mode: String,
|
||||||
|
#[serde(rename = "ShuffleMode")]
|
||||||
|
pub shuffle_mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Capabilities {
|
||||||
|
#[serde(rename = "PlayableMediaTypes")]
|
||||||
|
pub playable_media_types: Vec<String>,
|
||||||
|
#[serde(rename = "SupportedCommands")]
|
||||||
|
pub supported_commands: Vec<String>,
|
||||||
|
#[serde(rename = "SupportsMediaControl")]
|
||||||
|
pub supports_media_control: bool,
|
||||||
|
#[serde(rename = "SupportsContentUploading")]
|
||||||
|
pub supports_content_uploading: bool,
|
||||||
|
#[serde(rename = "SupportsPersistentIdentifier")]
|
||||||
|
pub supports_persistent_identifier: bool,
|
||||||
|
#[serde(rename = "SupportsSync")]
|
||||||
|
pub supports_sync: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_password(password: &str, stored_hash: &str) -> Result<bool> {
|
||||||
|
// Handle PBKDF2-SHA512 format: $PBKDF2-SHA512$iterations=210000$salt$hash
|
||||||
|
if stored_hash.starts_with("$PBKDF2-SHA512$") {
|
||||||
|
let parts: Vec<&str> = stored_hash.split('$').collect();
|
||||||
|
if parts.len() != 5 {
|
||||||
|
return Err(anyhow!("Invalid password hash format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let iterations_part = parts[2];
|
||||||
|
let salt_part = parts[3];
|
||||||
|
let hash_part = parts[4];
|
||||||
|
|
||||||
|
let iterations: u32 = iterations_part
|
||||||
|
.strip_prefix("iterations=")
|
||||||
|
.ok_or_else(|| anyhow!("Invalid iterations format"))?
|
||||||
|
.parse()?;
|
||||||
|
|
||||||
|
let salt = hex::decode(salt_part)?;
|
||||||
|
let expected_hash = hex::decode(hash_part)?;
|
||||||
|
|
||||||
|
let mut result = vec![0u8; expected_hash.len()];
|
||||||
|
pbkdf2::pbkdf2_hmac::<sha2::Sha512>(password.as_bytes(), &salt, iterations, &mut result);
|
||||||
|
|
||||||
|
Ok(result == expected_hash)
|
||||||
|
} else {
|
||||||
|
// Fallback for other hash formats
|
||||||
|
match PasswordHash::new(stored_hash) {
|
||||||
|
Ok(parsed_hash) => Ok(Pbkdf2.verify_password(password.as_bytes(), &parsed_hash).is_ok()),
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_authorization_header(auth_header: &str) -> Option<(String, String, String, String)> {
|
||||||
|
// Parse MediaBrowser authorization header
|
||||||
|
// Format: MediaBrowser Client="...", Version="...", DeviceId="...", Device="...", Token="..."
|
||||||
|
if !auth_header.starts_with("MediaBrowser ") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let params_part = &auth_header[12..]; // Remove "MediaBrowser "
|
||||||
|
let mut client = String::new();
|
||||||
|
let mut version = String::new();
|
||||||
|
let mut device_id = String::new();
|
||||||
|
let mut device = String::new();
|
||||||
|
|
||||||
|
for param in params_part.split(", ") {
|
||||||
|
if let Some((key, value)) = param.split_once('=') {
|
||||||
|
let value = value.trim_matches('"');
|
||||||
|
match key {
|
||||||
|
"Client" => client = value.replace('+', " "),
|
||||||
|
"Version" => version = value.to_string(),
|
||||||
|
"DeviceId" => device_id = value.to_string(),
|
||||||
|
"Device" => device = value.replace('+', " "),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !client.is_empty() && !version.is_empty() && !device_id.is_empty() && !device.is_empty() {
|
||||||
|
Some((client, version, device_id, device))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UserConfiguration {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
play_default_audio_track: true,
|
||||||
|
subtitle_language_preference: String::new(),
|
||||||
|
display_missing_episodes: false,
|
||||||
|
grouped_folders: Vec::new(),
|
||||||
|
subtitle_mode: "Default".to_string(),
|
||||||
|
display_collections_view: false,
|
||||||
|
enable_local_password: false,
|
||||||
|
ordered_views: Vec::new(),
|
||||||
|
latest_items_excludes: Vec::new(),
|
||||||
|
my_media_excludes: Vec::new(),
|
||||||
|
hide_played_in_latest: true,
|
||||||
|
remember_audio_selections: true,
|
||||||
|
remember_subtitle_selections: true,
|
||||||
|
enable_next_episode_auto_play: true,
|
||||||
|
cast_receiver_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UserPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
is_administrator: true,
|
||||||
|
is_hidden: false,
|
||||||
|
enable_collection_management: true,
|
||||||
|
enable_subtitle_management: true,
|
||||||
|
enable_lyric_management: true,
|
||||||
|
is_disabled: false,
|
||||||
|
max_parental_rating: None,
|
||||||
|
blocked_tags: Vec::new(),
|
||||||
|
allowed_tags: Vec::new(),
|
||||||
|
enable_user_preference_access: true,
|
||||||
|
access_schedules: Vec::new(),
|
||||||
|
block_unrated_items: Vec::new(),
|
||||||
|
enable_remote_control_of_other_users: true,
|
||||||
|
enable_shared_device_control: true,
|
||||||
|
enable_remote_access: true,
|
||||||
|
enable_live_tv_management: true,
|
||||||
|
enable_live_tv_access: true,
|
||||||
|
enable_media_playback: true,
|
||||||
|
enable_audio_playback_transcoding: true,
|
||||||
|
enable_video_playback_transcoding: true,
|
||||||
|
enable_playback_remuxing: true,
|
||||||
|
force_remote_source_transcoding: false,
|
||||||
|
enable_content_deletion: true,
|
||||||
|
enable_content_deletion_from_folders: Vec::new(),
|
||||||
|
enable_content_downloading: true,
|
||||||
|
enable_sync_transcoding: true,
|
||||||
|
enable_media_conversion: true,
|
||||||
|
enabled_devices: Vec::new(),
|
||||||
|
enable_all_devices: true,
|
||||||
|
enabled_channels: Vec::new(),
|
||||||
|
enable_all_channels: true,
|
||||||
|
enabled_folders: Vec::new(),
|
||||||
|
enable_all_folders: true,
|
||||||
|
invalid_login_attempt_count: 0,
|
||||||
|
login_attempts_before_lockout: -1,
|
||||||
|
max_active_sessions: 0,
|
||||||
|
enable_public_sharing: true,
|
||||||
|
blocked_media_folders: Vec::new(),
|
||||||
|
blocked_channels: Vec::new(),
|
||||||
|
remote_client_bitrate_limit: 0,
|
||||||
|
authentication_provider_id: "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider".to_string(),
|
||||||
|
password_reset_provider_id: "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider".to_string(),
|
||||||
|
sync_play_access: "CreateAndJoinGroups".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
src/config.rs
Normal file
45
src/config.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub sftp_host: String,
|
||||||
|
pub sftp_port: u16,
|
||||||
|
pub sftp_user: String,
|
||||||
|
pub sftp_password: String,
|
||||||
|
pub sftp_path: String,
|
||||||
|
pub jellyfin_url: String,
|
||||||
|
pub jellyfin_api_key: String,
|
||||||
|
pub jellyfin_power_on_command: String,
|
||||||
|
pub jellyfin_hibernate_command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
|
||||||
|
Ok(Config {
|
||||||
|
sftp_host: env::var("SFTP_HOST")
|
||||||
|
.map_err(|_| anyhow!("SFTP_HOST environment variable not set"))?,
|
||||||
|
sftp_port: env::var("SFTP_PORT")
|
||||||
|
.unwrap_or_else(|_| "22".to_string())
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| anyhow!("Invalid SFTP_PORT"))?,
|
||||||
|
sftp_user: env::var("SFTP_USER")
|
||||||
|
.map_err(|_| anyhow!("SFTP_USER environment variable not set"))?,
|
||||||
|
sftp_password: env::var("SFTP_PASSWORD")
|
||||||
|
.map_err(|_| anyhow!("SFTP_PASSWORD environment variable not set"))?,
|
||||||
|
sftp_path: env::var("SFTP_PATH")
|
||||||
|
.unwrap_or_else(|_| "/var/lib/jellyfin/data/jellyfin.db".to_string()),
|
||||||
|
jellyfin_url: env::var("JELLYFIN_URL")
|
||||||
|
.map_err(|_| anyhow!("JELLYFIN_URL environment variable not set"))?,
|
||||||
|
jellyfin_api_key: env::var("JELLYFIN_API_KEY")
|
||||||
|
.map_err(|_| anyhow!("JELLYFIN_API_KEY environment variable not set"))?,
|
||||||
|
jellyfin_power_on_command: env::var("JELLYFIN_POWER_ON_COMMAND")
|
||||||
|
.map_err(|_| anyhow!("JELLYFIN_POWER_ON_COMMAND environment variable not set"))?,
|
||||||
|
jellyfin_hibernate_command: env::var("JELLYFIN_HIBERNATE_COMMAND")
|
||||||
|
.map_err(|_| anyhow!("JELLYFIN_HIBERNATE_COMMAND environment variable not set"))?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
113
src/database.rs
Normal file
113
src/database.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Device {
|
||||||
|
pub id: i64,
|
||||||
|
pub user_id: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub app_name: String,
|
||||||
|
pub app_version: String,
|
||||||
|
pub device_name: String,
|
||||||
|
pub device_id: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub date_created: String,
|
||||||
|
pub date_modified: String,
|
||||||
|
pub date_last_activity: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
conn: Connection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
Ok(Database { conn })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_user_by_username(&self, username: &str) -> Result<Option<User>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT Id, Username, Password FROM Users WHERE Username = ?1 COLLATE NOCASE"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let user_result = stmt.query_row([username], |row| {
|
||||||
|
Ok(User {
|
||||||
|
id: row.get(0)?,
|
||||||
|
username: row.get(1)?,
|
||||||
|
password: row.get(2)?,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
match user_result {
|
||||||
|
Ok(user) => Ok(Some(user)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_device_by_access_token(&self, access_token: &str) -> Result<Option<Device>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT Id, UserId, AccessToken, AppName, AppVersion, DeviceName, DeviceId, IsActive, DateCreated, DateModified, DateLastActivity FROM Devices WHERE AccessToken = ?1"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let device_result = stmt.query_row([access_token], |row| {
|
||||||
|
Ok(Device {
|
||||||
|
id: row.get(0)?,
|
||||||
|
user_id: row.get(1)?,
|
||||||
|
access_token: row.get(2)?,
|
||||||
|
app_name: row.get(3)?,
|
||||||
|
app_version: row.get(4)?,
|
||||||
|
device_name: row.get(5)?,
|
||||||
|
device_id: row.get(6)?,
|
||||||
|
is_active: row.get::<_, i64>(7)? != 0,
|
||||||
|
date_created: row.get(8)?,
|
||||||
|
date_modified: row.get(9)?,
|
||||||
|
date_last_activity: row.get(10)?,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
match device_result {
|
||||||
|
Ok(device) => Ok(Some(device)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_device_by_device_id(&self, device_id: &str) -> Result<Option<Device>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT Id, UserId, AccessToken, AppName, AppVersion, DeviceName, DeviceId, IsActive, DateCreated, DateModified, DateLastActivity FROM Devices WHERE DeviceId = ?1"
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let device_result = stmt.query_row([device_id], |row| {
|
||||||
|
Ok(Device {
|
||||||
|
id: row.get(0)?,
|
||||||
|
user_id: row.get(1)?,
|
||||||
|
access_token: row.get(2)?,
|
||||||
|
app_name: row.get(3)?,
|
||||||
|
app_version: row.get(4)?,
|
||||||
|
device_name: row.get(5)?,
|
||||||
|
device_id: row.get(6)?,
|
||||||
|
is_active: row.get::<_, i64>(7)? != 0,
|
||||||
|
date_created: row.get(8)?,
|
||||||
|
date_modified: row.get(9)?,
|
||||||
|
date_last_activity: row.get(10)?,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
match device_result {
|
||||||
|
Ok(device) => Ok(Some(device)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
106
src/jellyfin.rs
Normal file
106
src/jellyfin.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::process::Command;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SystemInfo {
|
||||||
|
#[serde(rename = "LocalAddress")]
|
||||||
|
pub local_address: String,
|
||||||
|
#[serde(rename = "ServerName")]
|
||||||
|
pub server_name: String,
|
||||||
|
#[serde(rename = "Version")]
|
||||||
|
pub version: String,
|
||||||
|
#[serde(rename = "ProductName")]
|
||||||
|
pub product_name: String,
|
||||||
|
#[serde(rename = "OperatingSystem")]
|
||||||
|
pub operating_system: String,
|
||||||
|
#[serde(rename = "Id")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "StartupWizardCompleted")]
|
||||||
|
pub startup_wizard_completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BrandingConfig {
|
||||||
|
#[serde(rename = "LoginDisclaimer")]
|
||||||
|
pub login_disclaimer: String,
|
||||||
|
#[serde(rename = "CustomCss")]
|
||||||
|
pub custom_css: String,
|
||||||
|
#[serde(rename = "SplashscreenEnabled")]
|
||||||
|
pub splashscreen_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JellyfinClient {
|
||||||
|
client: Client,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JellyfinClient {
|
||||||
|
pub fn new(base_url: String, _api_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
base_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_base_url(&self) -> &str {
|
||||||
|
&self.base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_online(&self) -> bool {
|
||||||
|
let url = format!("{}/System/Info/Public", self.base_url);
|
||||||
|
match self.client.get(&url).send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
let is_ok = response.status().is_success();
|
||||||
|
if is_ok {
|
||||||
|
info!("Jellyfin server is online");
|
||||||
|
} else {
|
||||||
|
warn!("Jellyfin server responded with status: {}", response.status());
|
||||||
|
}
|
||||||
|
is_ok
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to connect to Jellyfin server: {}", e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_system_info(&self) -> Result<SystemInfo> {
|
||||||
|
let url = format!("{}/System/Info/Public", self.base_url);
|
||||||
|
let response = self.client.get(&url).send().await?;
|
||||||
|
let system_info: SystemInfo = response.json().await?;
|
||||||
|
Ok(system_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_branding_config(&self) -> Result<BrandingConfig> {
|
||||||
|
let url = format!("{}/Branding/Configuration", self.base_url);
|
||||||
|
let response = self.client.get(&url).send().await?;
|
||||||
|
let branding: BrandingConfig = response.json().await?;
|
||||||
|
Ok(branding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn power_on_server(command: &str) -> Result<()> {
|
||||||
|
info!("Powering on server with command: {}", command);
|
||||||
|
|
||||||
|
let parts: Vec<&str> = command.split_whitespace().collect();
|
||||||
|
if parts.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Empty power on command"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = Command::new(parts[0])
|
||||||
|
.args(&parts[1..])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
info!("Power on command executed successfully");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
error!("Power on command failed: {}", stderr);
|
||||||
|
Err(anyhow::anyhow!("Power on command failed: {}", stderr))
|
||||||
|
}
|
||||||
|
}
|
62
src/main.rs
Normal file
62
src/main.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
mod auth;
|
||||||
|
mod config;
|
||||||
|
mod database;
|
||||||
|
mod jellyfin;
|
||||||
|
mod proxy;
|
||||||
|
mod server;
|
||||||
|
mod sftp;
|
||||||
|
mod websocket;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use config::Config;
|
||||||
|
use server::create_app;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::time::{interval, Duration};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
// Initialize tracing
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = Config::load()?;
|
||||||
|
info!("Loaded configuration");
|
||||||
|
|
||||||
|
// Initialize shared state
|
||||||
|
let app_state = server::AppState::new(config).await?;
|
||||||
|
info!("Initialized application state");
|
||||||
|
|
||||||
|
// Start background tasks
|
||||||
|
let state_clone = app_state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
background_tasks(state_clone).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and start the server
|
||||||
|
let app = create_app(app_state);
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:8096").await?;
|
||||||
|
|
||||||
|
info!("Starting server on port 8096");
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn background_tasks(state: Arc<server::AppState>) {
|
||||||
|
let mut interval = interval(Duration::from_secs(15));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
|
||||||
|
// Check if Jellyfin server is online
|
||||||
|
if let Err(e) = state.update_jellyfin_status().await {
|
||||||
|
error!("Failed to update Jellyfin status: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for database updates
|
||||||
|
if let Err(e) = state.check_database_updates().await {
|
||||||
|
error!("Failed to check database updates: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
172
src/proxy.rs
Normal file
172
src/proxy.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::jellyfin::JellyfinClient;
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode},
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
pub async fn proxy_to_jellyfin_with_retry<F>(
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
query: Option<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Vec<u8>,
|
||||||
|
jellyfin_client: &JellyfinClient,
|
||||||
|
status_updater: F,
|
||||||
|
) -> Result<Response<Body>, StatusCode>
|
||||||
|
where
|
||||||
|
F: Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>> + Send + Sync,
|
||||||
|
{
|
||||||
|
let max_retries = 60; // 5 minutes with 5-second intervals
|
||||||
|
let retry_interval = Duration::from_secs(5);
|
||||||
|
|
||||||
|
for attempt in 0..max_retries {
|
||||||
|
match proxy_to_jellyfin_once(method.clone(), path, query.clone(), headers.clone(), body.clone(), jellyfin_client).await {
|
||||||
|
Ok(response) => return Ok(response),
|
||||||
|
Err(StatusCode::BAD_GATEWAY) => {
|
||||||
|
// Check if this is a connection error - update server status
|
||||||
|
let is_online = status_updater().await;
|
||||||
|
|
||||||
|
if !is_online {
|
||||||
|
if attempt == 0 {
|
||||||
|
info!("Jellyfin server is offline, waiting for it to come back online...");
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < max_retries - 1 {
|
||||||
|
debug!("Attempt {}/{} - server still offline, retrying in {} seconds",
|
||||||
|
attempt + 1, max_retries, retry_interval.as_secs());
|
||||||
|
sleep(retry_interval).await;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
error!("Server failed to come online after {} attempts", max_retries);
|
||||||
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server is reported as online but request failed, try once more
|
||||||
|
warn!("Server reported as online but proxy failed, retrying once more...");
|
||||||
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
return proxy_to_jellyfin_once(method, path, query, headers, body, jellyfin_client).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(other_error) => {
|
||||||
|
// For other errors, don't retry
|
||||||
|
return Err(other_error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_to_jellyfin_once(
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
query: Option<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Vec<u8>,
|
||||||
|
jellyfin_client: &JellyfinClient,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
let query_str = query.as_deref();
|
||||||
|
|
||||||
|
debug!("Proxying {} {} to Jellyfin", method.as_str(), path);
|
||||||
|
|
||||||
|
// Convert axum headers to reqwest headers
|
||||||
|
let mut reqwest_headers = reqwest::header::HeaderMap::new();
|
||||||
|
for (name, value) in &headers {
|
||||||
|
// Skip certain headers that should be handled by the proxy
|
||||||
|
if name.as_str().to_lowercase() == "host" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(header_name) = reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) {
|
||||||
|
if let Ok(header_value) = reqwest::header::HeaderValue::from_bytes(value.as_bytes()) {
|
||||||
|
reqwest_headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a client that doesn't follow redirects automatically
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let mut url = format!("{}{}", jellyfin_client.get_base_url(), path);
|
||||||
|
if let Some(q) = query_str {
|
||||||
|
url = format!("{}?{}", url, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut request = match method.as_str() {
|
||||||
|
"GET" => client.get(&url),
|
||||||
|
"POST" => client.post(&url),
|
||||||
|
"PUT" => client.put(&url),
|
||||||
|
"DELETE" => client.delete(&url),
|
||||||
|
"PATCH" => client.patch(&url),
|
||||||
|
"HEAD" => client.head(&url),
|
||||||
|
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy headers
|
||||||
|
for (name, value) in &reqwest_headers {
|
||||||
|
request = request.header(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add body if present
|
||||||
|
if !body.is_empty() {
|
||||||
|
request = request.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
match request.send().await {
|
||||||
|
Ok(response) => {
|
||||||
|
let status_code = response.status().as_u16();
|
||||||
|
let mut response_headers = HeaderMap::new();
|
||||||
|
|
||||||
|
// Copy response headers
|
||||||
|
for (name, value) in response.headers() {
|
||||||
|
// Skip certain headers that might cause issues
|
||||||
|
let header_name_str = name.as_str().to_lowercase();
|
||||||
|
if header_name_str == "transfer-encoding" ||
|
||||||
|
header_name_str == "connection" ||
|
||||||
|
header_name_str == "upgrade" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(header_name) = HeaderName::try_from(name.as_str()) {
|
||||||
|
if let Ok(header_value) = HeaderValue::from_bytes(value.as_bytes()) {
|
||||||
|
response_headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_bytes = match response.bytes().await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read response body: {}", e);
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response_builder = Response::builder()
|
||||||
|
.status(StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR));
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
if let Some(headers_mut) = response_builder.headers_mut() {
|
||||||
|
*headers_mut = response_headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
match response_builder.body(Body::from(body_bytes)) {
|
||||||
|
Ok(response) => Ok(response),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to build response: {}", e);
|
||||||
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to proxy request to Jellyfin: {}", e);
|
||||||
|
Err(StatusCode::BAD_GATEWAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
898
src/server.rs
Normal file
898
src/server.rs
Normal file
@@ -0,0 +1,898 @@
|
|||||||
|
use crate::{
|
||||||
|
auth::{parse_authorization_header, verify_password, AuthRequest},
|
||||||
|
config::Config,
|
||||||
|
database::{Database, Device},
|
||||||
|
jellyfin::{power_on_server, BrandingConfig, JellyfinClient, SystemInfo},
|
||||||
|
proxy::proxy_to_jellyfin_with_retry,
|
||||||
|
sftp::{calculate_local_file_hash, SftpClient},
|
||||||
|
websocket::proxy_websocket,
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{ws::WebSocketUpgrade, State},
|
||||||
|
http::{HeaderMap, Method, StatusCode, Uri},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::any,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::Path as StdPath,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tokio::time::sleep;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
const LOCAL_DB_PATH: &str = "./jellyfin.db";
|
||||||
|
const SYSTEM_INFO_PATH: &str = "./system_info.json";
|
||||||
|
|
||||||
|
// Embedded login HTML content at build time
|
||||||
|
const LOGIN_HTML: &str = include_str!("../login.html");
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
config: Config,
|
||||||
|
jellyfin_client: JellyfinClient,
|
||||||
|
sftp_client: SftpClient,
|
||||||
|
cached_system_info: RwLock<Option<SystemInfo>>,
|
||||||
|
is_jellyfin_online: RwLock<bool>,
|
||||||
|
last_db_hash: RwLock<Option<String>>,
|
||||||
|
last_activity: RwLock<Option<Instant>>,
|
||||||
|
is_powering_on: RwLock<bool>,
|
||||||
|
power_on_start_time: RwLock<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub async fn new(config: Config) -> Result<Arc<Self>> {
|
||||||
|
let jellyfin_client = JellyfinClient::new(config.jellyfin_url.clone(), config.jellyfin_api_key.clone());
|
||||||
|
let sftp_client = SftpClient::new(
|
||||||
|
config.sftp_host.clone(),
|
||||||
|
config.sftp_port,
|
||||||
|
config.sftp_user.clone(),
|
||||||
|
config.sftp_password.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to load cached system info
|
||||||
|
let cached_system_info = if StdPath::new(SYSTEM_INFO_PATH).exists() {
|
||||||
|
match std::fs::read_to_string(SYSTEM_INFO_PATH) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).ok(),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial database download
|
||||||
|
if let Err(e) = sftp_client.download_file(&config.sftp_path, LOCAL_DB_PATH).await {
|
||||||
|
warn!("Failed to download initial database: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_state = Self {
|
||||||
|
config,
|
||||||
|
jellyfin_client,
|
||||||
|
sftp_client,
|
||||||
|
cached_system_info: RwLock::new(cached_system_info),
|
||||||
|
is_jellyfin_online: RwLock::new(false),
|
||||||
|
last_db_hash: RwLock::new(None),
|
||||||
|
last_activity: RwLock::new(None),
|
||||||
|
is_powering_on: RwLock::new(false),
|
||||||
|
power_on_start_time: RwLock::new(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial status check
|
||||||
|
app_state.update_jellyfin_status().await?;
|
||||||
|
|
||||||
|
// Start background database update checker
|
||||||
|
let state_clone = Arc::new(app_state);
|
||||||
|
let checker_state = state_clone.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); // Check every 30 seconds
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(e) = checker_state.check_database_updates().await {
|
||||||
|
warn!("Failed to check database updates: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(state_clone)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_jellyfin_status(&self) -> Result<()> {
|
||||||
|
let is_online = self.jellyfin_client.is_online().await;
|
||||||
|
*self.is_jellyfin_online.write().unwrap() = is_online;
|
||||||
|
|
||||||
|
if is_online {
|
||||||
|
// Update system info cache
|
||||||
|
if let Ok(system_info) = self.jellyfin_client.get_system_info().await {
|
||||||
|
*self.cached_system_info.write().unwrap() = Some(system_info.clone());
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
if let Ok(json_str) = serde_json::to_string_pretty(&system_info) {
|
||||||
|
let _ = std::fs::write(SYSTEM_INFO_PATH, json_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_database_updates(&self) -> Result<()> {
|
||||||
|
match self.sftp_client.get_file_hash(&self.config.sftp_path).await {
|
||||||
|
Ok(remote_hash) => {
|
||||||
|
let current_hash = if StdPath::new(LOCAL_DB_PATH).exists() {
|
||||||
|
calculate_local_file_hash(LOCAL_DB_PATH).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let last_hash = self.last_db_hash.read().unwrap().clone();
|
||||||
|
|
||||||
|
if Some(&remote_hash) != last_hash.as_ref() || current_hash.as_ref() != Some(&remote_hash) {
|
||||||
|
info!("Database hash changed, downloading new version");
|
||||||
|
|
||||||
|
if let Err(e) = self.sftp_client.download_file(&self.config.sftp_path, LOCAL_DB_PATH).await {
|
||||||
|
error!("Failed to download updated database: {}", e);
|
||||||
|
} else {
|
||||||
|
*self.last_db_hash.write().unwrap() = Some(remote_hash);
|
||||||
|
info!("Database updated successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to check remote database hash: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_online(&self) -> bool {
|
||||||
|
*self.is_jellyfin_online.read().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_powering_on(&self) -> bool {
|
||||||
|
*self.is_powering_on.read().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_power_on_timeout(&self) -> bool {
|
||||||
|
if let Some(start_time) = *self.power_on_start_time.read().unwrap() {
|
||||||
|
start_time.elapsed() > Duration::from_secs(300) // 5 minutes
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_and_check_status(&self) -> bool {
|
||||||
|
let is_online = self.jellyfin_client.is_online().await;
|
||||||
|
*self.is_jellyfin_online.write().unwrap() = is_online;
|
||||||
|
|
||||||
|
if is_online {
|
||||||
|
// Reset power-on state if server came online
|
||||||
|
*self.is_powering_on.write().unwrap() = false;
|
||||||
|
*self.power_on_start_time.write().unwrap() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
is_online
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_activity(&self) {
|
||||||
|
*self.last_activity.write().unwrap() = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for database updates on every authentication-related operation
|
||||||
|
async fn get_database_with_update_check(&self) -> Result<Database> {
|
||||||
|
// Check for updates first
|
||||||
|
if let Err(e) = self.check_database_updates().await {
|
||||||
|
warn!("Failed to check database updates during access: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !StdPath::new(LOCAL_DB_PATH).exists() {
|
||||||
|
return Err(anyhow!("Local database not found"));
|
||||||
|
}
|
||||||
|
Database::new(LOCAL_DB_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authenticate_user(&self, username: &str, password: &str) -> Result<Option<crate::database::User>> {
|
||||||
|
let db = self.get_database_with_update_check().await?;
|
||||||
|
|
||||||
|
if let Some(user) = db.get_user_by_username(username)? {
|
||||||
|
if verify_password(password, &user.password)? {
|
||||||
|
return Ok(Some(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_token(&self, token: &str) -> Result<Option<Device>> {
|
||||||
|
let db = self.get_database_with_update_check().await?;
|
||||||
|
db.get_device_by_access_token(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_device_id(&self, device_id: &str) -> Result<Option<Device>> {
|
||||||
|
let db = self.get_database_with_update_check().await?;
|
||||||
|
db.get_device_by_device_id(device_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_app(state: Arc<AppState>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/", any(handle_root_request))
|
||||||
|
.route("/web", any(handle_web_request))
|
||||||
|
.route("/web/", any(handle_web_request))
|
||||||
|
.route("/web/*path", any(handle_web_request))
|
||||||
|
.route("/Users/AuthenticateByName", any(handle_auth_request))
|
||||||
|
.route("/System/Info/Public", any(handle_system_info_request))
|
||||||
|
.route("/Branding/Configuration", any(handle_branding_request))
|
||||||
|
.fallback(handle_fallback_request)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_root_request(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
method: Method,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
state.update_activity();
|
||||||
|
|
||||||
|
// Check authentication first
|
||||||
|
let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await;
|
||||||
|
|
||||||
|
// If server is offline but user is authenticated, power it on and wait
|
||||||
|
if !state.is_online() && is_authenticated {
|
||||||
|
ensure_server_online_for_authenticated_request(&state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server is online (either was online or just came online), proxy the request to Jellyfin
|
||||||
|
if state.is_online() {
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().map(|q| q.to_string());
|
||||||
|
|
||||||
|
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
||||||
|
Ok(bytes) => bytes.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read request body: {}", e);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return proxy_to_jellyfin_with_retry(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body_bytes,
|
||||||
|
&state.jellyfin_client,
|
||||||
|
{
|
||||||
|
let state_clone = state.clone();
|
||||||
|
move || {
|
||||||
|
let state_clone = state_clone.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
state_clone.update_and_check_status().await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, server is offline and user is not authenticated
|
||||||
|
// Serve login page only for GET requests
|
||||||
|
if method == Method::GET {
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("content-type", "text/html")
|
||||||
|
.body(Body::from(LOGIN_HTML))
|
||||||
|
.unwrap())
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_web_request(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
method: Method,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
state.update_activity();
|
||||||
|
|
||||||
|
// Check authentication first
|
||||||
|
let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await;
|
||||||
|
|
||||||
|
// If server is offline but user is authenticated, power it on and wait
|
||||||
|
if !state.is_online() && is_authenticated {
|
||||||
|
ensure_server_online_for_authenticated_request(&state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server is online (either was online or just came online), proxy the request to Jellyfin
|
||||||
|
if state.is_online() {
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().map(|q| q.to_string());
|
||||||
|
|
||||||
|
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
||||||
|
Ok(bytes) => bytes.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read request body: {}", e);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return proxy_to_jellyfin_with_retry(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body_bytes,
|
||||||
|
&state.jellyfin_client,
|
||||||
|
{
|
||||||
|
let state_clone = state.clone();
|
||||||
|
move || {
|
||||||
|
let state_clone = state_clone.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
state_clone.update_and_check_status().await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, server is offline and user is not authenticated
|
||||||
|
// Serve login page only for GET requests
|
||||||
|
if method == Method::GET {
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("content-type", "text/html")
|
||||||
|
.body(Body::from(LOGIN_HTML))
|
||||||
|
.unwrap())
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_auth_request(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
method: Method,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
state.update_activity();
|
||||||
|
|
||||||
|
// Check authentication first (for existing session tokens)
|
||||||
|
let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await;
|
||||||
|
|
||||||
|
// If server is offline but user is authenticated, power it on and wait
|
||||||
|
if !state.is_online() && is_authenticated {
|
||||||
|
ensure_server_online_for_authenticated_request(&state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server is online, proxy the request to Jellyfin
|
||||||
|
if state.is_online() {
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().map(|q| q.to_string());
|
||||||
|
|
||||||
|
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
||||||
|
Ok(bytes) => bytes.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read request body: {}", e);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return proxy_to_jellyfin_with_retry(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body_bytes,
|
||||||
|
&state.jellyfin_client,
|
||||||
|
{
|
||||||
|
let state_clone = state.clone();
|
||||||
|
move || {
|
||||||
|
let state_clone = state_clone.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
state_clone.update_and_check_status().await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server is offline, handle authentication locally
|
||||||
|
if method == Method::POST {
|
||||||
|
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
||||||
|
Ok(bytes) => bytes.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read request body: {}", e);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_request: AuthRequest = match serde_json::from_slice(&body_bytes) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to parse auth request: {}", e);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate credentials locally first - don't start server if invalid
|
||||||
|
match state.authenticate_user(&auth_request.username, &auth_request.pw).await {
|
||||||
|
Ok(Some(_user)) => {
|
||||||
|
info!("Credentials validated locally, starting server and proxying to Jellyfin");
|
||||||
|
// User is valid, power on server and wait for it to come online
|
||||||
|
ensure_server_online_for_authenticated_request(&state).await?;
|
||||||
|
|
||||||
|
// Once server is online, proxy the original request to Jellyfin for real auth response
|
||||||
|
if state.is_online() {
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().map(|q| q.to_string());
|
||||||
|
return proxy_to_jellyfin_with_retry(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body_bytes,
|
||||||
|
&state.jellyfin_client,
|
||||||
|
{
|
||||||
|
let state_clone = state.clone();
|
||||||
|
move || {
|
||||||
|
let state_clone = state_clone.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
state_clone.update_and_check_status().await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await;
|
||||||
|
} else {
|
||||||
|
error!("Server failed to come online after authentication");
|
||||||
|
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
warn!("Authentication failed for user: {} - not starting server", auth_request.username);
|
||||||
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Database error during authentication: {}", e);
|
||||||
|
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(StatusCode::METHOD_NOT_ALLOWED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_system_info_request(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
method: Method,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
state.update_activity();
|
||||||
|
|
||||||
|
// Check authentication first
|
||||||
|
let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await;
|
||||||
|
|
||||||
|
// If server is offline but user is authenticated, power it on
|
||||||
|
if !state.is_online() && is_authenticated {
|
||||||
|
ensure_server_online_for_authenticated_request(&state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server is online, proxy the request to Jellyfin
|
||||||
|
if state.is_online() {
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().map(|q| q.to_string());
|
||||||
|
|
||||||
|
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
||||||
|
Ok(bytes) => bytes.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read request body: {}", e);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return proxy_to_jellyfin_with_retry(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body_bytes,
|
||||||
|
&state.jellyfin_client,
|
||||||
|
{
|
||||||
|
let state_clone = state.clone();
|
||||||
|
move || {
|
||||||
|
let state_clone = state_clone.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
state_clone.update_and_check_status().await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server is offline, return cached system info (this endpoint is usually public)
|
||||||
|
let system_info = get_system_info_impl(state).await;
|
||||||
|
let json_body = serde_json::to_string(&system_info).unwrap();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(json_body))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_branding_request(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
method: Method,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
state.update_activity();
|
||||||
|
|
||||||
|
// Check authentication first
|
||||||
|
let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await;
|
||||||
|
|
||||||
|
// If server is offline but user is authenticated, power it on
|
||||||
|
if !state.is_online() && is_authenticated {
|
||||||
|
ensure_server_online_for_authenticated_request(&state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server is online, proxy the request to Jellyfin
|
||||||
|
if state.is_online() {
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().map(|q| q.to_string());
|
||||||
|
|
||||||
|
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
||||||
|
Ok(bytes) => bytes.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read request body: {}", e);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return proxy_to_jellyfin_with_retry(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body_bytes,
|
||||||
|
&state.jellyfin_client,
|
||||||
|
{
|
||||||
|
let state_clone = state.clone();
|
||||||
|
move || {
|
||||||
|
let state_clone = state_clone.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
state_clone.update_and_check_status().await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If server is offline, return offline branding config (this endpoint is usually public)
|
||||||
|
let branding_config = get_branding_config_impl(state).await;
|
||||||
|
let json_body = serde_json::to_string(&branding_config).unwrap();
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(json_body))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_system_info_impl(state: Arc<AppState>) -> SystemInfo {
|
||||||
|
if state.is_online() {
|
||||||
|
// Try to get fresh info from Jellyfin
|
||||||
|
if let Ok(info) = state.jellyfin_client.get_system_info().await {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cached info or default
|
||||||
|
let cached_info = state.cached_system_info.read().unwrap();
|
||||||
|
if let Some(mut info) = cached_info.clone() {
|
||||||
|
if !state.is_online() {
|
||||||
|
info.server_name = format!("{} (Offline)", info.server_name);
|
||||||
|
}
|
||||||
|
info
|
||||||
|
} else {
|
||||||
|
SystemInfo {
|
||||||
|
local_address: "http://localhost:8096".to_string(),
|
||||||
|
server_name: "Jellyfin Server (Offline)".to_string(),
|
||||||
|
version: "10.10.6".to_string(),
|
||||||
|
product_name: "Jellyfin Server".to_string(),
|
||||||
|
operating_system: "".to_string(),
|
||||||
|
id: "unknown".to_string(),
|
||||||
|
startup_wizard_completed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_branding_config_impl(state: Arc<AppState>) -> BrandingConfig {
|
||||||
|
if state.is_online() {
|
||||||
|
if let Ok(config) = state.jellyfin_client.get_branding_config().await {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BrandingConfig {
|
||||||
|
login_disclaimer: "This server is currently offline. Log-in to start the server.".to_string(),
|
||||||
|
custom_css: "".to_string(),
|
||||||
|
splashscreen_enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_fallback_request(
|
||||||
|
ws: Option<WebSocketUpgrade>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
method: Method,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
// Check if this is a WebSocket upgrade request
|
||||||
|
if let Some(ws_upgrade) = ws {
|
||||||
|
return handle_websocket_request(ws_upgrade, state, uri, headers).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle as regular HTTP request
|
||||||
|
handle_proxy_request(None, State(state), method, uri, headers, body).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_websocket_request(
|
||||||
|
ws_upgrade: WebSocketUpgrade,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
state.update_activity();
|
||||||
|
|
||||||
|
let path = uri.path().to_string();
|
||||||
|
let query = uri.query().map(|q| q.to_string());
|
||||||
|
|
||||||
|
// Check authentication for WebSocket connections
|
||||||
|
let is_authenticated = check_authentication(&state, &headers, &query).await;
|
||||||
|
|
||||||
|
// If server is offline but user is authenticated, power it on
|
||||||
|
if !state.is_online() && is_authenticated {
|
||||||
|
ensure_server_online_for_authenticated_request(&state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server is online for WebSocket connections
|
||||||
|
if !state.is_online() {
|
||||||
|
// For WebSocket connections when offline, we need authentication
|
||||||
|
if !is_authenticated {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle WebSocket upgrade
|
||||||
|
let jellyfin_url = state.jellyfin_client.get_base_url().to_string();
|
||||||
|
let query_str = query.clone();
|
||||||
|
let headers_clone = headers.clone();
|
||||||
|
|
||||||
|
Ok(ws_upgrade.on_upgrade(move |socket| async move {
|
||||||
|
proxy_websocket(socket, &jellyfin_url, &path, query_str.as_deref(), &headers_clone).await;
|
||||||
|
}).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_proxy_request(
|
||||||
|
_ws: Option<WebSocketUpgrade>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
method: Method,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Body,
|
||||||
|
) -> Result<Response<Body>, StatusCode> {
|
||||||
|
state.update_activity();
|
||||||
|
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().map(|q| q.to_string());
|
||||||
|
|
||||||
|
// Check authentication for all requests
|
||||||
|
let is_authenticated = check_authentication(&state, &headers, &query).await;
|
||||||
|
|
||||||
|
// If server is offline but user is authenticated, power it on
|
||||||
|
if !state.is_online() && is_authenticated {
|
||||||
|
ensure_server_online_for_authenticated_request(&state).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular HTTP requests
|
||||||
|
let body_bytes = match axum::body::to_bytes(body, usize::MAX).await {
|
||||||
|
Ok(bytes) => bytes.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to read request body: {}", e);
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !state.is_online() && !is_authenticated {
|
||||||
|
return Err(StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy to Jellyfin
|
||||||
|
proxy_to_jellyfin_with_retry(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
query,
|
||||||
|
headers,
|
||||||
|
body_bytes,
|
||||||
|
&state.jellyfin_client,
|
||||||
|
{
|
||||||
|
let state_clone = state.clone();
|
||||||
|
move || {
|
||||||
|
let state_clone = state_clone.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
state_clone.update_and_check_status().await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_authentication(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
query: &Option<String>,
|
||||||
|
) -> bool {
|
||||||
|
// Check for API key in query parameters
|
||||||
|
if let Some(query_str) = query {
|
||||||
|
let params: HashMap<_, _> = url::form_urlencoded::parse(query_str.as_bytes()).collect();
|
||||||
|
if let Some(api_key) = params.get("api_key") {
|
||||||
|
if let Ok(Some(_)) = state.validate_token(api_key).await {
|
||||||
|
debug!("Valid API key found in query parameters");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(device_id) = params.get("deviceId").or_else(|| params.get("DeviceId")) {
|
||||||
|
if let Ok(Some(_)) = state.validate_device_id(device_id).await {
|
||||||
|
debug!("Valid device ID found in query parameters");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authorization header
|
||||||
|
if let Some(auth_header) = headers.get("authorization") {
|
||||||
|
if let Ok(header_str) = auth_header.to_str() {
|
||||||
|
debug!("Checking authorization header: {}", header_str);
|
||||||
|
|
||||||
|
// First, try to extract token from MediaBrowser header
|
||||||
|
if header_str.starts_with("MediaBrowser ") && header_str.contains("Token=") {
|
||||||
|
if let Some(token_start) = header_str.find("Token=\"") {
|
||||||
|
let token_start = token_start + 7; // Skip 'Token="'
|
||||||
|
if let Some(token_end) = header_str[token_start..].find('"') {
|
||||||
|
let token = &header_str[token_start..token_start + token_end];
|
||||||
|
debug!("Extracted token from header: {}", token);
|
||||||
|
if let Ok(Some(_)) = state.validate_token(token).await {
|
||||||
|
debug!("Valid token found in authorization header");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
debug!("Token validation failed for: {}", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(token_start) = header_str.find("Token=") {
|
||||||
|
// Handle case without quotes around token value
|
||||||
|
let token_start = token_start + 6; // Skip 'Token='
|
||||||
|
let token_end = header_str[token_start..].find(',').or_else(||
|
||||||
|
header_str[token_start..].find(' ')).unwrap_or(header_str.len() - token_start);
|
||||||
|
let token = &header_str[token_start..token_start + token_end].trim_matches('"');
|
||||||
|
debug!("Extracted token from header (no quotes): {}", token);
|
||||||
|
if let Ok(Some(_)) = state.validate_token(token).await {
|
||||||
|
debug!("Valid token found in authorization header (no quotes)");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
debug!("Token validation failed for: {}", token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try to parse device ID from header and validate it
|
||||||
|
if let Some((_, _, device_id, _)) = parse_authorization_header(header_str) {
|
||||||
|
// URL decode the device ID since it might be encoded
|
||||||
|
if let Ok(decoded_device_id) = urlencoding::decode(&device_id) {
|
||||||
|
debug!("Checking device ID: {}", decoded_device_id);
|
||||||
|
if let Ok(Some(_)) = state.validate_device_id(&decoded_device_id).await {
|
||||||
|
debug!("Valid device ID found in authorization header");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also try the non-decoded version
|
||||||
|
if let Ok(Some(_)) = state.validate_device_id(&device_id).await {
|
||||||
|
debug!("Valid device ID found in authorization header (non-decoded)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("No valid authentication found");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_server_online_for_authenticated_request(state: &Arc<AppState>) -> Result<(), StatusCode> {
|
||||||
|
// If server is already online, nothing to do
|
||||||
|
if state.is_online() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're already powering on
|
||||||
|
if state.is_powering_on() {
|
||||||
|
// Check if power-on has timed out
|
||||||
|
if state.is_power_on_timeout() {
|
||||||
|
error!("Server power-on timed out, resetting power-on state");
|
||||||
|
*state.is_powering_on.write().unwrap() = false;
|
||||||
|
*state.power_on_start_time.write().unwrap() = None;
|
||||||
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for server to come online or timeout
|
||||||
|
let max_wait = Duration::from_secs(300); // 5 minutes
|
||||||
|
let start_check = Instant::now();
|
||||||
|
|
||||||
|
info!("Server is being powered on, waiting for it to come online...");
|
||||||
|
while start_check.elapsed() < max_wait {
|
||||||
|
// Check if server came online
|
||||||
|
if state.jellyfin_client.is_online().await {
|
||||||
|
*state.is_jellyfin_online.write().unwrap() = true;
|
||||||
|
*state.is_powering_on.write().unwrap() = false;
|
||||||
|
*state.power_on_start_time.write().unwrap() = None;
|
||||||
|
info!("Server came online successfully");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if power-on process timed out
|
||||||
|
if state.is_power_on_timeout() {
|
||||||
|
error!("Server power-on timed out while waiting");
|
||||||
|
*state.is_powering_on.write().unwrap() = false;
|
||||||
|
*state.power_on_start_time.write().unwrap() = None;
|
||||||
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
error!("Timed out waiting for server to come online");
|
||||||
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start power-on process
|
||||||
|
info!("Server is offline but user is authenticated, powering on");
|
||||||
|
*state.is_powering_on.write().unwrap() = true;
|
||||||
|
*state.power_on_start_time.write().unwrap() = Some(Instant::now());
|
||||||
|
|
||||||
|
if let Err(e) = power_on_server(&state.config.jellyfin_power_on_command).await {
|
||||||
|
error!("Failed to power on server: {}", e);
|
||||||
|
*state.is_powering_on.write().unwrap() = false;
|
||||||
|
*state.power_on_start_time.write().unwrap() = None;
|
||||||
|
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for server to come online
|
||||||
|
let max_wait = Duration::from_secs(300); // 5 minutes
|
||||||
|
let start_time = Instant::now();
|
||||||
|
|
||||||
|
while start_time.elapsed() < max_wait {
|
||||||
|
if state.jellyfin_client.is_online().await {
|
||||||
|
*state.is_jellyfin_online.write().unwrap() = true;
|
||||||
|
*state.is_powering_on.write().unwrap() = false;
|
||||||
|
*state.power_on_start_time.write().unwrap() = None;
|
||||||
|
info!("Server came online successfully");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
sleep(tokio::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
error!("Server failed to come online within timeout");
|
||||||
|
*state.is_powering_on.write().unwrap() = false;
|
||||||
|
*state.power_on_start_time.write().unwrap() = None;
|
||||||
|
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||||
|
}
|
73
src/sftp.rs
Normal file
73
src/sftp.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use ssh2::Session;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::net::TcpStream;
|
||||||
|
use std::path::Path;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
pub struct SftpClient {
|
||||||
|
host: String,
|
||||||
|
port: u16,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SftpClient {
|
||||||
|
pub fn new(host: String, port: u16, username: String, password: String) -> Self {
|
||||||
|
Self {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result<()> {
|
||||||
|
let host_port = format!("{}:{}", self.host, self.port);
|
||||||
|
let tcp = TcpStream::connect(&host_port)?;
|
||||||
|
let mut sess = Session::new()?;
|
||||||
|
sess.set_tcp_stream(tcp);
|
||||||
|
sess.handshake()?;
|
||||||
|
sess.userauth_password(&self.username, &self.password)?;
|
||||||
|
|
||||||
|
let sftp = sess.sftp()?;
|
||||||
|
let mut remote_file = sftp.open(Path::new(remote_path))?;
|
||||||
|
let mut contents = Vec::new();
|
||||||
|
remote_file.read_to_end(&mut contents)?;
|
||||||
|
|
||||||
|
std::fs::write(local_path, contents)?;
|
||||||
|
info!("Downloaded {} to {}", remote_path, local_path);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_file_hash(&self, remote_path: &str) -> Result<String> {
|
||||||
|
let host_port = format!("{}:{}", self.host, self.port);
|
||||||
|
let tcp = TcpStream::connect(&host_port)?;
|
||||||
|
let mut sess = Session::new()?;
|
||||||
|
sess.set_tcp_stream(tcp);
|
||||||
|
sess.handshake()?;
|
||||||
|
sess.userauth_password(&self.username, &self.password)?;
|
||||||
|
|
||||||
|
let sftp = sess.sftp()?;
|
||||||
|
let mut remote_file = sftp.open(Path::new(remote_path))?;
|
||||||
|
let mut contents = Vec::new();
|
||||||
|
remote_file.read_to_end(&mut contents)?;
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&contents);
|
||||||
|
let hash = format!("{:x}", hasher.finalize());
|
||||||
|
|
||||||
|
debug!("File {} hash: {}", remote_path, hash);
|
||||||
|
Ok(hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calculate_local_file_hash(file_path: &str) -> Result<String> {
|
||||||
|
let contents = std::fs::read(file_path)?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(&contents);
|
||||||
|
let hash = format!("{:x}", hasher.finalize());
|
||||||
|
Ok(hash)
|
||||||
|
}
|
186
src/websocket.rs
Normal file
186
src/websocket.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::ws::{Message, WebSocket},
|
||||||
|
http::HeaderMap,
|
||||||
|
};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio_tungstenite::{
|
||||||
|
connect_async,
|
||||||
|
tungstenite::{Message as TungsteniteMessage, client::IntoClientRequest}
|
||||||
|
};
|
||||||
|
use tracing::{debug, error, warn};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
pub async fn proxy_websocket(
|
||||||
|
socket: WebSocket,
|
||||||
|
target_url: &str,
|
||||||
|
path: &str,
|
||||||
|
query: Option<&str>,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) {
|
||||||
|
// Build the target WebSocket URL
|
||||||
|
let ws_url = if target_url.starts_with("http://") {
|
||||||
|
target_url.replacen("http://", "ws://", 1)
|
||||||
|
} else if target_url.starts_with("https://") {
|
||||||
|
target_url.replacen("https://", "wss://", 1)
|
||||||
|
} else {
|
||||||
|
format!("ws://{}", target_url)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut full_url = format!("{}{}", ws_url, path);
|
||||||
|
if let Some(q) = query {
|
||||||
|
full_url = format!("{}?{}", full_url, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Proxying WebSocket connection to: {}", full_url);
|
||||||
|
|
||||||
|
// Parse the URL
|
||||||
|
let url = match Url::parse(&full_url) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Invalid WebSocket URL {}: {}", full_url, e);
|
||||||
|
let _ = socket.close().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create request with headers
|
||||||
|
let mut request = url.into_client_request().unwrap();
|
||||||
|
|
||||||
|
// Copy relevant headers from the original request
|
||||||
|
for (name, value) in headers {
|
||||||
|
// Skip headers that shouldn't be forwarded
|
||||||
|
let header_name = name.as_str().to_lowercase();
|
||||||
|
if header_name == "host"
|
||||||
|
|| header_name == "connection"
|
||||||
|
|| header_name == "upgrade"
|
||||||
|
|| header_name == "sec-websocket-key"
|
||||||
|
|| header_name == "sec-websocket-version"
|
||||||
|
|| header_name == "sec-websocket-protocol"
|
||||||
|
|| header_name == "sec-websocket-extensions" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(_header_value) = value.to_str() {
|
||||||
|
request.headers_mut().insert(name, value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the target WebSocket
|
||||||
|
let (target_ws, _) = match connect_async(request).await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to connect to target WebSocket: {}", e);
|
||||||
|
let _ = socket.close().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Connected to target WebSocket");
|
||||||
|
|
||||||
|
let (target_sink, target_stream) = target_ws.split();
|
||||||
|
|
||||||
|
// Spawn task to forward messages from client to target
|
||||||
|
let (client_sink, client_stream) = socket.split();
|
||||||
|
|
||||||
|
let client_to_target = async move {
|
||||||
|
let mut target_sink = target_sink;
|
||||||
|
let mut client_stream = client_stream;
|
||||||
|
|
||||||
|
while let Some(msg) = client_stream.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(Message::Text(text)) => {
|
||||||
|
if let Err(e) = target_sink.send(TungsteniteMessage::Text(text)).await {
|
||||||
|
error!("Failed to send text message to target: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Binary(data)) => {
|
||||||
|
if let Err(e) = target_sink.send(TungsteniteMessage::Binary(data)).await {
|
||||||
|
error!("Failed to send binary message to target: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Ping(data)) => {
|
||||||
|
if let Err(e) = target_sink.send(TungsteniteMessage::Ping(data)).await {
|
||||||
|
error!("Failed to send ping to target: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Pong(data)) => {
|
||||||
|
if let Err(e) = target_sink.send(TungsteniteMessage::Pong(data)).await {
|
||||||
|
error!("Failed to send pong to target: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Close(_)) => {
|
||||||
|
debug!("Client closed WebSocket connection");
|
||||||
|
let _ = target_sink.send(TungsteniteMessage::Close(None)).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("WebSocket error from client: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_to_client = async move {
|
||||||
|
let mut client_sink = client_sink;
|
||||||
|
let mut target_stream = target_stream;
|
||||||
|
|
||||||
|
while let Some(msg) = target_stream.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(TungsteniteMessage::Text(text)) => {
|
||||||
|
if let Err(e) = client_sink.send(Message::Text(text)).await {
|
||||||
|
error!("Failed to send text message to client: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(TungsteniteMessage::Binary(data)) => {
|
||||||
|
if let Err(e) = client_sink.send(Message::Binary(data)).await {
|
||||||
|
error!("Failed to send binary message to client: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(TungsteniteMessage::Ping(data)) => {
|
||||||
|
if let Err(e) = client_sink.send(Message::Ping(data)).await {
|
||||||
|
error!("Failed to send ping to client: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(TungsteniteMessage::Pong(data)) => {
|
||||||
|
if let Err(e) = client_sink.send(Message::Pong(data)).await {
|
||||||
|
error!("Failed to send pong to client: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(TungsteniteMessage::Close(_)) => {
|
||||||
|
debug!("Target closed WebSocket connection");
|
||||||
|
let _ = client_sink.send(Message::Close(None)).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(TungsteniteMessage::Frame(_)) => {
|
||||||
|
// Frame messages are low-level and should be handled automatically
|
||||||
|
debug!("Received frame message, ignoring");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("WebSocket error from target: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run both forwarding tasks concurrently
|
||||||
|
tokio::select! {
|
||||||
|
_ = client_to_target => {
|
||||||
|
debug!("Client to target forwarding finished");
|
||||||
|
}
|
||||||
|
_ = target_to_client => {
|
||||||
|
debug!("Target to client forwarding finished");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("WebSocket proxy connection closed");
|
||||||
|
}
|
Reference in New Issue
Block a user