Added web player

This commit is contained in:
Ultradesu
2026-03-17 13:49:03 +00:00
parent f2d42751fd
commit 46ba3d5490
10 changed files with 2298 additions and 15 deletions

View File

@@ -0,0 +1,213 @@
use axum::{
body::Body,
extract::{Request, State},
http::{HeaderMap, StatusCode, header},
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
Form,
};
use sha2::{Digest, Sha256};
use serde::Deserialize;
use super::WebState;
/// Cookie name used to store the session token.
const SESSION_COOKIE: &str = "furumi_session";
/// Compute SHA-256 of the token as hex string (stored in cookie, not raw token).
pub fn token_hash(token: &str) -> String {
let mut h = Sha256::new();
h.update(token.as_bytes());
format!("{:x}", h.finalize())
}
/// axum middleware: if token is configured, requires a valid session cookie.
pub async fn require_auth(
State(state): State<WebState>,
req: Request,
next: Next,
) -> Response {
// Auth disabled when token is empty
if state.token.is_empty() {
return next.run(req).await;
}
let cookies = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let expected = token_hash(&state.token);
let authed = cookies
.split(';')
.any(|c| c.trim() == format!("{}={}", SESSION_COOKIE, expected));
if authed {
next.run(req).await
} else {
let uri = req.uri().path();
if uri.starts_with("/api/") {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} else {
Redirect::to("/login").into_response()
}
}
}
/// GET /login — show login form.
pub async fn login_page(State(state): State<WebState>) -> impl IntoResponse {
if state.token.is_empty() {
return Redirect::to("/").into_response();
}
Html(LOGIN_HTML).into_response()
}
#[derive(Deserialize)]
pub struct LoginForm {
password: String,
}
/// POST /login — validate password, set session cookie.
pub async fn login_submit(
State(state): State<WebState>,
Form(form): Form<LoginForm>,
) -> impl IntoResponse {
if state.token.is_empty() {
return Redirect::to("/").into_response();
}
if form.password == *state.token {
let hash = token_hash(&state.token);
let cookie = format!(
"{}={}; HttpOnly; SameSite=Strict; Path=/",
SESSION_COOKIE, hash
);
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
headers.insert(header::LOCATION, "/".parse().unwrap());
(StatusCode::FOUND, headers, Body::empty()).into_response()
} else {
Html(LOGIN_ERROR_HTML).into_response()
}
}
/// GET /logout — clear session cookie and redirect to login.
pub async fn logout() -> impl IntoResponse {
let cookie = format!(
"{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
SESSION_COOKIE
);
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
headers.insert(header::LOCATION, "/login".parse().unwrap());
(StatusCode::FOUND, headers, Body::empty()).into_response()
}
const LOGIN_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Furumi Player — Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: #0d0f14;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
}
.card {
background: #161b27;
border: 1px solid #2a3347;
border-radius: 16px;
padding: 2.5rem 3rem;
width: 360px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; }
input[type=password] {
width: 100%; padding: 0.6rem 0.8rem;
background: #0d0f14; border: 1px solid #2a3347; border-radius: 8px;
color: #e2e8f0; font-size: 0.95rem; outline: none;
transition: border-color 0.2s;
}
input[type=password]:focus { border-color: #7c6af7; }
button {
margin-top: 1.2rem; width: 100%; padding: 0.65rem;
background: #7c6af7; border: none; border-radius: 8px;
color: #fff; font-size: 0.95rem; font-weight: 600; cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #6b58e8; }
</style>
</head>
<body>
<div class="card">
<div class="logo">🎵 Furumi</div>
<div class="subtitle">Enter access token to continue</div>
<form method="POST" action="/login">
<label for="password">Access Token</label>
<input type="password" id="password" name="password" autofocus autocomplete="current-password">
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>"#;
const LOGIN_ERROR_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Furumi Player — Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: #0d0f14;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
}
.card {
background: #161b27;
border: 1px solid #2a3347;
border-radius: 16px;
padding: 2.5rem 3rem;
width: 360px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
.error { color: #f87171; font-size: 0.85rem; margin-bottom: 1rem; }
label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; }
input[type=password] {
width: 100%; padding: 0.6rem 0.8rem;
background: #0d0f14; border: 1px solid #f87171; border-radius: 8px;
color: #e2e8f0; font-size: 0.95rem; outline: none;
}
button {
margin-top: 1.2rem; width: 100%; padding: 0.65rem;
background: #7c6af7; border: none; border-radius: 8px;
color: #fff; font-size: 0.95rem; font-weight: 600; cursor: pointer;
}
button:hover { background: #6b58e8; }
</style>
</head>
<body>
<div class="card">
<div class="logo">🎵 Furumi</div>
<div class="subtitle">Enter access token to continue</div>
<p class="error">❌ Invalid token. Please try again.</p>
<form method="POST" action="/login">
<label for="password">Access Token</label>
<input type="password" id="password" name="password" autofocus>
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>"#;