Files
descrybe/apps/api/internal/feeds/source.go
T

156 lines
3.7 KiB
Go
Raw Normal View History

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
}