5f905b4925
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>
278 lines
9.2 KiB
Rust
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(())
|
|
}
|
|
}
|