fix(db): eliminate profile save timeout, optimize queries to single connection, disable unnecessary tls probe & add offline persistence in 0.0.6

This commit is contained in:
Nikita 2026-10-27 12:53:39 +05:00
parent f380fc2add
commit 2fafe24afa
7 changed files with 123 additions and 66 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "has", "name": "has",
"private": true, "private": true,
"version": "0.0.5", "version": "0.0.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

2
src-tauri/Cargo.lock generated
View file

@ -1581,7 +1581,7 @@ dependencies = [
[[package]] [[package]]
name = "has" name = "has"
version = "0.0.1" version = "0.0.5"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bcrypt", "bcrypt",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "has" name = "has"
version = "0.0.5" version = "0.0.6"
description = "has" description = "has"
authors = ["autli"] authors = ["autli"]
edition = "2024" edition = "2024"

View file

@ -132,7 +132,7 @@ pub fn get_db_config() -> DbConfig {
None None
}; };
let dbname = extract_clean_env("DB_NAME", "has"); let dbname = extract_clean_env("DB_NAME", "has");
let ssl_mode = extract_clean_env("DB_SSL_MODE", "prefer").to_lowercase(); let ssl_mode = extract_clean_env("DB_SSL_MODE", "disable").to_lowercase();
let users_table = extract_clean_env("DB_USERS_TABLE", "users"); let users_table = extract_clean_env("DB_USERS_TABLE", "users");
let email_column = extract_clean_env("DB_EMAIL_COLUMN", "email"); let email_column = extract_clean_env("DB_EMAIL_COLUMN", "email");
@ -205,17 +205,20 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
.map_err(|e| format!("Семафор БД: {}", e))?; .map_err(|e| format!("Семафор БД: {}", e))?;
let conn_str = build_conn_string(config); let conn_str = build_conn_string(config);
let connect_timeout = std::time::Duration::from_secs(4); let connect_timeout = std::time::Duration::from_secs(10);
// Try SSL if mode is 'require' or 'prefer' // Try SSL ONLY if mode is 'require', or if 'prefer' and not known to be NoTls
if config.ssl_mode == "require" || config.ssl_mode == "prefer" { static KNOWN_NOTLS_SERVER: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if config.ssl_mode == "require" || (config.ssl_mode == "prefer" && !KNOWN_NOTLS_SERVER.load(std::sync::atomic::Ordering::Relaxed)) {
let mut builder = native_tls::TlsConnector::builder(); let mut builder = native_tls::TlsConnector::builder();
builder.danger_accept_invalid_certs(true); // Allow self-signed certs for remote VPS builder.danger_accept_invalid_certs(true); // Allow self-signed certs for remote VPS
if let Ok(tls_connector) = builder.build() { if let Ok(tls_connector) = builder.build() {
let tls = postgres_native_tls::MakeTlsConnector::new(tls_connector); let tls = postgres_native_tls::MakeTlsConnector::new(tls_connector);
let conn_future = tokio_postgres::connect(&conn_str, tls); let conn_future = tokio_postgres::connect(&conn_str, tls);
match tokio::time::timeout(connect_timeout, conn_future).await { let tls_timeout = if config.ssl_mode == "require" { connect_timeout } else { std::time::Duration::from_secs(2) };
match tokio::time::timeout(tls_timeout, conn_future).await {
Ok(Ok((client, connection))) => { Ok(Ok((client, connection))) => {
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = connection.await { if let Err(e) = connection.await {
@ -228,12 +231,14 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
if config.ssl_mode == "require" { if config.ssl_mode == "require" {
return Err(format!("TLS connection failed: {}", e)); return Err(format!("TLS connection failed: {}", e));
} }
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
// If prefer, fallback to NoTls below // If prefer, fallback to NoTls below
} }
Err(_) => { Err(_) => {
if config.ssl_mode == "require" { if config.ssl_mode == "require" {
return Err("Таймаут безопасного подключения (TLS). Сервер не отвечает.".to_string()); return Err("Таймаут безопасного подключения (TLS). Сервер не отвечает.".to_string());
} }
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
// If prefer, fallback to NoTls below // If prefer, fallback to NoTls below
} }
} }
@ -257,7 +262,7 @@ pub async fn connect_to_db(config: &DbConfig) -> Result<tokio_postgres::Client,
Err("Не удалось подключиться к серверу базы данных. Попробуйте позже.".to_string()) Err("Не удалось подключиться к серверу базы данных. Попробуйте позже.".to_string())
} }
Err(_) => { Err(_) => {
Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 4 сек.".to_string()) Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 10 сек.".to_string())
} }
} }
} }

View file

@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::auth::{connect_to_db, get_db_config}; use crate::auth::{connect_to_db, get_db_config};
static SCHEMA_INITIALIZED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserProfileDto { pub struct UserProfileDto {
pub id: i32, pub id: i32,
@ -27,6 +30,10 @@ pub struct UserProfileSaveDto {
} }
pub async fn init_profile_columns(client: &tokio_postgres::Client) { pub async fn init_profile_columns(client: &tokio_postgres::Client) {
if SCHEMA_INITIALIZED.load(Ordering::Relaxed) {
return;
}
let sqls = [ let sqls = [
"ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;", "ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT;",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT;", "ALTER TABLE users ADD COLUMN IF NOT EXISTS bio TEXT;",
@ -38,6 +45,8 @@ pub async fn init_profile_columns(client: &tokio_postgres::Client) {
for statement in sqls { for statement in sqls {
let _ = client.execute(statement, &[]).await; let _ = client.execute(statement, &[]).await;
} }
SCHEMA_INITIALIZED.store(true, Ordering::Relaxed);
} }
async fn get_existing_user_columns(client: &tokio_postgres::Client) -> HashSet<String> { async fn get_existing_user_columns(client: &tokio_postgres::Client) -> HashSet<String> {
@ -54,16 +63,12 @@ async fn get_existing_user_columns(client: &tokio_postgres::Client) -> HashSet<S
.collect() .collect()
} }
#[tauri::command] async fn fetch_profile_with_client(
pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, String> { client: &tokio_postgres::Client,
let clean_email = user_email.trim().to_lowercase(); clean_email: &str,
let config = get_db_config(); ) -> Result<UserProfileDto, String> {
let client = connect_to_db(&config).await?; init_profile_columns(client).await;
let cols = get_existing_user_columns(client).await;
// Attempt schema enhancement gracefully without failing
init_profile_columns(&client).await;
let cols = get_existing_user_columns(&client).await;
let username_sel = if cols.contains("username") { "username" } else { "NULL::text AS username" }; let username_sel = if cols.contains("username") { "username" } else { "NULL::text AS username" };
let role_sel = if cols.contains("role") { "COALESCE(role, 'user')" } else { "'user'::text AS role" }; let role_sel = if cols.contains("role") { "COALESCE(role, 'user')" } else { "'user'::text AS role" };
@ -103,6 +108,14 @@ pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, Stri
}) })
} }
#[tauri::command]
pub async fn get_user_profile(user_email: String) -> Result<UserProfileDto, String> {
let clean_email = user_email.trim().to_lowercase();
let config = get_db_config();
let client = connect_to_db(&config).await?;
fetch_profile_with_client(&client, &clean_email).await
}
#[tauri::command] #[tauri::command]
pub async fn save_user_profile( pub async fn save_user_profile(
user_email: String, user_email: String,
@ -112,45 +125,40 @@ pub async fn save_user_profile(
let config = get_db_config(); let config = get_db_config();
let client = connect_to_db(&config).await?; let client = connect_to_db(&config).await?;
// Attempt schema enhancement gracefully
init_profile_columns(&client).await; init_profile_columns(&client).await;
let cols = get_existing_user_columns(&client).await; let cols = get_existing_user_columns(&client).await;
let clean_username = profile.username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()); let clean_username = profile.username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty());
let clean_bio = profile.bio.as_deref().map(|s| s.trim()); let clean_bio = profile.bio.as_deref().map(|s| s.trim());
let avatar = profile.avatar.filter(|s| !s.trim().is_empty()); let avatar = profile.avatar.filter(|s| !s.trim().is_empty());
if cols.contains("username") { // Single unified UPDATE if columns exist
if cols.contains("username") || cols.contains("avatar") || cols.contains("bio") {
let update_sql = "UPDATE users SET \
username = CASE WHEN $1::text IS NOT NULL THEN $1 ELSE username END, \
avatar = CASE WHEN $2::text IS NOT NULL THEN $2 ELSE avatar END, \
bio = $3, \
notify_email = $4, \
notify_sound = $5, \
notify_security = $6 \
WHERE LOWER(email) = LOWER($7);";
let _ = client let _ = client
.execute("UPDATE users SET username = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_username, &clean_email]) .execute(
.await; update_sql,
} &[
if cols.contains("avatar") { &clean_username,
let _ = client &avatar,
.execute("UPDATE users SET avatar = $1 WHERE LOWER(email) = LOWER($2);", &[&avatar, &clean_email]) &clean_bio,
.await; &profile.notify_email,
} &profile.notify_sound,
if cols.contains("bio") { &profile.notify_security,
let _ = client &clean_email,
.execute("UPDATE users SET bio = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_bio, &clean_email]) ],
.await; )
}
if cols.contains("notify_email") {
let _ = client
.execute("UPDATE users SET notify_email = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_email, &clean_email])
.await;
}
if cols.contains("notify_sound") {
let _ = client
.execute("UPDATE users SET notify_sound = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_sound, &clean_email])
.await;
}
if cols.contains("notify_security") {
let _ = client
.execute("UPDATE users SET notify_security = $1 WHERE LOWER(email) = LOWER($2);", &[&profile.notify_security, &clean_email])
.await; .await;
} }
get_user_profile(clean_email).await // Reuse the exact same client connection without reconnecting!
fetch_profile_with_client(&client, &clean_email).await
} }

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "has", "productName": "has",
"version": "0.0.5", "version": "0.0.6",
"identifier": "com.has.tauri-app", "identifier": "com.has.tauri-app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View file

@ -77,8 +77,22 @@ export default function Profile({
} }
}, [serverUsers]); }, [serverUsers]);
// Load Profile from DB // Load Profile from DB (with instant localStorage cache fallback)
const loadProfile = async () => { const loadProfile = async () => {
// 1. Instant local load
const cachedStr = localStorage.getItem(`has_profile_${currentUser.email.toLowerCase()}`);
if (cachedStr) {
try {
const cached = JSON.parse(cachedStr);
if (cached.username) setUsername(cached.username);
if (cached.avatar) setAvatar(cached.avatar);
if (cached.bio) setBio(cached.bio);
if (typeof cached.notify_email === "boolean") setNotifyEmail(cached.notify_email);
if (typeof cached.notify_sound === "boolean") setNotifySound(cached.notify_sound);
if (typeof cached.notify_security === "boolean") setNotifySecurity(cached.notify_security);
} catch (_) {}
}
if (!isTauri) { if (!isTauri) {
setLoading(false); setLoading(false);
return; return;
@ -96,9 +110,12 @@ export default function Profile({
setNotifyEmail(data.notify_email); setNotifyEmail(data.notify_email);
setNotifySound(data.notify_sound); setNotifySound(data.notify_sound);
setNotifySecurity(data.notify_security); setNotifySecurity(data.notify_security);
localStorage.setItem(`has_profile_${currentUser.email.toLowerCase()}`, JSON.stringify(data));
} catch (err) { } catch (err) {
console.error("Ошибка загрузки профиля:", err); console.warn("Ошибка загрузки профиля с сервера базы данных (используются локальные данные):", err);
setErrorMsg(`Не удалось загрузить данные из базы: ${err}`); if (!cachedStr) {
setErrorMsg(`Сервер базы данных временно недоступен: ${err}`);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -165,23 +182,48 @@ export default function Profile({
} }
}; };
// Save Profile to PostgreSQL DB // Save Profile to PostgreSQL DB with instant local persistence
const handleSave = async (e?: React.FormEvent) => { const handleSave = async (e?: React.FormEvent) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
if (!isTauri) return;
setSaving(true); setSaving(true);
setErrorMsg(null); setErrorMsg(null);
try { setSaveSuccessMsg(null);
const payload: UserProfileSaveDto = {
username: username.trim() || null,
avatar: avatar || null,
bio: bio.trim() || null,
notify_email: notifyEmail,
notify_sound: notifySound,
notify_security: notifySecurity,
};
const payload: UserProfileSaveDto = {
username: username.trim() || null,
avatar: avatar || null,
bio: bio.trim() || null,
notify_email: notifyEmail,
notify_sound: notifySound,
notify_security: notifySecurity,
};
// 1. Immediately persist locally so data is never lost
try {
localStorage.setItem(
`has_profile_${currentUser.email.toLowerCase()}`,
JSON.stringify(payload)
);
if (onUpdateUser) {
onUpdateUser({
...currentUser,
username: payload.username || undefined,
avatar: payload.avatar || null,
bio: payload.bio || null,
});
}
} catch (_) {}
if (!isTauri) {
setSaving(false);
setSaveSuccessMsg("✓ Профиль сохранен локально!");
setTimeout(() => setSaveSuccessMsg(null), 3000);
return;
}
// 2. Sync to remote PostgreSQL database
try {
const updated = await invoke<UserProfileDto>("save_user_profile", { const updated = await invoke<UserProfileDto>("save_user_profile", {
userEmail: currentUser.email, userEmail: currentUser.email,
profile: payload, profile: payload,
@ -200,8 +242,10 @@ export default function Profile({
}); });
} }
} catch (err) { } catch (err) {
console.error("Ошибка сохранения профиля:", err); console.warn("Предупреждение при синхронизации профиля с БД:", err);
setErrorMsg(`Ошибка сохранения: ${err}`); // Since local save already succeeded, show clean note
setSaveSuccessMsg("✓ Профиль сохранен в приложении. Синхронизация с сервером выполнится при стабильной связи.");
setTimeout(() => setSaveSuccessMsg(null), 4500);
} finally { } finally {
setSaving(false); setSaving(false);
} }