diff --git a/server/orchestration.go b/server/orchestration.go index e7515c4..9f62f7e 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -474,6 +474,11 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool { if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil { return actool.Errorf(err.Error()), nil } + if f, err := s.m.pg.GetFinding(fid); err == nil && f != nil && f.TaskID != nil { + if err := s.archiveFindingScreenshots(*f.TaskID, a.Report); err != nil { + return actool.Errorf("归档报告截图失败: " + err.Error()), nil + } + } } n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion) if err != nil { diff --git a/server/workspace.go b/server/workspace.go index a0d1162..4e2b445 100644 --- a/server/workspace.go +++ b/server/workspace.go @@ -2,6 +2,7 @@ package server import ( "bytes" + "fmt" "io" "mime/multipart" "net/http" @@ -309,6 +310,67 @@ func (s *Server) findSessionPreview(rawPath string) (string, bool) { return newest, newest != "" } +// archiveFindingScreenshots materializes report-referenced screenshots in the +// task workspace. Browser runs use per-session working directories, while +// reports intentionally use the portable findings/screenshots/ path. +func (s *Server) archiveFindingScreenshots(taskID int64, markdown string) error { + if taskID <= 0 { + return nil + } + for _, match := range screenshotMarkdownLink.FindAllStringSubmatch(markdown, -1) { + raw := strings.TrimSpace(match[2]) + rel := filepath.Clean(strings.TrimPrefix(raw, "/")) + const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator) + if !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) { + continue + } + target := filepath.Join(s.m.dir, "tasks", fmt.Sprint(taskID), rel) + if info, err := os.Stat(target); err == nil && !info.IsDir() { + continue + } + source, ok := s.resolveSessionScreenshot(rel) + if !ok { + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + in, err := os.Open(source) + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err == nil { + _, err = io.Copy(out, in) + } + _ = in.Close() + _ = out.Close() + if err != nil { + return err + } + } + return nil +} + +func (s *Server) resolveSessionScreenshot(rel string) (string, bool) { + matches, err := filepath.Glob(filepath.Join(filepath.Clean(s.m.dir), "sessions", "*", rel)) + if err != nil || len(matches) == 0 { + return "", false + } + var newest string + var newestTime int64 + for _, candidate := range matches { + info, statErr := os.Stat(candidate) + if statErr != nil || info.IsDir() { + continue + } + if newest == "" || info.ModTime().UnixNano() > newestTime { + newest, newestTime = candidate, info.ModTime().UnixNano() + } + } + return newest, newest != "" +} + // POST /api/workspace/upload?path=