Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
156 lines
3.7 KiB
Go
156 lines
3.7 KiB
Go
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
|
|
}
|