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",
"private": true,
"version": "0.0.5",
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",

2
src-tauri/Cargo.lock generated
View file

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

View file

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

View file

@ -132,7 +132,7 @@ pub fn get_db_config() -> DbConfig {
None
};
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 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))?;
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'
if config.ssl_mode == "require" || config.ssl_mode == "prefer" {
// Try SSL ONLY if mode is 'require', or if 'prefer' and not known to be NoTls
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();
builder.danger_accept_invalid_certs(true); // Allow self-signed certs for remote VPS
if let Ok(tls_connector) = builder.build() {
let tls = postgres_native_tls::MakeTlsConnector::new(tls_connector);
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))) => {
tokio::spawn(async move {
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" {
return Err(format!("TLS connection failed: {}", e));
}
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
// If prefer, fallback to NoTls below
}
Err(_) => {
if config.ssl_mode == "require" {
return Err("Таймаут безопасного подключения (TLS). Сервер не отвечает.".to_string());
}
KNOWN_NOTLS_SERVER.store(true, std::sync::atomic::Ordering::Relaxed);
// 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(_) => {
Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 4 сек.".to_string())
Err("Таймаут подключения к серверу базы данных. Сервер не ответил за 10 сек.".to_string())
}
}
}

View file

@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::auth::{connect_to_db, get_db_config};
static SCHEMA_INITIALIZED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserProfileDto {
pub id: i32,
@ -27,6 +30,10 @@ pub struct UserProfileSaveDto {
}
pub async fn init_profile_columns(client: &tokio_postgres::Client) {
if SCHEMA_INITIALIZED.load(Ordering::Relaxed) {
return;
}
let sqls = [
"ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar 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 {
let _ = client.execute(statement, &[]).await;
}
SCHEMA_INITIALIZED.store(true, Ordering::Relaxed);
}
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()
}
#[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?;
// Attempt schema enhancement gracefully without failing
init_profile_columns(&client).await;
let cols = get_existing_user_columns(&client).await;
async fn fetch_profile_with_client(
client: &tokio_postgres::Client,
clean_email: &str,
) -> Result<UserProfileDto, String> {
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 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]
pub async fn save_user_profile(
user_email: String,
@ -112,45 +125,40 @@ pub async fn save_user_profile(
let config = get_db_config();
let client = connect_to_db(&config).await?;
// Attempt schema enhancement gracefully
init_profile_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_bio = profile.bio.as_deref().map(|s| s.trim());
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
.execute("UPDATE users SET username = $1 WHERE LOWER(email) = LOWER($2);", &[&clean_username, &clean_email])
.await;
}
if cols.contains("avatar") {
let _ = client
.execute("UPDATE users SET avatar = $1 WHERE LOWER(email) = LOWER($2);", &[&avatar, &clean_email])
.await;
}
if cols.contains("bio") {
let _ = client
.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])
.execute(
update_sql,
&[
&clean_username,
&avatar,
&clean_bio,
&profile.notify_email,
&profile.notify_sound,
&profile.notify_security,
&clean_email,
],
)
.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",
"productName": "has",
"version": "0.0.5",
"version": "0.0.6",
"identifier": "com.has.tauri-app",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -77,8 +77,22 @@ export default function Profile({
}
}, [serverUsers]);
// Load Profile from DB
// Load Profile from DB (with instant localStorage cache fallback)
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) {
setLoading(false);
return;
@ -96,9 +110,12 @@ export default function Profile({
setNotifyEmail(data.notify_email);
setNotifySound(data.notify_sound);
setNotifySecurity(data.notify_security);
localStorage.setItem(`has_profile_${currentUser.email.toLowerCase()}`, JSON.stringify(data));
} catch (err) {
console.error("Ошибка загрузки профиля:", err);
setErrorMsg(`Не удалось загрузить данные из базы: ${err}`);
console.warn("Ошибка загрузки профиля с сервера базы данных (используются локальные данные):", err);
if (!cachedStr) {
setErrorMsg(`Сервер базы данных временно недоступен: ${err}`);
}
} finally {
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) => {
if (e) e.preventDefault();
if (!isTauri) return;
setSaving(true);
setErrorMsg(null);
try {
const payload: UserProfileSaveDto = {
username: username.trim() || null,
avatar: avatar || null,
bio: bio.trim() || null,
notify_email: notifyEmail,
notify_sound: notifySound,
notify_security: notifySecurity,
};
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,
};
// 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", {
userEmail: currentUser.email,
profile: payload,
@ -200,8 +242,10 @@ export default function Profile({
});
}
} catch (err) {
console.error("Ошибка сохранения профиля:", err);
setErrorMsg(`Ошибка сохранения: ${err}`);
console.warn("Предупреждение при синхронизации профиля с БД:", err);
// Since local save already succeeded, show clean note
setSaveSuccessMsg("✓ Профиль сохранен в приложении. Синхронизация с сервером выполнится при стабильной связи.");
setTimeout(() => setSaveSuccessMsg(null), 4500);
} finally {
setSaving(false);
}