package feeds import ( "context" "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/google/uuid" ) var ( errSourceRequired = errors.New("feed url or uploaded CSV source required") errLocalSource = errors.New("local feed source unavailable") ) // feedBlob is feed content on disk. Close removes owned temp files (HTTP downloads). // Local uploads reference the existing path and Close is a no-op. type feedBlob struct { path string contentType string size int64 owned bool } // Close removes the temp file when this blob owns it. func (b *feedBlob) Close() error { if b == nil || !b.owned || b.path == "" { return nil } err := os.Remove(b.path) b.path = "" b.owned = false return err } // Open returns a new read handle at the start of the blob. func (b *feedBlob) Open() (*os.File, error) { if b == nil || b.path == "" { return nil, errors.New("feed blob closed or empty") } return os.Open(b.path) } // Sniff reads up to n bytes from the start of the blob (for format detection). func (b *feedBlob) Sniff(n int) ([]byte, error) { if n <= 0 { return nil, nil } f, err := b.Open() if err != nil { return nil, err } defer f.Close() buf := make([]byte, n) nr, err := io.ReadFull(f, buf) if err == io.EOF || err == io.ErrUnexpectedEOF { err = nil } if err != nil { return nil, err } return buf[:nr], nil } // loadFeedSource returns on-disk feed content from a local upload or HTTP(S) URL. // Callers must Close the blob when finished. func (s *Service) loadFeedSource(ctx context.Context, companyID uuid.UUID, feed map[string]any) (*feedBlob, error) { if path := sourcePathFromOptions(feed["options"]); path != "" { return s.readLocalFeed(companyID, path) } urlStr, _ := feed["url"].(string) urlStr = strings.TrimSpace(urlStr) if urlStr == "" { return nil, errSourceRequired } return downloadFeed(ctx, urlStr) } func sourcePathFromOptions(raw any) string { opts, ok := raw.(map[string]any) if !ok || opts == nil { return "" } for _, key := range []string{"source_path", "local_path", "file_path"} { if v, ok := opts[key].(string); ok { if p := strings.TrimSpace(v); p != "" { return p } } } return "" } func (s *Service) readLocalFeed(companyID uuid.UUID, rel string) (*feedBlob, error) { uploadDir := strings.TrimSpace(s.UploadDir) if uploadDir == "" { return nil, ClientMsg("upload directory not configured") } abs, err := resolveCompanyPath(uploadDir, companyID, rel) if err != nil { return nil, err } info, err := os.Stat(abs) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("%w: file missing", errLocalSource) } return nil, err } if !info.Mode().IsRegular() { return nil, ClientMsg("invalid source path") } if info.Size() > maxDownloadBytes { return nil, downloadTooLarge() } ct := "text/csv" lower := strings.ToLower(abs) if strings.HasSuffix(lower, ".xml") { ct = "application/xml" } return &feedBlob{ path: abs, contentType: ct, size: info.Size(), owned: false, }, nil } func resolveCompanyPath(uploadDir string, companyID uuid.UUID, rel string) (string, error) { rel = filepath.ToSlash(strings.TrimSpace(rel)) if rel == "" || strings.Contains(rel, "..") { return "", ClientMsg("invalid source path") } prefix := companyID.String() + "/" if !strings.HasPrefix(rel, prefix) { return "", ClientMsg("forbidden source path") } base, err := filepath.Abs(uploadDir) if err != nil { return "", err } abs, err := filepath.Abs(filepath.Join(uploadDir, filepath.FromSlash(rel))) if err != nil { return "", err } sep := string(os.PathSeparator) if abs != base && !strings.HasPrefix(abs, base+sep) { return "", ClientMsg("forbidden source path") } return abs, nil }