Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user