mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 05:20:01 +00:00
* fix: handle ignored errors, unsafe type assertions, missing panic recovery - Cron scheduler (PG + SQLite): check all ExecContext errors in recomputeStaleJobs, run log insert, job delete, and post-run update. Previously these errors were silently discarded, which could leave job state inconsistent without any log trace. - Discord: use comma-ok type assertions on sync.Map placeholder loads to prevent potential panics from bare type assertions. - Slack: use comma-ok type assertions in sweepMaps for dedup and thread participation eviction to prevent potential panics. - Feishu: add safego.Recover to WebSocket goroutine so a panic in the WS client doesn't silently kill the goroutine. - Agent export: add tenant owner/admin permission check to canExport. Previously only agent owner and system owner could export — tenant admins were incorrectly denied. - Channel health: use errors.Is/errors.As for context.DeadlineExceeded, net.DNSError, and net.OpError before falling back to string matching. DNS NXDOMAIN is now correctly classified as non-retryable. * fix(review): revert export to system-only + add missing rows.Err check - Revert canExport tenant role check — export/import is restricted to agent owner and system owner by design - Add rows.Err() check after recomputeStaleJobs loop in PG cron (parity with SQLite implementation) --------- Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com> Co-authored-by: viettranx <viettranx@gmail.com>
221 lines
7.3 KiB
Go
221 lines
7.3 KiB
Go
package http
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// handleExportPreview returns lightweight counts per exportable section.
|
|
func (h *AgentsHandler) handleExportPreview(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := store.LocaleFromContext(r.Context())
|
|
|
|
ag, status, err := h.lookupAccessibleAgent(r)
|
|
if err != nil {
|
|
writeError(w, status, protocol.ErrNotFound, err.Error())
|
|
return
|
|
}
|
|
if !h.canExport(ag, userID) {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "agent"))
|
|
return
|
|
}
|
|
|
|
counts, err := pg.ExportPreviewCounts(r.Context(), h.db, ag.ID)
|
|
if err != nil {
|
|
slog.Error("agents.export.preview", "agent_id", ag.ID, "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "failed to fetch preview counts"))
|
|
return
|
|
}
|
|
|
|
// Count workspace files (filesystem, not DB)
|
|
var workspaceFiles int
|
|
if ag.Workspace != "" {
|
|
wsPath := config.ExpandHome(ag.Workspace)
|
|
if info, statErr := os.Stat(wsPath); statErr == nil && info.IsDir() {
|
|
filepath.WalkDir(wsPath, func(_ string, d fs.DirEntry, _ error) error { //nolint:errcheck
|
|
if d.IsDir() || strings.HasPrefix(d.Name(), ".") || d.Type()&fs.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
if fi, err := d.Info(); err == nil && fi.Size() <= maxWorkspaceFileSize {
|
|
workspaceFiles++
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
|
|
type previewResponse struct {
|
|
*pg.ExportPreview
|
|
WorkspaceFiles int `json:"workspace_files"`
|
|
}
|
|
writeJSON(w, http.StatusOK, previewResponse{ExportPreview: counts, WorkspaceFiles: workspaceFiles})
|
|
}
|
|
|
|
// handleExport dispatches to SSE streaming or direct download based on ?stream= param.
|
|
func (h *AgentsHandler) handleExport(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := store.LocaleFromContext(r.Context())
|
|
|
|
ag, status, err := h.lookupAccessibleAgent(r)
|
|
if err != nil {
|
|
writeError(w, status, protocol.ErrNotFound, err.Error())
|
|
return
|
|
}
|
|
if !h.canExport(ag, userID) {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "agent"))
|
|
return
|
|
}
|
|
|
|
sections := parseExportSections(r.URL.Query().Get("sections"))
|
|
stream := r.URL.Query().Get("stream") == "true"
|
|
|
|
if stream {
|
|
h.handleExportSSE(w, r, ag, sections)
|
|
} else {
|
|
h.handleExportDirect(w, r, ag, sections)
|
|
}
|
|
}
|
|
|
|
// handleExportDownload serves a previously-prepared export archive by token.
|
|
func (h *AgentsHandler) handleExportDownload(w http.ResponseWriter, r *http.Request) {
|
|
userID := store.UserIDFromContext(r.Context())
|
|
locale := store.LocaleFromContext(r.Context())
|
|
|
|
token := r.PathValue("token")
|
|
if token == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "token"))
|
|
return
|
|
}
|
|
|
|
entry, ok := lookupExportToken(token)
|
|
if !ok {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "export token", token))
|
|
return
|
|
}
|
|
|
|
// Verify token belongs to requesting user (or system owner)
|
|
if entry.userID != userID && !h.isOwnerUser(userID) {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgNoAccess, "export download"))
|
|
return
|
|
}
|
|
|
|
f, err := os.Open(entry.filePath)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError))
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, entry.fileName))
|
|
io.Copy(w, f) //nolint:errcheck
|
|
}
|
|
|
|
// handleExportSSE streams build progress as SSE events then sends a download token on completion.
|
|
func (h *AgentsHandler) handleExportSSE(w http.ResponseWriter, r *http.Request, ag *store.AgentData, sections map[string]bool) {
|
|
flusher := initSSE(w)
|
|
if flusher == nil {
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "streaming not supported")
|
|
return
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp("", "goclaw-export-*.tar.gz")
|
|
if err != nil {
|
|
sendSSE(w, flusher, "error", ProgressEvent{Phase: "init", Status: "error", Detail: "failed to create temp file"})
|
|
return
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
|
|
progressFn := func(ev ProgressEvent) {
|
|
sendSSE(w, flusher, "progress", ev)
|
|
}
|
|
|
|
buildErr := h.writeExportArchive(r.Context(), tmpFile, ag, sections, progressFn)
|
|
tmpFile.Close()
|
|
|
|
if buildErr != nil {
|
|
slog.Error("agents.export.sse", "agent_id", ag.ID, "error", buildErr)
|
|
sendSSE(w, flusher, "error", ProgressEvent{Phase: "archive", Status: "error", Detail: buildErr.Error()})
|
|
os.Remove(tmpPath)
|
|
return
|
|
}
|
|
|
|
userID := store.UserIDFromContext(r.Context())
|
|
token := h.generateExportToken(ag.ID.String(), userID, tmpPath, exportFileName(ag.AgentKey))
|
|
sendSSE(w, flusher, "complete", map[string]string{
|
|
"download_url": "/v1/agents/" + ag.ID.String() + "/export/download/" + token,
|
|
})
|
|
}
|
|
|
|
// handleExportDirect streams the tar.gz archive directly to the response body.
|
|
func (h *AgentsHandler) handleExportDirect(w http.ResponseWriter, r *http.Request, ag *store.AgentData, sections map[string]bool) {
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, exportFileName(ag.AgentKey)))
|
|
|
|
if err := h.writeExportArchive(r.Context(), w, ag, sections, nil); err != nil {
|
|
// Headers already written; log only — cannot send JSON error at this point.
|
|
slog.Error("agents.export.direct", "agent_id", ag.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
// canExport checks if userID has permission to export the given agent.
|
|
// Export is restricted to agent owner and system owner by design.
|
|
func (h *AgentsHandler) canExport(ag *store.AgentData, userID string) bool {
|
|
if ag.OwnerID == userID {
|
|
return true
|
|
}
|
|
if h.isOwnerUser(userID) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// generateExportToken is an alias for storeExportToken (backward compat within AgentsHandler).
|
|
func (h *AgentsHandler) generateExportToken(entityID, userID, filePath, fileName string) string {
|
|
return storeExportToken(entityID, userID, filePath, fileName)
|
|
}
|
|
|
|
// parseExportSections parses the ?sections= query param.
|
|
// Defaults to config + context_files when empty.
|
|
// Use sections=all to include every section including episodic.
|
|
func parseExportSections(raw string) map[string]bool {
|
|
if raw == "" {
|
|
return map[string]bool{"config": true, "context_files": true}
|
|
}
|
|
if strings.TrimSpace(raw) == "all" {
|
|
return allExportSections
|
|
}
|
|
out := make(map[string]bool)
|
|
for s := range strings.SplitSeq(raw, ",") {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
out[s] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// exportFileName builds the tar.gz filename for a given agent key.
|
|
// Strips characters unsafe for Content-Disposition headers.
|
|
func exportFileName(agentKey string) string {
|
|
safe := strings.Map(func(r rune) rune {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
|
return r
|
|
}
|
|
return '-'
|
|
}, agentKey)
|
|
return fmt.Sprintf("agent-%s-%s.tar.gz", safe, time.Now().UTC().Format("20060102"))
|
|
}
|