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,273 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user