Files
goclaw/internal/tools/workspace_media.go
T
viettranx da59377387 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.
2026-03-18 13:02:07 +07:00

138 lines
4.2 KiB
Go

package tools
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
)
// 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 {
nameMap := make(map[string]string)
for _, m := range mediaDocNameRe.FindAllStringSubmatch(content, -1) {
if len(m) == 3 {
nameMap[m[2]] = m[1] // path → name
}
}
// 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
}
}
}
return nameMap
}
// copyMediaToWorkspace copies media files to the team workspace attachments dir.
// Uses original filenames from nameMap when available; falls back to UUID filename.
// Appends -1, -2 suffix on collision. Skips files that already exist with same size.
// Returns absolute paths of copied files.
func copyMediaToWorkspace(mediaPaths []string, wsDir string, nameMap map[string]string) []string {
if len(mediaPaths) == 0 || wsDir == "" {
return nil
}
attachDir := filepath.Join(wsDir, "attachments")
if err := os.MkdirAll(attachDir, 0750); err != nil {
slog.Warn("workspace_media: failed to create attachments dir", "dir", attachDir, "error", err)
return nil
}
var copied []string
for _, src := range mediaPaths {
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 != "" {
// 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)
continue
}
dst := filepath.Join(attachDir, baseName)
// Check if already copied (same name + same size = skip).
if dstInfo, err := os.Stat(dst); err == nil {
if dstInfo.Size() == srcInfo.Size() {
copied = append(copied, dst)
continue
}
// Name collision with different file — add suffix.
dst = deduplicatePath(attachDir, baseName)
}
// 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",
"original", baseName, "src", filepath.Base(src), "dst", dst)
copied = append(copied, dst)
}
return copied
}
// deduplicatePath appends -1, -2, etc. to the filename until it's unique.
func deduplicatePath(dir, name string) string {
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 1; i < 100; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext))
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
return filepath.Join(dir, name) // fallback
}
// copyFile copies src to dst using io.Copy.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}