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 }