From 2e730832a7de60bb306060910e38825ede1c509d Mon Sep 17 00:00:00 2001 From: tian <11429339@qq.com> Date: Wed, 6 May 2026 13:28:29 +0800 Subject: [PATCH] feat: add GET /v1/resources/status and PUT /v1/resources/{type}/{name} endpoints --- agent/internal/httpapi/resources.go | 215 ++++++++++++++++++++++++++++ agent/internal/httpapi/server.go | 2 + 2 files changed, 217 insertions(+) create mode 100644 agent/internal/httpapi/resources.go diff --git a/agent/internal/httpapi/resources.go b/agent/internal/httpapi/resources.go new file mode 100644 index 0000000..9c14b9e --- /dev/null +++ b/agent/internal/httpapi/resources.go @@ -0,0 +1,215 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "rk3588sys/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"` +} + +func (s *Server) resourcesDir() string { + return filepath.Join(s.agentCfg.ModelsDir, "resources") +} + +// 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) + + // 1. Face gallery (stored at modelsDir/face_gallery.db) + fgPath := filepath.Join(s.agentCfg.ModelsDir, "face_gallery.db") + if st, err := os.Stat(fgPath); err == nil { + sha, _ := fileSHA256HTTP(fgPath) + resources = append(resources, installedResource{ + Name: "face_gallery_v1", + ResourceType: "face_gallery", + SHA256: sha, + SizeBytes: st.Size(), + UpdatedAt: st.ModTime().Unix(), + }) + } + + // 2. Resources directory — scan each type subdirectory + 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 +} diff --git a/agent/internal/httpapi/server.go b/agent/internal/httpapi/server.go index 078abe5..8b75c03 100644 --- a/agent/internal/httpapi/server.go +++ b/agent/internal/httpapi/server.go @@ -126,6 +126,8 @@ func New(agentCfg config.AgentConfig, baseDir string, ms *mediaserver.Client, st mux.HandleFunc("/v1/config/ui/apply", s.handleConfigUIApply) mux.HandleFunc("/v1/face-gallery", s.handleFaceGallery) mux.HandleFunc("/v1/face-gallery/reload", s.handleFaceGalleryReload) + mux.HandleFunc("/v1/resources/status", s.handleResourcesStatus) + mux.HandleFunc("/v1/resources/", s.handleResourceUpload) mux.HandleFunc("/v1/models", s.handleModelsList) mux.HandleFunc("/v1/models/status", s.handleModelsStatus) mux.HandleFunc("/v1/models/", s.handleModelUpload)