inou/portal/upload.go

503 lines
14 KiB
Go

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.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.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(targetID, old.EntryID)
}
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.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))))
}
}