fix(teams): use OR logic for task search instead of AND

plainto_tsquery ANDs all terms, so queries like "teenager coffee
instagram" return 0 results when any single term is missing from the
tsvector. Switch to to_tsquery with OR-joined terms so tasks matching
any keyword are returned, ranked by relevance. Sanitize input with
a whitelist (letters, digits, hyphens, underscores) to prevent
tsquery syntax injection.
This commit is contained in:
viettranx committed 2026-03-18 13:02:07 +07:00
1 parent c7d0bc19f8
commit da59377387
2 files changed
+46 -14

No files matched your search

+27 -3
View File
@@ -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
}
+19 -11
View File
@@ -13,6 +13,10 @@ import (
// mediaDocNameRe extracts name and path attributes from <media:document> tags.
var mediaDocNameRe = regexp.MustCompile(`<media:document\b[^>]*\bname="([^"]+)"[^>]*\bpath="([^"]+)"`)
// mediaDocPathNameRe matches path-before-name ordering and Slack file= variant.
var mediaDocPathNameRe = regexp.MustCompile(`<media:document\b[^>]*\bpath="([^"]+)"[^>]*\b(?:name|file)="([^"]+)"`)
// ExtractMediaNameMap parses message content for <media:document name="X" path="Y"> 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: <media:document path="Y" name="X">
altRe := regexp.MustCompile(`<media:document\b[^>]*\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",