Compare commits

..

14 Commits

Author SHA1 Message Date
spinline
817dc49db2 fix: wasm-opt build hatası için --enable-bulk-memory flag'i eklendi
Some checks failed
Build MIPS Binary / build (push) Failing after 3s
2026-02-08 16:29:33 +03:00
spinline
b2a60d3d1e cleanup: kullanılmayan get_vapid_public_key fonksiyonu kaldırıldı
Some checks failed
Build MIPS Binary / build (push) Failing after 1m6s
2026-02-08 16:26:16 +03:00
spinline
520903fa3f perf: push bildirimleri paralel gönderim ve env var önbelleğe alma ile optimize edildi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:25:44 +03:00
spinline
c45f2f50e9 fix: ARM64 build hatası için wasm-opt versiyonu v117 olarak güncellendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:25:02 +03:00
spinline
791eabe9bd fix: SQLite deadlock ve busy_timeout yönetimi iyileştirildi
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 16:20:55 +03:00
spinline
12f93dd640 perf: Trunk WASM optimizasyonu aktif edildi (data-wasm-opt=0 kaldırıldı)
Some checks failed
Build MIPS Binary / build (push) Failing after 1m2s
2026-02-08 16:18:50 +03:00
spinline
7306db8c2f fix: torrent diff algoritması hash tabanlı hale getirilerek sıralama bağımlılığı kaldırıldı
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:17:30 +03:00
spinline
ce0ecd62af fix: index.html yükleme ekranına zaman aşımı (15sn) ve hata mesajı eklendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:13:20 +03:00
spinline
f2379b67d8 docs: main.rs içindeki güncelliğini yitirmiş şifre güncelleme yorumu temizlendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:11:18 +03:00
spinline
755f35c94c security: gerçek .env dosyası takipten çıkarıldı ve .env.example güncellendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 16:07:26 +03:00
spinline
175cac953e fix: SSE bağlantısı sadece giriş yapıldıktan sonra başlatılacak şekilde düzeltildi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m23s
2026-02-08 15:57:24 +03:00
spinline
2c812fc4f6 fix: login rate limit 5 deneme ve 1 dakika bekleme olarak güncellendi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 15:56:07 +03:00
spinline
08df851970 feat: login rate limit için frontend uyarı mesajı ve IP bazlı limit aktif edildi
Some checks failed
Build MIPS Binary / build (push) Has been cancelled
2026-02-08 15:54:54 +03:00
spinline
35faa6bfda test: global rate limit denemesi
All checks were successful
Build MIPS Binary / build (push) Successful in 4m21s
2026-02-08 15:47:00 +03:00
13 changed files with 208 additions and 89 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ result.xml
frontend/dist
backend.log
.runner
.env
backend/.env

View File

@@ -1,8 +0,0 @@
# Database
DATABASE_URL=sqlite:vibetorrent.db
# VAPID Keys for Push Notifications
# Generate new keys for production using: cargo run --bin web-push --features web-push -- generate-vapid-keys
VAPID_PUBLIC_KEY=BEdPj6XQR7MGzM28Nev9wokF5upHoydNDahouJbQ9ZdBJpEFAN1iNfANSEvY0ItasNY5zcvvqN_tjUt64Rfd0gU
VAPID_PRIVATE_KEY=aUcCYJ7kUd9UClCaWwad0IVgbYJ6svwl19MjSX7GH10
VAPID_EMAIL=mailto:admin@vibetorrent.app

View File

@@ -3,3 +3,12 @@ RTORRENT_SOCKET=/tmp/rtorrent.sock
# Backend Listen Port
PORT=3000
# Database URL
DATABASE_URL=sqlite:vibetorrent.db
# VAPID Keys for Push Notifications
# Generate new keys for production using: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=YOUR_PUBLIC_VAPID_KEY
VAPID_PRIVATE_KEY=YOUR_PRIVATE_VAPID_KEY
VAPID_EMAIL=mailto:your-email@example.com

View File

@@ -1,6 +1,7 @@
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row};
use sqlx::{sqlite::SqlitePoolOptions, Pool, Sqlite, Row, sqlite::SqliteConnectOptions};
use std::time::Duration;
use anyhow::Result;
use std::str::FromStr;
#[derive(Clone)]
pub struct Db {
@@ -9,10 +10,16 @@ pub struct Db {
impl Db {
pub async fn new(db_url: &str) -> Result<Self> {
let options = SqliteConnectOptions::from_str(db_url)?
.create_if_missing(true)
.busy_timeout(Duration::from_secs(10)) // Bekleme süresini 10 saniyeye çıkardık
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(db_url)
.acquire_timeout(Duration::from_secs(10))
.connect_with(options)
.await?;
let db = Self { pool };
@@ -21,21 +28,6 @@ impl Db {
}
async fn run_migrations(&self) -> Result<()> {
// WAL mode - enables concurrent reads while writing
sqlx::query("PRAGMA journal_mode=WAL")
.execute(&self.pool)
.await?;
// NORMAL synchronous - faster than FULL, still safe enough
sqlx::query("PRAGMA synchronous=NORMAL")
.execute(&self.pool)
.await?;
// 5 second busy timeout - reduces "database locked" errors
sqlx::query("PRAGMA busy_timeout=5000")
.execute(&self.pool)
.await?;
sqlx::migrate!("./migrations").run(&self.pool).await?;
Ok(())
}

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use shared::{AppEvent, NotificationLevel, SystemNotification, Torrent, TorrentUpdate};
#[derive(Debug)]
@@ -8,24 +9,32 @@ pub enum DiffResult {
}
pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
// 1. Structural Check (Length or Order changed)
// 1. Structural Check: Eğer torrent sayısı değişmişse (yeni eklenen veya silinen),
// şimdilik basitlik adına FullUpdate gönderiyoruz.
if old.len() != new.len() {
return DiffResult::FullUpdate;
}
for (i, t) in new.iter().enumerate() {
if old[i].hash != t.hash {
// 2. Hash Set Karşılaştırması:
// Sıralama değişmiş olabilir ama torrentler aynı mı?
let old_map: HashMap<&str, &Torrent> = old.iter().map(|t| (t.hash.as_str(), t)).collect();
// Eğer yeni listedeki bir hash eski listede yoksa, yapı değişmiş demektir.
for new_t in new {
if !old_map.contains_key(new_t.hash.as_str()) {
return DiffResult::FullUpdate;
}
}
// 2. Field Updates
// 3. Alan Güncellemeleri (Partial Updates)
// Buraya geldiğimizde biliyoruz ki old ve new listelerindeki torrentler (hash olarak) aynı,
// sadece sıraları farklı olabilir veya içindeki veriler güncellenmiş olabilir.
let mut events = Vec::new();
for (i, new_t) in new.iter().enumerate() {
let old_t = &old[i];
for new_t in new {
// old_map'ten ilgili torrente hash ile ulaşalım (sıradan bağımsız)
let old_t = old_map.get(new_t.hash.as_str()).unwrap();
// Initialize with all None
let mut update = TorrentUpdate {
hash: new_t.hash.clone(),
name: None,
@@ -42,7 +51,7 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
let mut has_changes = false;
// Compare fields
// Alanları karşılaştır
if old_t.name != new_t.name {
update.name = Some(new_t.name.clone());
has_changes = true;
@@ -63,7 +72,7 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
update.percent_complete = Some(new_t.percent_complete);
has_changes = true;
// Check for torrent completion: reached 100%
// Torrent tamamlanma kontrolü
if old_t.percent_complete < 100.0 && new_t.percent_complete >= 100.0 {
tracing::info!("Torrent completed: {} ({})", new_t.name, new_t.hash);
events.push(AppEvent::Notification(SystemNotification {
@@ -83,8 +92,7 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
if old_t.status != new_t.status {
update.status = Some(new_t.status.clone());
has_changes = true;
// Log status changes for debugging
tracing::debug!(
"Torrent status changed: {} ({}) {:?} -> {:?}",
new_t.name, new_t.hash, old_t.status, new_t.status
@@ -110,4 +118,4 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult {
tracing::debug!("Generated {} partial updates", events.len());
DiffResult::Partial(events)
}
}
}

View File

@@ -690,8 +690,10 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
(status = 200, description = "VAPID public key", body = String)
)
)]
pub async fn get_push_public_key_handler() -> impl IntoResponse {
let public_key = push::get_vapid_public_key();
pub async fn get_push_public_key_handler(
State(state): State<AppState>,
) -> impl IntoResponse {
let public_key = state.push_store.get_public_key();
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
}

View File

@@ -255,9 +255,7 @@ async fn main() {
}
};
// 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.
// Update in DB
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);

View File

@@ -5,6 +5,7 @@ use utoipa::ToSchema;
use web_push::{
HyperWebPushClient, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder,
};
use futures::StreamExt;
use crate::db::Db;
@@ -20,17 +21,34 @@ pub struct PushKeys {
pub auth: String,
}
#[derive(Clone)]
pub struct VapidConfig {
pub private_key: String,
pub public_key: String,
pub email: String,
}
#[derive(Clone)]
pub struct PushSubscriptionStore {
db: Option<Db>,
subscriptions: Arc<RwLock<Vec<PushSubscription>>>,
vapid_config: VapidConfig,
}
impl PushSubscriptionStore {
pub fn new() -> Self {
let private_key = std::env::var("VAPID_PRIVATE_KEY").expect("VAPID_PRIVATE_KEY must be set in .env");
let public_key = std::env::var("VAPID_PUBLIC_KEY").expect("VAPID_PUBLIC_KEY must be set in .env");
let email = std::env::var("VAPID_EMAIL").expect("VAPID_EMAIL must be set in .env");
Self {
db: None,
subscriptions: Arc::new(RwLock::new(Vec::new())),
vapid_config: VapidConfig {
private_key,
public_key,
email,
},
}
}
@@ -47,9 +65,18 @@ impl PushSubscriptionStore {
}
tracing::info!("Loaded {} push subscriptions from database", subscriptions_vec.len());
let private_key = std::env::var("VAPID_PRIVATE_KEY").expect("VAPID_PRIVATE_KEY must be set in .env");
let public_key = std::env::var("VAPID_PUBLIC_KEY").expect("VAPID_PUBLIC_KEY must be set in .env");
let email = std::env::var("VAPID_EMAIL").expect("VAPID_EMAIL must be set in .env");
Ok(Self {
db: Some(db.clone()),
subscriptions: Arc::new(RwLock::new(subscriptions_vec)),
vapid_config: VapidConfig {
private_key,
public_key,
email,
},
})
}
@@ -91,6 +118,10 @@ impl PushSubscriptionStore {
pub async fn get_all_subscriptions(&self) -> Vec<PushSubscription> {
self.subscriptions.read().await.clone()
}
pub fn get_public_key(&self) -> &str {
&self.vapid_config.public_key
}
}
/// Send push notification to all subscribed clients
@@ -116,50 +147,68 @@ pub async fn send_push_notification(
"tag": "vibetorrent"
});
let client = HyperWebPushClient::new();
let client = Arc::new(HyperWebPushClient::new());
let vapid_config = store.vapid_config.clone();
let payload_str = payload.to_string();
let vapid_private_key = std::env::var("VAPID_PRIVATE_KEY").expect("VAPID_PRIVATE_KEY must be set in .env");
let vapid_email = std::env::var("VAPID_EMAIL").expect("VAPID_EMAIL must be set in .env");
// Send notifications concurrently
futures::stream::iter(subscriptions)
.for_each_concurrent(10, |subscription| {
let client = client.clone();
let vapid_config = vapid_config.clone();
let payload_str = payload_str.clone();
for subscription in subscriptions {
let subscription_info = SubscriptionInfo {
endpoint: subscription.endpoint.clone(),
keys: web_push::SubscriptionKeys {
p256dh: subscription.keys.p256dh.clone(),
auth: subscription.keys.auth.clone(),
},
};
async move {
let subscription_info = SubscriptionInfo {
endpoint: subscription.endpoint.clone(),
keys: web_push::SubscriptionKeys {
p256dh: subscription.keys.p256dh.clone(),
auth: subscription.keys.auth.clone(),
},
};
let mut sig_builder = VapidSignatureBuilder::from_base64(
&vapid_private_key,
web_push::URL_SAFE_NO_PAD,
&subscription_info,
)?;
let sig_res = VapidSignatureBuilder::from_base64(
&vapid_config.private_key,
web_push::URL_SAFE_NO_PAD,
&subscription_info,
);
sig_builder.add_claim("sub", vapid_email.as_str());
sig_builder.add_claim("aud", subscription.endpoint.as_str());
let signature = sig_builder.build()?;
match sig_res {
Ok(mut sig_builder) => {
sig_builder.add_claim("sub", vapid_config.email.as_str());
sig_builder.add_claim("aud", subscription.endpoint.as_str());
match sig_builder.build() {
Ok(signature) => {
let mut builder = WebPushMessageBuilder::new(&subscription_info);
builder.set_vapid_signature(signature);
builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
let mut builder = WebPushMessageBuilder::new(&subscription_info);
builder.set_vapid_signature(signature);
let payload_str = payload.to_string();
builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload_str.as_bytes());
match client.send(builder.build()?).await {
Ok(_) => {
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
match builder.build() {
Ok(msg) => {
match client.send(msg).await {
Ok(_) => {
tracing::debug!("Push notification sent to: {}", subscription.endpoint);
}
Err(e) => {
tracing::error!("Failed to send push notification to {}: {}", subscription.endpoint, e);
}
}
}
Err(e) => tracing::error!("Failed to build push message: {}", e),
}
}
Err(e) => tracing::error!("Failed to build VAPID signature: {}", e),
}
}
Err(e) => tracing::error!("Failed to create VAPID signature builder: {}", e),
}
}
Err(e) => {
tracing::error!("Failed to send push notification: {}", e);
// TODO: Remove invalid subscriptions
}
}
})
.await;
Ok(())
}
Ok(())
}
pub fn get_vapid_public_key() -> String {
std::env::var("VAPID_PUBLIC_KEY").expect("VAPID_PUBLIC_KEY must be set in .env")
}

View File

@@ -5,13 +5,12 @@ use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
pub fn get_login_rate_limit_config() -> GovernorConfig<SmartIpKeyExtractor, NoOpMiddleware<QuantaInstant>> {
// 20 saniyede bir yeni hak verilir (dakikada 3 istek).
// Başlangıçta 3 isteklik bir patlama (burst) hakkı tanınır.
// Kullanıcı 3 kere hızlıca deneyebilir, 4. deneme için 20 saniye beklemesi gerekir.
// 5 yanlış denemeden sonra bloklanır.
// Her yeni hak için 60 saniye (1 dakika) bekleme süresi.
GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor)
.per_second(20)
.burst_size(3)
.per_second(60)
.burst_size(5)
.finish()
.unwrap()
}
}

View File

@@ -10,3 +10,6 @@ command_arguments = ["-c", "npx @tailwindcss/cli -i input.css -o public/tailwind
[build]
target = "index.html"
dist = "dist"
[tools]
wasm_opt = "version_117"

View File

@@ -21,7 +21,7 @@
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<!-- Trunk Assets -->
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="0" />
<link data-trunk rel="rust" href="Cargo.toml" data-wasm-opt="--enable-bulk-memory" />
<link data-trunk rel="css" href="public/tailwind.css" />
<link data-trunk rel="copy-file" href="manifest.json" />
<link data-trunk rel="copy-file" href="icon-192.png" />
@@ -86,12 +86,15 @@
id="app-loading"
style="
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
font-family: sans-serif;
"
>
<div
id="app-loading-spinner"
style="
width: 40px;
height: 40px;
@@ -102,6 +105,32 @@
opacity: 0.5;
"
></div>
<div
id="app-loading-error"
style="display: none; text-align: center; margin-top: 20px; padding: 0 20px"
>
<p style="color: #ef4444; font-weight: bold; margin-bottom: 8px">
Uygulama yüklenemedi
</p>
<p style="font-size: 14px; opacity: 0.7">
Bağlantınız yavaş olabilir veya bir sistem hatası oluşmuş olabilir.
</p>
<button
onclick="location.reload()"
style="
margin-top: 16px;
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
"
>
Sayfayı Yenile
</button>
</div>
</div>
<style>
@keyframes spin {
@@ -114,6 +143,34 @@
display: none !important;
}
</style>
<script>
// App loading timeout handler
(function () {
var timeout = setTimeout(function () {
if (!document.body.classList.contains("app-loaded")) {
var spinner = document.getElementById("app-loading-spinner");
var error = document.getElementById("app-loading-error");
if (spinner) spinner.style.display = "none";
if (error) error.style.display = "block";
}
}, 15000); // 15 seconds timeout
// Clean up timeout if app loads
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.attributeName === "class" &&
document.body.classList.contains("app-loaded")
) {
clearTimeout(timeout);
observer.disconnect();
}
});
});
observer.observe(document.body, { attributes: true });
})();
</script>
<!-- Service Worker Registration & PWA Setup -->
<script>

View File

@@ -41,6 +41,8 @@ pub fn Login() -> impl IntoView {
logging::log!("Login successful, redirecting...");
// Force a full reload to re-run auth checks in App.rs
let _ = window().location().set_href("/");
} else if resp.status() == 429 {
set_error.set(Some("Çok fazla başarısız deneme yaptınız. Lütfen bir süre bekleyip tekrar deneyin.".to_string()));
} else {
let text = resp.text().await.unwrap_or_default();
logging::error!("Login failed: {}", text);

View File

@@ -143,6 +143,12 @@ pub fn provide_torrent_store() {
// Initialize SSE connection with auto-reconnect
create_effect(move |_| {
// Sadece kullanıcı giriş yapmışsa bağlantıyı başlat
if user.get().is_none() {
logging::log!("SSE: User not authenticated, skipping connection.");
return;
}
spawn_local(async move {
let mut backoff_ms: u32 = 1000; // Start with 1 second
let max_backoff_ms: u32 = 30000; // Max 30 seconds