Skip to main content

offline_intelligence/api/
files_api.rs

1//! Files API endpoints - Database-backed local file management
2//!
3//! Provides persistent file storage with metadata in SQLite and content in app data folder.
4//! Supports nested folder hierarchy and 10MB file size limit.
5
6use axum::{
7    extract::{Multipart, Path, Query, State},
8    http::StatusCode,
9    response::IntoResponse,
10    Json,
11};
12use serde::{Deserialize, Serialize};
13use tracing::{error, info};
14
15use crate::shared_state::UnifiedAppState;
16use crate::memory_db::{LocalFile, LocalFileTree};
17
18/// Response structure for file entries (compatible with frontend)
19#[derive(Debug, Serialize)]
20pub struct FileEntryResponse {
21    pub id: i64,
22    pub name: String,
23    pub path: String,
24    #[serde(rename = "isDirectory")]
25    pub is_directory: bool,
26    pub size: i64,
27    pub modified: String,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub children: Option<Vec<FileEntryResponse>>,
30}
31
32impl From<LocalFile> for FileEntryResponse {
33    fn from(f: LocalFile) -> Self {
34        Self {
35            id: f.id,
36            name: f.name,
37            path: f.path,
38            is_directory: f.is_directory,
39            size: f.size_bytes,
40            modified: f.modified_at.to_rfc3339(),
41            children: None,
42        }
43    }
44}
45
46impl From<LocalFileTree> for FileEntryResponse {
47    fn from(t: LocalFileTree) -> Self {
48        Self {
49            id: t.file.id,
50            name: t.file.name,
51            path: t.file.path,
52            is_directory: t.file.is_directory,
53            size: t.file.size_bytes,
54            modified: t.file.modified_at.to_rfc3339(),
55            children: t.children.map(|c| c.into_iter().map(FileEntryResponse::from).collect()),
56        }
57    }
58}
59
60#[derive(Debug, Deserialize)]
61pub struct CreateFolderRequest {
62    pub name: String,
63    #[serde(default)]
64    pub parent_id: Option<i64>,
65}
66
67#[derive(Debug, Deserialize)]
68pub struct DeleteQuery {
69    #[serde(default)]
70    pub path: Option<String>,
71    #[serde(default)]
72    pub id: Option<i64>,
73}
74
75#[derive(Debug, Deserialize)]
76pub struct SearchQuery {
77    pub q: String,
78}
79
80#[derive(Debug, Deserialize)]
81pub struct UploadQuery {
82    #[serde(default)]
83    pub parent_id: Option<i64>,
84}
85
86/// GET /files - Get all files as a nested tree
87pub async fn get_files(
88    State(state): State<UnifiedAppState>,
89) -> Result<impl IntoResponse, StatusCode> {
90    let local_files = &state.shared_state.database_pool.local_files;
91    
92    match local_files.get_file_tree() {
93        Ok(tree) => {
94            let response: Vec<FileEntryResponse> = tree.into_iter()
95                .map(FileEntryResponse::from)
96                .collect();
97            Ok(Json(response))
98        }
99        Err(e) => {
100            error!("Failed to get files: {}", e);
101            // Return empty array on error for compatibility
102            Ok(Json(Vec::<FileEntryResponse>::new()))
103        }
104    }
105}
106
107/// GET /files/:id - Get single file metadata
108pub async fn get_file_by_id(
109    State(state): State<UnifiedAppState>,
110    Path(id): Path<i64>,
111) -> Result<impl IntoResponse, StatusCode> {
112    let local_files = &state.shared_state.database_pool.local_files;
113    
114    match local_files.get_file(id) {
115        Ok(file) => Ok(Json(FileEntryResponse::from(file))),
116        Err(e) => {
117            error!("Failed to get file {}: {}", id, e);
118            Err(StatusCode::NOT_FOUND)
119        }
120    }
121}
122
123/// GET /files/:id/content - Get file content for LLM processing
124pub async fn get_file_content(
125    State(state): State<UnifiedAppState>,
126    Path(id): Path<i64>,
127) -> Result<impl IntoResponse, StatusCode> {
128    let local_files = &state.shared_state.database_pool.local_files;
129    
130    match local_files.get_file_content_string(id) {
131        Ok(content) => {
132            Ok(Json(serde_json::json!({
133                "id": id,
134                "content": content
135            })))
136        }
137        Err(e) => {
138            error!("Failed to get file content {}: {}", id, e);
139            Err(StatusCode::NOT_FOUND)
140        }
141    }
142}
143
144/// GET /files/search?q=... - Search files by name
145pub async fn search_files(
146    State(state): State<UnifiedAppState>,
147    Query(query): Query<SearchQuery>,
148) -> Result<impl IntoResponse, StatusCode> {
149    let local_files = &state.shared_state.database_pool.local_files;
150    
151    match local_files.search_files(&query.q) {
152        Ok(files) => {
153            let response: Vec<FileEntryResponse> = files.into_iter()
154                .map(FileEntryResponse::from)
155                .collect();
156            Ok(Json(response))
157        }
158        Err(e) => {
159            error!("Failed to search files: {}", e);
160            Ok(Json(Vec::<FileEntryResponse>::new()))
161        }
162    }
163}
164
165/// GET /files/all - Get flat list of all files (for @filename autocomplete)
166pub async fn get_all_files(
167    State(state): State<UnifiedAppState>,
168) -> Result<impl IntoResponse, StatusCode> {
169    let local_files = &state.shared_state.database_pool.local_files;
170    
171    match local_files.get_all_files() {
172        Ok(files) => {
173            let response: Vec<FileEntryResponse> = files.into_iter()
174                .map(FileEntryResponse::from)
175                .collect();
176            Ok(Json(response))
177        }
178        Err(e) => {
179            error!("Failed to get all files: {}", e);
180            Ok(Json(Vec::<FileEntryResponse>::new()))
181        }
182    }
183}
184
185/// POST /files/folder - Create a new folder
186pub async fn create_folder(
187    State(state): State<UnifiedAppState>,
188    Json(request): Json<CreateFolderRequest>,
189) -> Result<impl IntoResponse, StatusCode> {
190    let local_files = &state.shared_state.database_pool.local_files;
191    
192    if request.name.trim().is_empty() {
193        return Err(StatusCode::BAD_REQUEST);
194    }
195    
196    match local_files.create_folder(request.parent_id, &request.name) {
197        Ok(folder) => {
198            info!("Created folder: {}", folder.path);
199            Ok(Json(serde_json::json!({
200                "message": "Folder created successfully",
201                "id": folder.id,
202                "path": folder.path
203            })))
204        }
205        Err(e) => {
206            error!("Failed to create folder '{}': {}", request.name, e);
207            Err(StatusCode::INTERNAL_SERVER_ERROR)
208        }
209    }
210}
211
212/// POST /files/upload - Upload files
213pub async fn upload_file(
214    State(state): State<UnifiedAppState>,
215    Query(query): Query<UploadQuery>,
216    mut multipart: Multipart,
217) -> Result<impl IntoResponse, StatusCode> {
218    let local_files = &state.shared_state.database_pool.local_files;
219    
220    let mut file_count = 0;
221    const MAX_FILES: usize = 16;
222    const MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB
223    
224    let allowed_extensions = [
225        // Documents
226        "pdf", "doc", "docx", "txt", "rtf", "odt",
227        // Spreadsheets
228        "xls", "xlsx", "csv", "ods",
229        // Presentations
230        "ppt", "pptx", "odp",
231        // Code files
232        "js", "ts", "jsx", "tsx", "py", "java", "cpp", "c", "cs",
233        "html", "css", "scss", "json", "xml", "yaml", "yml", "md",
234        "go", "rs", "php", "rb", "swift", "kt", "scala", "sql",
235        "sh", "bat", "ps1", "dockerfile", "env", "toml", "ini", "cfg"
236    ];
237
238    while let Some(field) = multipart.next_field().await.map_err(|e| {
239        error!("Error reading multipart field: {}", e);
240        StatusCode::BAD_REQUEST
241    })? {
242        if file_count >= MAX_FILES {
243            return Err(StatusCode::BAD_REQUEST);
244        }
245        
246        let file_name = field.file_name().unwrap_or("unknown_filename").to_string();
247        
248        // Validate file extension
249        let file_extension = std::path::Path::new(&file_name)
250            .extension()
251            .and_then(|ext| ext.to_str())
252            .unwrap_or("")
253            .to_lowercase();
254        
255        if !allowed_extensions.contains(&file_extension.as_str()) {
256            error!("File type not allowed: {}", file_extension);
257            continue; // Skip unsupported files instead of failing entire upload
258        }
259        
260        // Get file data
261        let data = field.bytes().await.map_err(|e| {
262            error!("Error reading file {}: {}", file_name, e);
263            StatusCode::BAD_REQUEST
264        })?;
265
266        // Check file size
267        if data.len() > MAX_FILE_SIZE {
268            error!("File {} exceeds size limit of {} bytes", file_name, MAX_FILE_SIZE);
269            return Err(StatusCode::PAYLOAD_TOO_LARGE);
270        }
271        
272        // Determine MIME type
273        let mime_type = mime_guess::from_path(&file_name)
274            .first()
275            .map(|m| m.to_string());
276
277        // Upload file to database-backed storage
278        match local_files.upload_file(query.parent_id, &file_name, &data, mime_type.as_deref()) {
279            Ok(file) => {
280                info!("Uploaded file: {} ({} bytes)", file.path, data.len());
281                file_count += 1;
282            }
283            Err(e) => {
284                error!("Failed to upload file {}: {}", file_name, e);
285                return Err(StatusCode::INTERNAL_SERVER_ERROR);
286            }
287        }
288    }
289
290    Ok(Json(serde_json::json!({
291        "message": format!("Successfully uploaded {} file(s)", file_count),
292        "count": file_count
293    })))
294}
295
296/// DELETE /files - Delete a file or folder (by id or path query param)
297pub async fn delete_file(
298    State(state): State<UnifiedAppState>,
299    Query(query): Query<DeleteQuery>,
300) -> Result<impl IntoResponse, StatusCode> {
301    let local_files = &state.shared_state.database_pool.local_files;
302    
303    // Get file ID either directly or by path lookup
304    let file_id = if let Some(id) = query.id {
305        id
306    } else if let Some(path) = &query.path {
307        match local_files.get_file_by_path(path) {
308            Ok(file) => file.id,
309            Err(e) => {
310                error!("File not found at path {}: {}", path, e);
311                return Err(StatusCode::NOT_FOUND);
312            }
313        }
314    } else {
315        return Err(StatusCode::BAD_REQUEST);
316    };
317    
318    match local_files.delete_file(file_id) {
319        Ok(()) => {
320            info!("Deleted file/folder with id {}", file_id);
321            Ok(Json(serde_json::json!({
322                "message": "File/directory deleted successfully"
323            })))
324        }
325        Err(e) => {
326            error!("Failed to delete file {}: {}", file_id, e);
327            Err(StatusCode::INTERNAL_SERVER_ERROR)
328        }
329    }
330}
331
332/// DELETE /files/:id - Delete a file or folder by ID
333pub async fn delete_file_by_id(
334    State(state): State<UnifiedAppState>,
335    Path(id): Path<i64>,
336) -> Result<impl IntoResponse, StatusCode> {
337    let local_files = &state.shared_state.database_pool.local_files;
338    
339    match local_files.delete_file(id) {
340        Ok(()) => {
341            info!("Deleted file/folder with id {}", id);
342            Ok(Json(serde_json::json!({
343                "message": "File/directory deleted successfully"
344            })))
345        }
346        Err(e) => {
347            error!("Failed to delete file {}: {}", id, e);
348            Err(StatusCode::INTERNAL_SERVER_ERROR)
349        }
350    }
351}
352
353/// POST /files/sync - Sync filesystem with database (import existing files)
354pub async fn sync_files(
355    State(state): State<UnifiedAppState>,
356) -> Result<impl IntoResponse, StatusCode> {
357    let local_files = &state.shared_state.database_pool.local_files;
358    
359    match local_files.sync_from_filesystem() {
360        Ok(count) => {
361            info!("Synced {} files from filesystem", count);
362            Ok(Json(serde_json::json!({
363                "message": format!("Synced {} files from filesystem", count),
364                "count": count
365            })))
366        }
367        Err(e) => {
368            error!("Failed to sync files: {}", e);
369            Err(StatusCode::INTERNAL_SERVER_ERROR)
370        }
371    }
372}
373
374/// POST /files/resync - Clear database and resync from filesystem (fixes duplicates)
375pub async fn resync_files(
376    State(state): State<UnifiedAppState>,
377) -> Result<impl IntoResponse, StatusCode> {
378    let local_files = &state.shared_state.database_pool.local_files;
379    
380    // First clear all entries
381    match local_files.clear_all() {
382        Ok(cleared) => {
383            info!("Cleared {} entries from local_files", cleared);
384        }
385        Err(e) => {
386            error!("Failed to clear local_files: {}", e);
387            return Err(StatusCode::INTERNAL_SERVER_ERROR);
388        }
389    }
390    
391    // Then resync from filesystem
392    match local_files.sync_from_filesystem() {
393        Ok(count) => {
394            info!("Resynced {} files from filesystem", count);
395            Ok(Json(serde_json::json!({
396                "message": format!("Cleared and resynced {} files from filesystem", count),
397                "count": count
398            })))
399        }
400        Err(e) => {
401            error!("Failed to sync files: {}", e);
402            Err(StatusCode::INTERNAL_SERVER_ERROR)
403        }
404    }
405}