503 lines
14 KiB
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))))
|
|
}
|
|
}
|