Files
descrybe/apps/api/internal/catalog/files.go
T
greeneclipse 8580c996c3 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.
2026-08-09 22:47:43 +02:00

274 lines
7.3 KiB
Go

package catalog
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
const maxUploadBytes = 5 << 20 // 5 MiB
func sanitizeFileName(name string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "" || name == "." || name == ".." {
return "upload.csv"
}
var b strings.Builder
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
out := b.String()
if out == "" {
return "upload.csv"
}
return out
}
func (s *Service) SaveUpload(ctx context.Context, companyID, userID uuid.UUID, uploadDir, originalName, contentType, kind string, r io.Reader) (map[string]any, error) {
uploadDir = strings.TrimSpace(uploadDir)
if uploadDir == "" {
return nil, ClientMsg("upload directory not configured")
}
safe := sanitizeFileName(originalName)
lower := strings.ToLower(safe)
if !strings.HasSuffix(lower, ".csv") {
return nil, ClientMsg("only .csv uploads are allowed")
}
if contentType != "" &&
!strings.Contains(strings.ToLower(contentType), "csv") &&
!strings.Contains(strings.ToLower(contentType), "text/plain") &&
!strings.Contains(strings.ToLower(contentType), "octet-stream") {
return nil, ClientMsg("invalid content type for CSV upload")
}
kind = strings.ToLower(strings.TrimSpace(kind))
if kind == "" {
kind = "products"
}
metaBytes, _ := json.Marshal(map[string]any{"kind": kind})
fileID := uuid.New()
dir := filepath.Join(uploadDir, companyID.String())
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
rel := filepath.ToSlash(filepath.Join(companyID.String(), fileID.String()+"-"+safe))
abs := filepath.Join(uploadDir, filepath.FromSlash(rel))
f, err := os.OpenFile(abs, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640)
if err != nil {
return nil, err
}
defer f.Close()
n, err := io.Copy(f, io.LimitReader(r, maxUploadBytes+1))
if err != nil {
_ = os.Remove(abs)
return nil, err
}
if n > maxUploadBytes {
_ = os.Remove(abs)
return nil, ClientMsg(fmt.Sprintf("file exceeds %d byte limit", maxUploadBytes))
}
var uid any
if userID != uuid.Nil {
uid = userID
}
var id uuid.UUID
err = s.Pool.QueryRow(ctx, `
INSERT INTO files (id, company_id, user_id, name, path, content_type, size_bytes, status, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploaded', $8::jsonb)
RETURNING id`, fileID, companyID, uid, safe, rel, contentType, n, string(metaBytes)).Scan(&id)
if err != nil {
_ = os.Remove(abs)
return nil, err
}
return map[string]any{
"id": id.String(),
"name": safe,
"path": rel,
"content_type": contentType,
"size_bytes": n,
"status": "uploaded",
"kind": kind,
"metadata": map[string]any{"kind": kind},
}, nil
}
func (s *Service) ResolveUploadPath(uploadDir string, companyID uuid.UUID, rel string) (string, error) {
uploadDir = strings.TrimSpace(uploadDir)
if uploadDir == "" {
return "", ClientMsg("upload directory not configured")
}
rel = filepath.ToSlash(strings.TrimSpace(rel))
if rel == "" || strings.Contains(rel, "..") {
return "", ClientMsg("invalid path")
}
prefix := companyID.String() + "/"
if !strings.HasPrefix(rel, prefix) {
return "", ClientMsg("forbidden")
}
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")
}
return abs, nil
}
func scanFileRow(rows pgx.Row) (map[string]any, error) {
var (
id uuid.UUID
companyID uuid.UUID
userID *uuid.UUID
name string
path *string
contentType *string
sizeBytes int64
status string
metadata []byte
createdAt time.Time
updatedAt time.Time
)
if err := rows.Scan(&id, &companyID, &userID, &name, &path, &contentType, &sizeBytes, &status, &metadata, &createdAt, &updatedAt); err != nil {
return nil, err
}
var meta any = map[string]any{}
if len(metadata) > 0 {
_ = json.Unmarshal(metadata, &meta)
}
out := map[string]any{
"id": id.String(),
"company_id": companyID.String(),
"name": name,
"size_bytes": sizeBytes,
"status": status,
"metadata": meta,
"created_at": createdAt.UTC().Format(time.RFC3339),
"updated_at": updatedAt.UTC().Format(time.RFC3339),
}
if userID != nil {
out["user_id"] = userID.String()
}
if path != nil {
out["path"] = *path
}
if contentType != nil {
out["content_type"] = *contentType
}
if m, ok := meta.(map[string]any); ok {
if k, ok := m["kind"].(string); ok {
out["kind"] = k
}
}
return out, nil
}
func (s *Service) ListFiles(ctx context.Context, companyID uuid.UUID, f ListFilter) ([]map[string]any, int, error) {
f = NormalizeListFilter(f)
var total int
if err := s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM files WHERE company_id = $1`, companyID).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.Pool.Query(ctx, `
SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
FROM files
WHERE company_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`, companyID, f.Limit, f.Offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]map[string]any, 0)
for rows.Next() {
item, err := scanFileRow(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
}
func (s *Service) GetFile(ctx context.Context, companyID, fileID uuid.UUID) (map[string]any, error) {
row := s.Pool.QueryRow(ctx, `
SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
FROM files WHERE company_id = $1 AND id = $2`, companyID, fileID)
item, err := scanFileRow(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
return item, err
}
func (s *Service) UpdateFileStatus(ctx context.Context, companyID, fileID uuid.UUID, status string, metadata map[string]any) (map[string]any, error) {
status = strings.ToLower(strings.TrimSpace(status))
switch status {
case "uploaded", "processing", "completed", "failed":
default:
return nil, ClientMsg("invalid file status")
}
metaBytes := []byte("{}")
if metadata != nil {
b, err := json.Marshal(metadata)
if err != nil {
return nil, err
}
metaBytes = b
}
_, err := s.Pool.Exec(ctx, `
UPDATE files
SET status = $3,
metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb,
updated_at = now()
WHERE company_id = $1 AND id = $2`, companyID, fileID, status, string(metaBytes))
if err != nil {
return nil, err
}
return s.GetFile(ctx, companyID, fileID)
}
func (s *Service) DeleteFile(ctx context.Context, companyID, fileID uuid.UUID, uploadDir string) error {
item, err := s.GetFile(ctx, companyID, fileID)
if err != nil {
return err
}
tag, err := s.Pool.Exec(ctx, `DELETE FROM files WHERE company_id = $1 AND id = $2`, companyID, fileID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
if pathStr, ok := item["path"].(string); ok && pathStr != "" {
if abs, err := s.ResolveUploadPath(uploadDir, companyID, pathStr); err == nil {
_ = os.Remove(abs)
}
}
return nil
}