diff --git a/internal/store/pg/teams_tasks.go b/internal/store/pg/teams_tasks.go index 040d4a2f..7f24c3d2 100644 --- a/internal/store/pg/teams_tasks.go +++ b/internal/store/pg/teams_tasks.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "time" + "unicode" "github.com/google/uuid" "github.com/lib/pq" @@ -239,12 +240,35 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s if limit <= 0 { limit = 20 } + // Split query into words and join with OR for broader matching + words := strings.Fields(query) + if len(words) == 0 { + return nil, nil + } + var sanitized []string + for _, w := range words { + w = strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' { + return r + } + return -1 + }, w) + w = strings.TrimSpace(w) + if w != "" { + sanitized = append(sanitized, w) + } + } + if len(sanitized) == 0 { + return nil, nil + } + tsq := strings.Join(sanitized, " | ") + rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.team_id = $1 AND t.tsv @@ plainto_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4) - ORDER BY ts_rank(t.tsv, plainto_tsquery('simple', $2)) DESC - LIMIT $3`, teamID, query, limit, userID) + WHERE t.team_id = $1 AND t.tsv @@ to_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4) + ORDER BY ts_rank(t.tsv, to_tsquery('simple', $2)) DESC + LIMIT $3`, teamID, tsq, limit, userID) if err != nil { return nil, err } diff --git a/internal/tools/workspace_media.go b/internal/tools/workspace_media.go index 9e7501ab..48578183 100644 --- a/internal/tools/workspace_media.go +++ b/internal/tools/workspace_media.go @@ -13,6 +13,10 @@ import ( // mediaDocNameRe extracts name and path attributes from tags. var mediaDocNameRe = regexp.MustCompile(`]*\bname="([^"]+)"[^>]*\bpath="([^"]+)"`) +// mediaDocPathNameRe matches path-before-name ordering and Slack file= variant. +var mediaDocPathNameRe = regexp.MustCompile(`]*\bpath="([^"]+)"[^>]*\b(?:name|file)="([^"]+)"`) + + // ExtractMediaNameMap parses message content for tags // and returns a map from absolute file path to original filename. func ExtractMediaNameMap(content string) map[string]string { @@ -22,9 +26,8 @@ func ExtractMediaNameMap(content string) map[string]string { nameMap[m[2]] = m[1] // path → name } } - // Also match path before name: - altRe := regexp.MustCompile(`]*\bpath="([^"]+)"[^>]*\bname="([^"]+)"`) - for _, m := range altRe.FindAllStringSubmatch(content, -1) { + // Also match path-before-name ordering and Slack file= variant. + for _, m := range mediaDocPathNameRe.FindAllStringSubmatch(content, -1) { if len(m) == 3 { if _, exists := nameMap[m[1]]; !exists { nameMap[m[1]] = m[2] // path → name @@ -51,16 +54,22 @@ func copyMediaToWorkspace(mediaPaths []string, wsDir string, nameMap map[string] var copied []string for _, src := range mediaPaths { - srcInfo, err := os.Stat(src) + srcInfo, err := os.Lstat(src) if err != nil { slog.Debug("workspace_media: source file not found", "path", src, "error", err) continue } + // Skip symlinks to prevent following to unexpected locations. + if srcInfo.Mode()&os.ModeSymlink != 0 { + slog.Debug("workspace_media: skipping symlink", "path", src) + continue + } // Use original filename if available, otherwise fall back to UUID name. baseName := filepath.Base(src) if origName, ok := nameMap[src]; ok && origName != "" { - baseName = origName + // Strip directory components to prevent path traversal (e.g. "../../etc/crontab"). + baseName = filepath.Base(origName) } if baseName == "." || baseName == ".." || baseName == "" { slog.Debug("workspace_media: skipping invalid filename", "path", src) @@ -79,12 +88,11 @@ func copyMediaToWorkspace(mediaPaths []string, wsDir string, nameMap map[string] dst = deduplicatePath(attachDir, baseName) } - // Hard link first (same filesystem, no disk usage), fallback to copy. - if err := os.Link(src, dst); err != nil { - if err := copyFile(src, dst); err != nil { - slog.Warn("workspace_media: failed to copy file", "src", src, "dst", dst, "error", err) - continue - } + // Always copy (not hard link) to maintain isolation — members modifying + // workspace files must not affect the original media store. + if err := copyFile(src, dst); err != nil { + slog.Warn("workspace_media: failed to copy file", "src", src, "dst", dst, "error", err) + continue } slog.Info("workspace_media: copied to team workspace",