package main import ( "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "strings" "time" "inou/lib" ) // Upload represents a file upload entry for display type Upload struct { ID string FileName, FilePath, SizeHuman, UploadedAt, ExpiresAt, DeletedReason string Category, Status string Deleted bool } // UploadData is the JSON structure stored in Entry.Data for uploads type UploadData struct { Path string `json:"path"` RelPath string `json:"rel_path,omitempty"` // Original relative path for folder uploads Size int64 `json:"size"` UploadedBy string `json:"uploaded_by"` Status string `json:"status"` } func formatBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp]) } // getUploads returns all uploads for a dossier using lib.EntryList func getUploads(dossierID string) []Upload { var uploads []Upload entries, err := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryUpload, &lib.EntryFilter{ // nil ctx - internal operation DossierID: dossierID, Limit: 50, }) if err != nil { return uploads } for _, e := range entries { var data UploadData json.Unmarshal([]byte(e.Data), &data) u := Upload{ ID: e.EntryID, FileName: e.Value, FilePath: data.Path, Category: e.Type, Status: data.Status, SizeHuman: formatBytes(data.Size), UploadedAt: time.Unix(e.Timestamp, 0).Format("Jan 2"), ExpiresAt: time.Unix(e.TimestampEnd, 0).Format("Jan 2"), } if e.Status != 0 { u.Deleted = true u.DeletedReason = "Deleted" } uploads = append(uploads, u) } return uploads } // getUploadEntry retrieves a single upload entry using lib.EntryGet func getUploadEntry(entryID, dossierID string) (filePath, fileName, category, status string, deleted bool) { e, err := lib.EntryGet(nil, entryID) // nil ctx - internal operation if err != nil || e == nil { return } // Verify it belongs to the right dossier and is an upload if e.DossierID != dossierID || e.Category != lib.CategoryUpload { return } var data UploadData json.Unmarshal([]byte(e.Data), &data) fileName = e.Value category = e.Type filePath = data.Path status = data.Status deleted = e.Status != 0 return } // findUploadByFilename finds existing uploads with the same filename func findUploadByFilename(dossierID, filename string) []*lib.Entry { entries, err := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryUpload, &lib.EntryFilter{ // nil ctx - internal operation DossierID: dossierID, Value: filename, }) if err != nil { return nil } return entries } func handleUploadPage(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { http.NotFound(w, r) return } targetID := parts[2] isSelf := targetID == p.DossierID if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.CanEdit { http.Error(w, "Forbidden", http.StatusForbidden) return } } target, _ := lib.DossierGet(nil, targetID) // nil ctx - internal operation if target == nil { http.NotFound(w, r) return } lang := getLang(r) data := PageData{ Page: "upload", Lang: lang, T: translations[lang], Dossier: p, TargetDossier: target, UploadList: getUploads(targetID), } w.Header().Set("Content-Type", "text/html; charset=utf-8") templates.ExecuteTemplate(w, "base.tmpl", data) } func handleUploadPost(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { http.NotFound(w, r) return } targetID := parts[2] isSelf := targetID == p.DossierID if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.CanEdit { http.Error(w, "Forbidden", http.StatusForbidden) return } } r.ParseMultipartForm(10 << 30) file, header, err := r.FormFile("file") if err != nil { http.Error(w, "No file", http.StatusBadRequest) return } defer file.Close() relPath := r.FormValue("path") if relPath == "" { relPath = header.Filename } fileName := filepath.Base(relPath) category := r.FormValue("category") // Read file content content, err := io.ReadAll(file) if err != nil { http.Error(w, "Failed to read", http.StatusInternalServerError) return } // Generate file ID using lib.NewID() (Hex16 format) fileID := lib.NewID() // Store in uploads directory uploadDir := filepath.Join(uploadsDir, formatHexID(targetID)) filePath := filepath.Join(uploadDir, fileID) os.MkdirAll(uploadDir, 0755) if err := lib.EncryptFile(content, filePath); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"status":"error","message":"Upload failed, we've been notified"}`)) return } written := int64(len(content)) // Delete existing upload with same filename (re-upload cleanup) existingUploads := findUploadByFilename(targetID, fileName) for _, old := range existingUploads { lib.EntryDeleteTree(nil, targetID, old.EntryID) // nil ctx = internal upload cleanup } now := time.Now().Unix() expires := now + 7*24*60*60 uploadData := UploadData{ Path: filePath, RelPath: relPath, Size: written, UploadedBy: p.DossierID, Status: "uploaded", } dataJSON, _ := json.Marshal(uploadData) uploadEntry := &lib.Entry{ DossierID: targetID, Category: lib.CategoryUpload, Type: category, Value: fileName, Timestamp: now, TimestampEnd: expires, Data: string(dataJSON), } lib.EntryWrite(nil, uploadEntry) // nil ctx - internal operation entryID := uploadEntry.EntryID lib.AuditLog(p.DossierID, "file_upload", targetID, fileName) // Notify via Signal if target, _ := lib.DossierGet(nil, targetID); target != nil { // nil ctx - internal operation sizeStr := fmt.Sprintf("%d B", written) if written >= 1024*1024 { sizeStr = fmt.Sprintf("%.1f MB", float64(written)/(1024*1024)) } else if written >= 1024 { sizeStr = fmt.Sprintf("%.1f KB", float64(written)/1024) } lib.SendSignal(fmt.Sprintf("Upload: %s → %s (%s, %s, %s)", p.Name, target.Name, category, fileName, sizeStr)) } // Spawn genome processing for genetics uploads if category == "genetics" { go processGenomeUpload(entryID, targetID, filePath) } w.Header().Set("Content-Type", "application/json") w.Write([]byte(fmt.Sprintf(`{"status":"ok","id":"%s"}`, entryID))) } func handleDeleteFile(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } p := getLoggedInDossier(r) if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Parse /dossier/{hex}/files/{id}/delete parts := strings.Split(r.URL.Path, "/") if len(parts) < 6 { http.NotFound(w, r) return } targetID := parts[2] fileID := parts[4] isSelf := targetID == p.DossierID if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.CanEdit { http.Error(w, "Forbidden", http.StatusForbidden) return } } // Get file info for audit and deletion filePath, fileName, _, _, _ := getUploadEntry(fileID, targetID) if filePath != "" { os.Remove(filePath) } // Mark as deleted using lib.EntryGet + lib.EntryWrite entry, err := lib.EntryGet(nil, fileID) // nil ctx - internal operation if err == nil && entry != nil && entry.DossierID == targetID { entry.Status = 1 // Mark as deleted lib.EntryWrite(nil, entry) // nil ctx - internal operation } lib.AuditLog(p.DossierID, "file_delete", targetID, fileName) http.Redirect(w, r, fmt.Sprintf("/dossier/%s/upload", formatHexID(targetID)), http.StatusSeeOther) } func handleUpdateFile(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } p := getLoggedInDossier(r) if p == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } // Parse /dossier/{hex}/files/{id}/update parts := strings.Split(r.URL.Path, "/") if len(parts) < 6 { http.NotFound(w, r) return } targetID := parts[2] fileID := parts[4] isSelf := targetID == p.DossierID if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.CanEdit { http.Error(w, "Forbidden", http.StatusForbidden) return } } // Get entry using lib.EntryGet entry, err := lib.EntryGet(nil, fileID) // nil ctx - internal operation if err != nil || entry == nil || entry.DossierID != targetID || entry.Category != lib.CategoryUpload { http.NotFound(w, r) return } if entry.Status != 0 { http.NotFound(w, r) // Deleted return } var data UploadData json.Unmarshal([]byte(entry.Data), &data) if data.Status != "uploaded" { http.Error(w, "Cannot modify processed file", http.StatusBadRequest) return } newCategory := r.FormValue("category") if newCategory != "" && newCategory != entry.Type { entry.Type = newCategory lib.EntryWrite(nil, entry) // nil ctx - internal operation lib.AuditLog(p.DossierID, "file_category_change", targetID, entry.Value) } http.Redirect(w, r, fmt.Sprintf("/dossier/%s/upload", formatHexID(targetID)), http.StatusSeeOther) } func handleProcessImaging(w http.ResponseWriter, r *http.Request) { logFile := "/tmp/process-imaging.log" log := func(msg string) { f, _ := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); f.WriteString(msg + "\n"); f.Close() } log(fmt.Sprintf("Request: %s %s", r.Method, r.URL.Path)) if r.Method != "POST" { log("Rejected: not POST") http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } log("Method OK") p := getLoggedInDossier(r) if p == nil { log("Rejected: not logged in") http.Error(w, "Unauthorized", http.StatusUnauthorized) return } log(fmt.Sprintf("Logged in as: %s", p.DossierID)) // Parse /dossier/{hex}/process-imaging parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { http.NotFound(w, r) return } targetID := parts[2] isSelf := targetID == p.DossierID log(fmt.Sprintf("Target: %s, isSelf: %v", targetID, isSelf)) if !isSelf { if access, found := getAccess(formatHexID(p.DossierID), formatHexID(targetID)); !found || !access.CanEdit { log("Rejected: no access") http.Error(w, "Forbidden", http.StatusForbidden) return } } log("Access OK") // Get all uploads with status=uploaded (any type for now) entries, err := lib.EntryList(lib.SystemAccessorID, "", lib.CategoryUpload, &lib.EntryFilter{ DossierID: targetID, }) if err != nil { log(fmt.Sprintf("EntryList error: %v", err)) http.Error(w, "Failed to list uploads", http.StatusInternalServerError) return } log(fmt.Sprintf("Found %d entries", len(entries))) // Filter to only "uploaded" status var pending []*lib.Entry for _, e := range entries { if e.Status != 0 { continue // deleted } var data UploadData json.Unmarshal([]byte(e.Data), &data) if data.Status == "uploaded" { pending = append(pending, e) } } log(fmt.Sprintf("Pending uploads: %d", len(pending))) if len(pending) == 0 { log("No pending - sending done") w.Header().Set("Content-Type", "text/event-stream") fmt.Fprintf(w, "data: {\"type\":\"done\",\"message\":\"No pending imaging uploads\"}\n\n") return } // Create temp directory for decrypted files tempDir, err := os.MkdirTemp("", "dicom-import-*") if err != nil { log("Failed to create temp dir") http.Error(w, "Failed to create temp dir", http.StatusInternalServerError) return } defer os.RemoveAll(tempDir) log(fmt.Sprintf("Temp dir: %s", tempDir)) // Decrypt all files to temp dir preserving relative paths for i, e := range pending { var data UploadData json.Unmarshal([]byte(e.Data), &data) if i%50 == 0 { log(fmt.Sprintf("Decrypting %d of %d...", i+1, len(pending))) } relPath := data.RelPath if relPath == "" { relPath = e.Value } outPath := filepath.Join(tempDir, relPath) os.MkdirAll(filepath.Dir(outPath), 0755) content, err := lib.DecryptFile(data.Path) if err != nil { continue } os.WriteFile(outPath, content, 0644) data.Status = "processing" dataJSON, _ := json.Marshal(data) e.Data = string(dataJSON) lib.EntryWrite(nil, e) } log("Decryption complete, running import-dicom") // Run import-dicom synchronously cmd := exec.Command("/tank/inou/bin/import-dicom", targetID, tempDir) output, cmdErr := cmd.CombinedOutput() log(fmt.Sprintf("import-dicom output: %s", string(output))) // Update all entries based on result status := "processed" if cmdErr != nil { status = "failed" } for _, e := range pending { var data UploadData json.Unmarshal([]byte(e.Data), &data) data.Status = status dataJSON, _ := json.Marshal(data) e.Data = string(dataJSON) lib.EntryWrite(nil, e) } // Log result lib.AuditLog(p.DossierID, "dicom_import", targetID, fmt.Sprintf("files=%d status=%s", len(pending), status)) w.Header().Set("Content-Type", "application/json") if cmdErr != nil { log(fmt.Sprintf("Import failed: %v", cmdErr)) w.Write([]byte(fmt.Sprintf(`{"status":"error","message":"Import failed: %v"}`, cmdErr))) } else { log(fmt.Sprintf("Import success: %d files", len(pending))) w.Write([]byte(fmt.Sprintf(`{"status":"ok","message":"Successfully imported %d files"}`, len(pending)))) } }