mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +00:00
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:
1 parent
c7d0bc19f8
commit
da59377387
2 files changed
+46
-14
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user