package httpapi import ( "crypto/sha256" "encoding/hex" "io" "net/http" "os" "path/filepath" "sort" "strings" "safesight-agent/internal/files" ) type installedResource struct { Name string `json:"name"` ResourceType string `json:"resource_type"` SHA256 string `json:"sha256"` SizeBytes int64 `json:"size_bytes"` UpdatedAt int64 `json:"updated_at"` } // handleResourcesStatus returns all installed resources on this device. // GET /v1/resources/status func (s *Server) handleResourcesStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") return } if !s.authorize(r, false) { errorJSON(w, http.StatusUnauthorized, "unauthorized") return } resources := make([]installedResource, 0) // Scan resourcesDir — each subdirectory is a resource_type resDir := s.resourcesDir entries, err := os.ReadDir(resDir) if err == nil { for _, entry := range entries { if !entry.IsDir() { continue } resourceType := entry.Name() typeDir := filepath.Join(resDir, resourceType) files, ferr := os.ReadDir(typeDir) if ferr != nil { continue } for _, f := range files { if f.IsDir() { continue } fullPath := filepath.Join(typeDir, f.Name()) st, serr := os.Stat(fullPath) if serr != nil { continue } sha, _ := fileSHA256HTTP(fullPath) name := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name())) resources = append(resources, installedResource{ Name: name, ResourceType: resourceType, SHA256: sha, SizeBytes: st.Size(), UpdatedAt: st.ModTime().Unix(), }) } } } sort.Slice(resources, func(i, j int) bool { if resources[i].ResourceType != resources[j].ResourceType { return resources[i].ResourceType < resources[j].ResourceType } return resources[i].Name < resources[j].Name }) writeJSON(w, http.StatusOK, map[string]any{"resources": resources}) } // handleResourceUpload accepts a resource file upload. // PUT /v1/resources/{resource_type}/{name} func (s *Server) handleResourceUpload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { errorJSON(w, http.StatusMethodNotAllowed, "method not allowed") return } if !s.authorize(r, true) { errorJSON(w, http.StatusUnauthorized, "unauthorized") return } if ct := strings.TrimSpace(r.Header.Get("Content-Type")); ct != "application/octet-stream" { errorJSON(w, http.StatusBadRequest, "validation failed: Content-Type must be application/octet-stream") return } // Parse path: /v1/resources/{resource_type}/{name} path := strings.TrimPrefix(r.URL.Path, "/v1/resources/") parts := strings.SplitN(path, "/", 2) if len(parts) != 2 { errorJSON(w, http.StatusBadRequest, "validation failed: path must be /v1/resources/{resource_type}/{name}") return } resourceType := strings.TrimSpace(parts[0]) name := strings.TrimSpace(parts[1]) if resourceType == "" || name == "" { errorJSON(w, http.StatusBadRequest, "validation failed: resource_type and name are required") return } if strings.Contains(resourceType, "..") || strings.Contains(name, "..") { errorJSON(w, http.StatusBadRequest, "validation failed: invalid path") return } maxBytes := int64(s.agentCfg.MaxUploadMB) * 1024 * 1024 r.Body = http.MaxBytesReader(w, r.Body, maxBytes) typeDir := filepath.Join(s.resourcesDir, resourceType) if err := files.EnsureDir(typeDir, 0o755); err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } dst := filepath.Join(typeDir, name+".db") f, err := os.CreateTemp(typeDir, ".tmp-resource-*") if err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } tmp := f.Name() ok := false defer func() { _ = f.Close() if !ok { _ = os.Remove(tmp) } }() if _, err := f.ReadFrom(r.Body); err != nil { errorJSON(w, http.StatusBadRequest, "invalid body: "+err.Error()) return } if err := f.Chmod(0o644); err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } if err := f.Sync(); err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } if err := f.Close(); err != nil { errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } if err := files.ReplaceFile(tmp, dst); err != nil { s.recordAudit(r, "resource.upload", false, err.Error()) errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } ok = true st, err := os.Stat(dst) if err != nil { s.recordAudit(r, "resource.upload", false, err.Error()) errorJSON(w, http.StatusInternalServerError, "internal error: "+err.Error()) return } sha, _ := fileSHA256HTTP(dst) s.recordAudit(r, "resource.upload", true, path) writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "name": name, "resource_type": resourceType, "path": filepath.ToSlash(dst), "sha256": sha, "size": st.Size(), "mtime_ms": st.ModTime().UnixMilli(), }) } func fileSHA256HTTP(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil }