Files
minmal-file-cloud-email-pim…/clients/desktop/src-tauri/src/sync/api.rs
T
Stefan Hacker 5f905b4925 fix: Sync-Fehler "error decoding response body" + Server-Edits
Drei Probleme in einem:

1. create_folder/get_sync_tree parsten die Response auch bei HTTP-
   Fehlern als JSON. Bei 401/409/etc. kam "error decoding response
   body" statt der eigentlichen Fehlermeldung. Status wird jetzt
   zuerst geprueft, Body-Text wird bei Fehlern zurueckgegeben.

2. Ohne Journal-Eintrag und unterschiedlichen Hashes wurde vorher
   eine Konflikt-Kopie erstellt. Fuer Server-Edits aus dem Web-UI
   (wo der Client die Datei gar nie mit Journal erfasst hatte) war
   das falsch. Nextcloud-Ansatz: beim Erstkontakt Server
   autoritativ - Download statt Konflikt-Kopie.

3. run_sync_now uebernimmt neu konfigurierte sync_paths aus dem
   State, damit manuelle Syncs auch nach add_sync_path greifen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:25:01 +02:00

278 lines
9.2 KiB
Rust

use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone)]
pub struct MiniCloudApi {
client: Client,
pub server_url: String,
pub access_token: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LoginResponse {
pub access_token: String,
pub user: UserInfo,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub id: i64,
pub username: String,
pub role: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileEntry {
pub id: i64,
pub name: String,
pub is_folder: bool,
pub size: Option<i64>,
pub checksum: Option<String>,
pub updated_at: Option<String>,
pub children: Option<Vec<FileEntry>>,
pub locked: Option<bool>,
pub locked_by: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SyncTreeResponse {
pub tree: Vec<FileEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct SyncChangesResponse {
pub changes: Vec<FileEntry>,
pub server_time: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct LockResponse {
pub locked: Option<bool>,
pub locked_by: Option<String>,
pub error: Option<String>,
}
impl MiniCloudApi {
pub fn new(server_url: &str) -> Self {
Self {
client: Client::builder()
.danger_accept_invalid_certs(false)
.build()
.unwrap(),
server_url: server_url.trim_end_matches('/').to_string(),
access_token: String::new(),
}
}
fn auth_header(&self) -> String {
format!("Bearer {}", self.access_token)
}
pub async fn login(&mut self, username: &str, password: &str) -> Result<LoginResponse, String> {
let url = format!("{}/api/auth/login", self.server_url);
let body = serde_json::json!({
"username": username,
"password": password,
});
let resp = self.client.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("Verbindungsfehler: {}", e))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("Login fehlgeschlagen: {}", text));
}
let data: LoginResponse = resp.json().await
.map_err(|e| format!("Antwort-Fehler: {}", e))?;
self.access_token = data.access_token.clone();
Ok(data)
}
pub async fn refresh_token(&mut self) -> Result<String, String> {
let url = format!("{}/api/auth/refresh", self.server_url);
let resp = self.client.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| format!("Refresh fehlgeschlagen: {}", e))?;
if !resp.status().is_success() {
return Err("Token-Refresh fehlgeschlagen".to_string());
}
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if let Some(token) = data.get("access_token").and_then(|t| t.as_str()) {
self.access_token = token.to_string();
Ok(token.to_string())
} else {
Err("Kein Token in Antwort".to_string())
}
}
pub async fn get_sync_tree(&self) -> Result<Vec<FileEntry>, String> {
let url = format!("{}/api/sync/tree", self.server_url);
let resp = self.client.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| format!("Sync-Tree Fehler: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("Sync-Tree HTTP {}: {}", status, text));
}
let data: SyncTreeResponse = resp.json().await
.map_err(|e| format!("Sync-Tree Parse-Fehler: {}", e))?;
Ok(data.tree)
}
#[allow(dead_code)]
pub async fn get_changes(&self, since: &str) -> Result<SyncChangesResponse, String> {
let url = format!("{}/api/sync/changes?since={}", self.server_url, since);
let resp = self.client.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| format!("Changes Fehler: {}", e))?;
resp.json().await.map_err(|e| format!("Parse-Fehler: {}", e))
}
pub async fn download_file(&self, file_id: i64, dest: &Path) -> Result<(), String> {
let url = format!("{}/api/files/{}/download?token={}",
self.server_url, file_id, self.access_token);
let resp = self.client.get(&url)
.send()
.await
.map_err(|e| format!("Download Fehler: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Download fehlgeschlagen: {}", resp.status()));
}
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(dest, &bytes).map_err(|e| format!("Schreiben fehlgeschlagen: {}", e))
}
pub async fn upload_file(&self, file_path: &Path, parent_id: Option<i64>) -> Result<FileEntry, String> {
let url = format!("{}/api/files/upload", self.server_url);
let file_name = file_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
let file_bytes = std::fs::read(file_path)
.map_err(|e| format!("Datei lesen fehlgeschlagen: {}", e))?;
let mut form = reqwest::multipart::Form::new()
.part("file", reqwest::multipart::Part::bytes(file_bytes).file_name(file_name));
if let Some(pid) = parent_id {
form = form.text("parent_id", pid.to_string());
}
let resp = self.client.post(&url)
.header("Authorization", self.auth_header())
.multipart(form)
.send()
.await
.map_err(|e| format!("Upload Fehler: {}", e))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("Upload fehlgeschlagen: {}", text));
}
resp.json().await.map_err(|e| format!("Parse-Fehler: {}", e))
}
pub async fn create_folder(&self, name: &str, parent_id: Option<i64>) -> Result<FileEntry, String> {
let url = format!("{}/api/files/folder", self.server_url);
let body = serde_json::json!({
"name": name,
"parent_id": parent_id,
});
let resp = self.client.post(&url)
.header("Authorization", self.auth_header())
.json(&body)
.send()
.await
.map_err(|e| format!("Create-Folder Verbindungsfehler: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("Create-Folder fehlgeschlagen ({}): {}", status, text));
}
resp.json().await.map_err(|e| format!("Create-Folder Parse-Fehler: {}", e))
}
pub async fn lock_file(&self, file_id: i64, client_info: &str) -> Result<(), String> {
let url = format!("{}/api/files/{}/lock", self.server_url, file_id);
let body = serde_json::json!({ "client_info": client_info });
let resp = self.client.post(&url)
.header("Authorization", self.auth_header())
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status().as_u16() == 423 {
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let by = data.get("locked_by").and_then(|v| v.as_str()).unwrap_or("?");
return Err(format!("Datei gesperrt von {}", by));
}
if !resp.status().is_success() {
return Err("Lock fehlgeschlagen".to_string());
}
Ok(())
}
pub async fn unlock_file(&self, file_id: i64) -> Result<(), String> {
let url = format!("{}/api/files/{}/unlock", self.server_url, file_id);
self.client.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn delete_file(&self, file_id: i64) -> Result<(), String> {
let url = format!("{}/api/files/{}", self.server_url, file_id);
let resp = self.client.delete(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| format!("Delete Fehler: {}", e))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("Delete fehlgeschlagen: {}", text));
}
Ok(())
}
pub async fn heartbeat(&self, file_id: i64) -> Result<(), String> {
let url = format!("{}/api/files/{}/heartbeat", self.server_url, file_id);
self.client.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
}