Compare commits
8 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f009bc18b | ||
|
|
643b83ac21 | ||
|
|
90b65240b2 | ||
|
|
69243a5590 | ||
|
|
10262142fc | ||
|
|
858a1c9b63 | ||
|
|
edfb7458f8 | ||
|
|
575cfa4b38 |
@@ -111,4 +111,21 @@ impl Db {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_password(&self, user_id: i64, password_hash: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_sessions_for_user(&self, user_id: i64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM sessions WHERE user_id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ use time::Duration;
|
|||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
remember_me: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -61,8 +63,13 @@ pub async fn login_handler(
|
|||||||
rand::thread_rng().sample(Alphanumeric) as char
|
rand::thread_rng().sample(Alphanumeric) as char
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
// Expires in 30 days
|
// Expiration: 30 days if remember_me is true, else 1 day
|
||||||
let expires_in = 60 * 60 * 24 * 30;
|
let expires_in = if payload.remember_me {
|
||||||
|
60 * 60 * 24 * 30
|
||||||
|
} else {
|
||||||
|
60 * 60 * 24
|
||||||
|
};
|
||||||
|
|
||||||
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
|
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
|
||||||
|
|
||||||
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
||||||
@@ -70,13 +77,14 @@ pub async fn login_handler(
|
|||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create session").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let cookie = Cookie::build(("auth_token", token))
|
let mut cookie = Cookie::build(("auth_token", token))
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.same_site(SameSite::Lax)
|
.same_site(SameSite::Lax)
|
||||||
.max_age(Duration::seconds(expires_in))
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
cookie.set_max_age(Duration::seconds(expires_in));
|
||||||
|
|
||||||
tracing::info!("Session created and cookie set for user: {}", payload.username);
|
tracing::info!("Session created and cookie set for user: {}", payload.username);
|
||||||
(StatusCode::OK, jar.add(cookie), Json(UserResponse { username: payload.username })).into_response()
|
(StatusCode::OK, jar.add(cookie), Json(UserResponse { username: payload.username })).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use time::Duration;
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct SetupRequest {
|
pub struct SetupRequest {
|
||||||
@@ -41,7 +43,7 @@ pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl Int
|
|||||||
path = "/api/setup",
|
path = "/api/setup",
|
||||||
request_body = SetupRequest,
|
request_body = SetupRequest,
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Setup completed"),
|
(status = 200, description = "Setup completed and logged in"),
|
||||||
(status = 400, description = "Invalid request"),
|
(status = 400, description = "Invalid request"),
|
||||||
(status = 403, description = "Setup already completed"),
|
(status = 403, description = "Setup already completed"),
|
||||||
(status = 500, description = "Internal server error")
|
(status = 500, description = "Internal server error")
|
||||||
@@ -49,6 +51,7 @@ pub async fn get_setup_status_handler(State(state): State<AppState>) -> impl Int
|
|||||||
)]
|
)]
|
||||||
pub async fn setup_handler(
|
pub async fn setup_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
Json(payload): Json<SetupRequest>,
|
Json(payload): Json<SetupRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
// 1. Check if setup is already completed (i.e., users exist)
|
// 1. Check if setup is already completed (i.e., users exist)
|
||||||
@@ -68,7 +71,6 @@ pub async fn setup_handler(
|
|||||||
|
|
||||||
// 3. Create User
|
// 3. Create User
|
||||||
// Lower cost for faster login on low-power devices (MIPS routers etc.)
|
// Lower cost for faster login on low-power devices (MIPS routers etc.)
|
||||||
// Default is usually 12, which takes ~3s on slow CPUs. 6 should be much faster.
|
|
||||||
let password_hash = match bcrypt::hash(&payload.password, 6) {
|
let password_hash = match bcrypt::hash(&payload.password, 6) {
|
||||||
Ok(h) => h,
|
Ok(h) => h,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -82,5 +84,42 @@ pub async fn setup_handler(
|
|||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create user").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
(StatusCode::OK, "Setup completed successfully").into_response()
|
// 4. Auto-Login (Create Session)
|
||||||
|
// Get the created user's ID
|
||||||
|
let user = match state.db.get_user_by_username(&payload.username).await {
|
||||||
|
Ok(Some(u)) => u,
|
||||||
|
Ok(None) => return (StatusCode::INTERNAL_SERVER_ERROR, "User created but not found").into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("DB error fetching new user: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (user_id, _) = user;
|
||||||
|
|
||||||
|
// Create session token
|
||||||
|
let token: String = (0..32).map(|_| {
|
||||||
|
use rand::{distributions::Alphanumeric, Rng};
|
||||||
|
rand::thread_rng().sample(Alphanumeric) as char
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Default expiration: 1 day (since it's not "remember me")
|
||||||
|
let expires_in = 60 * 60 * 24;
|
||||||
|
let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + expires_in;
|
||||||
|
|
||||||
|
if let Err(e) = state.db.create_session(user_id, &token, expires_at).await {
|
||||||
|
tracing::error!("Failed to create session for new user: {}", e);
|
||||||
|
// Even if session fails, setup is technically complete, but login failed.
|
||||||
|
// We return OK but user will have to login manually.
|
||||||
|
return (StatusCode::OK, "Setup completed, please login").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cookie = Cookie::build(("auth_token", token))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
cookie.set_max_age(Duration::seconds(expires_in));
|
||||||
|
|
||||||
|
(StatusCode::OK, jar.add(cookie), "Setup completed and logged in").into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ struct Args {
|
|||||||
/// Database URL
|
/// Database URL
|
||||||
#[arg(long, env = "DATABASE_URL", default_value = "sqlite:vibetorrent.db")]
|
#[arg(long, env = "DATABASE_URL", default_value = "sqlite:vibetorrent.db")]
|
||||||
db_url: String,
|
db_url: String,
|
||||||
|
|
||||||
|
/// Reset password for the specified user
|
||||||
|
#[arg(long)]
|
||||||
|
reset_password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "push-notifications")]
|
#[cfg(feature = "push-notifications")]
|
||||||
@@ -130,7 +134,8 @@ struct Args {
|
|||||||
push::PushKeys,
|
push::PushKeys,
|
||||||
handlers::auth::LoginRequest,
|
handlers::auth::LoginRequest,
|
||||||
handlers::setup::SetupRequest,
|
handlers::setup::SetupRequest,
|
||||||
handlers::setup::SetupStatusResponse
|
handlers::setup::SetupStatusResponse,
|
||||||
|
handlers::auth::UserResponse
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -173,7 +178,8 @@ struct ApiDoc;
|
|||||||
shared::GlobalLimitRequest,
|
shared::GlobalLimitRequest,
|
||||||
handlers::auth::LoginRequest,
|
handlers::auth::LoginRequest,
|
||||||
handlers::setup::SetupRequest,
|
handlers::setup::SetupRequest,
|
||||||
handlers::setup::SetupStatusResponse
|
handlers::setup::SetupStatusResponse,
|
||||||
|
handlers::auth::UserResponse
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
@@ -197,9 +203,6 @@ async fn main() {
|
|||||||
|
|
||||||
// Parse CLI Args
|
// Parse CLI Args
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
tracing::info!("Starting VibeTorrent Backend...");
|
|
||||||
tracing::info!("Socket: {}", args.socket);
|
|
||||||
tracing::info!("Port: {}", args.port);
|
|
||||||
|
|
||||||
// Initialize Database
|
// Initialize Database
|
||||||
tracing::info!("Connecting to database: {}", args.db_url);
|
tracing::info!("Connecting to database: {}", args.db_url);
|
||||||
@@ -224,6 +227,68 @@ async fn main() {
|
|||||||
};
|
};
|
||||||
tracing::info!("Database connected successfully.");
|
tracing::info!("Database connected successfully.");
|
||||||
|
|
||||||
|
// Handle Password Reset
|
||||||
|
if let Some(username) = args.reset_password {
|
||||||
|
tracing::info!("Resetting password for user: {}", username);
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
let user_result = db.get_user_by_username(&username).await;
|
||||||
|
|
||||||
|
match user_result {
|
||||||
|
Ok(Some((user_id, _))) => {
|
||||||
|
// Generate random password
|
||||||
|
use rand::{distributions::Alphanumeric, Rng};
|
||||||
|
let new_password: String = rand::thread_rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(12)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Hash password (low cost for performance)
|
||||||
|
let password_hash = match bcrypt::hash(&new_password, 6) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to hash password: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update in DB (using a direct query since db.rs doesn't have update_password yet)
|
||||||
|
// We should add `update_password` to db.rs for cleaner code, but for now direct query is fine or we can extend Db.
|
||||||
|
// Let's extend Db.rs first to be clean.
|
||||||
|
if let Err(e) = db.update_password(user_id, &password_hash).await {
|
||||||
|
tracing::error!("Failed to update password in DB: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("--------------------------------------------------");
|
||||||
|
println!("Password reset successfully for user: {}", username);
|
||||||
|
println!("New Password: {}", new_password);
|
||||||
|
println!("--------------------------------------------------");
|
||||||
|
|
||||||
|
// Invalidate existing sessions for security
|
||||||
|
if let Err(e) = db.delete_all_sessions_for_user(user_id).await {
|
||||||
|
tracing::warn!("Failed to invalidate existing sessions: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::process::exit(0);
|
||||||
|
},
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::error!("User '{}' not found.", username);
|
||||||
|
std::process::exit(1);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Database error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Starting VibeTorrent Backend...");
|
||||||
|
tracing::info!("Socket: {}", args.socket);
|
||||||
|
tracing::info!("Port: {}", args.port);
|
||||||
|
|
||||||
|
// ... rest of the main function ...
|
||||||
// Startup Health Check
|
// Startup Health Check
|
||||||
let socket_path = std::path::Path::new(&args.socket);
|
let socket_path = std::path::Path::new(&args.socket);
|
||||||
if !socket_path.exists() {
|
if !socket_path.exists() {
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set_is_authenticated.set(true);
|
set_is_authenticated.set(true);
|
||||||
|
|
||||||
|
// If user is already authenticated but on login/setup page, redirect to home
|
||||||
|
let pathname = window().location().pathname().unwrap_or_default();
|
||||||
|
if pathname == "/login" || pathname == "/setup" {
|
||||||
|
logging::log!("Already authenticated, redirecting to home");
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/", Default::default());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logging::log!("Not authenticated, redirecting to /login");
|
logging::log!("Not authenticated, redirecting to /login");
|
||||||
let navigate = use_navigate();
|
let navigate = use_navigate();
|
||||||
@@ -80,7 +88,6 @@ pub fn App() -> impl IntoView {
|
|||||||
}
|
}
|
||||||
Err(e) => logging::error!("Network error checking auth status: {}", e),
|
Err(e) => logging::error!("Network error checking auth status: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
set_is_loading.set(false);
|
set_is_loading.set(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_router::*;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct LoginRequest {
|
struct LoginRequest {
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
|
remember_me: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Login() -> impl IntoView {
|
pub fn Login() -> impl IntoView {
|
||||||
let (username, set_username) = create_signal(String::new());
|
let (username, set_username) = create_signal(String::new());
|
||||||
let (password, set_password) = create_signal(String::new());
|
let (password, set_password) = create_signal(String::new());
|
||||||
|
let (remember_me, set_remember_me) = create_signal(false);
|
||||||
let (error, set_error) = create_signal(Option::<String>::None);
|
let (error, set_error) = create_signal(Option::<String>::None);
|
||||||
let (loading, set_loading) = create_signal(false);
|
let (loading, set_loading) = create_signal(false);
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ pub fn Login() -> impl IntoView {
|
|||||||
let req = LoginRequest {
|
let req = LoginRequest {
|
||||||
username: username.get(),
|
username: username.get(),
|
||||||
password: password.get(),
|
password: password.get(),
|
||||||
|
remember_me: remember_me.get(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = gloo_net::http::Request::post("/api/auth/login")
|
let client = gloo_net::http::Request::post("/api/auth/login")
|
||||||
@@ -89,6 +91,19 @@ pub fn Login() -> impl IntoView {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-4">
|
||||||
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary checkbox-sm"
|
||||||
|
prop:checked=remember_me
|
||||||
|
on:change=move |ev| set_remember_me.set(event_target_checked(&ev))
|
||||||
|
disabled=move || loading.get()
|
||||||
|
/>
|
||||||
|
<span class="label-text">"Beni Hatırla"</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Show when=move || error.get().is_some()>
|
<Show when=move || error.get().is_some()>
|
||||||
<div class="alert alert-error mt-4 text-sm py-2">
|
<div class="alert alert-error mt-4 text-sm py-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_router::*;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -49,8 +48,9 @@ pub fn Setup() -> impl IntoView {
|
|||||||
match client.send().await {
|
match client.send().await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
// Redirect to login after setup (full reload to be safe)
|
// Redirect to home after setup (auto-login handled by backend)
|
||||||
let _ = window().location().set_href("/login");
|
// Full reload to ensure auth state is refreshed
|
||||||
|
let _ = window().location().set_href("/");
|
||||||
} else {
|
} else {
|
||||||
let text = resp.text().await.unwrap_or_default();
|
let text = resp.text().await.unwrap_or_default();
|
||||||
set_error.set(Some(format!("Hata: {}", text)));
|
set_error.set(Some(format!("Hata: {}", text)));
|
||||||
|
|||||||
Reference in New Issue
Block a user