274 lines
7.3 KiB
Go
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
|
||
|
|
}
|