feat(marketplace): add rich text description with sanitization
This commit is contained in:
parent
d57c45c32e
commit
f25956e9e2
4 changed files with 117 additions and 5 deletions
|
|
@ -1,7 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* ProductDetailView — description card.
|
* ProductDetailView — description card.
|
||||||
|
* v0.401 M1.11: Rich text description with sanitization
|
||||||
*/
|
*/
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { sanitizeProductDescription } from '@/utils/sanitize';
|
||||||
import type { Product } from '@/types';
|
import type { Product } from '@/types';
|
||||||
|
|
||||||
interface ProductDetailViewDescriptionProps {
|
interface ProductDetailViewDescriptionProps {
|
||||||
|
|
@ -9,13 +11,19 @@ interface ProductDetailViewDescriptionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProductDetailViewDescription({ product }: ProductDetailViewDescriptionProps) {
|
export function ProductDetailViewDescription({ product }: ProductDetailViewDescriptionProps) {
|
||||||
|
const safeDescription = sanitizeProductDescription(product.description ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="default">
|
<Card variant="default">
|
||||||
<h3 className="font-bold text-foreground text-xl mb-4 border-b border-border pb-2 tracking-tight">
|
<h3 className="font-bold text-foreground text-xl mb-4 border-b border-border pb-2 tracking-tight">
|
||||||
Description
|
Description
|
||||||
</h3>
|
</h3>
|
||||||
<div className="prose prose-invert max-w-none text-foreground">
|
<div className="prose prose-invert max-w-none text-foreground">
|
||||||
<p>{product.description}</p>
|
{safeDescription ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: safeDescription }} />
|
||||||
|
) : (
|
||||||
|
<p>{product.description || 'No description.'}</p>
|
||||||
|
)}
|
||||||
{product.features?.length ? (
|
{product.features?.length ? (
|
||||||
<ul>
|
<ul>
|
||||||
{product.features.map((f: string, i: number) => (
|
{product.features.map((f: string, i: number) => (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
import { useRef } from 'react';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Tag } from 'lucide-react';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tag, Bold, List } from 'lucide-react';
|
||||||
|
|
||||||
interface CreateProductViewDetailsCardProps {
|
interface CreateProductViewDetailsCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -31,6 +33,26 @@ export function CreateProductViewDetailsCard({
|
||||||
keyVal,
|
keyVal,
|
||||||
setKey,
|
setKey,
|
||||||
}: CreateProductViewDetailsCardProps) {
|
}: CreateProductViewDetailsCardProps) {
|
||||||
|
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const insertAtCursor = (before: string, after: string) => {
|
||||||
|
const ta = descriptionRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const end = ta.selectionEnd;
|
||||||
|
const text = description;
|
||||||
|
const selected = text.slice(start, end) || 'text';
|
||||||
|
const newText = text.slice(0, start) + before + selected + after + text.slice(end);
|
||||||
|
setDescription(newText);
|
||||||
|
setTimeout(() => {
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBold = () => insertAtCursor('<b>', '</b>');
|
||||||
|
const handleList = () => insertAtCursor('\n<ul>\n<li>', '</li>\n</ul>\n');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="default">
|
<Card variant="default">
|
||||||
<h3 className="font-bold text-foreground mb-6 border-b border-border pb-2 tracking-tight">
|
<h3 className="font-bold text-foreground mb-6 border-b border-border pb-2 tracking-tight">
|
||||||
|
|
@ -47,9 +69,19 @@ export function CreateProductViewDetailsCard({
|
||||||
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
<label className="block text-sm font-medium text-muted-foreground mb-2">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleBold} className="h-8 px-3" aria-label="Bold">
|
||||||
|
<Bold className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleList} className="h-8 px-3" aria-label="Insert list">
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground self-center">Basic HTML: <b>, <ul><li></span>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={descriptionRef}
|
||||||
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none focus-visible:ring-2 focus-visible:ring-ring min-h-24 transition-colors duration-[var(--sumi-duration-normal)]"
|
className="w-full bg-muted border border-border rounded-xl p-4 text-foreground focus:border-primary outline-none focus-visible:ring-2 focus-visible:ring-ring min-h-24 transition-colors duration-[var(--sumi-duration-normal)]"
|
||||||
placeholder="Describe your sound pack..."
|
placeholder="Describe your sound pack... Use Bold and List for formatting."
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,34 @@ export function sanitizeChatMessage(message: string): string {
|
||||||
return sanitizeHTML(message, chatOptions);
|
return sanitizeHTML(message, chatOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitise la description produit (rich text) pour affichage sécurisé
|
||||||
|
* v0.401 M1.11: Bold, listes (ul, ol, li), paragraphes
|
||||||
|
*/
|
||||||
|
export function sanitizeProductDescription(html: string): string {
|
||||||
|
if (!html?.trim()) return '';
|
||||||
|
if (typeof window !== 'undefined' && DOMPurify.isSupported) {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'i', 'b', 'ul', 'ol', 'li', 'span', 'a'],
|
||||||
|
ALLOWED_ATTR: ['class', 'href', 'title', 'target'],
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
|
KEEP_CONTENT: true,
|
||||||
|
RETURN_DOM: false,
|
||||||
|
RETURN_DOM_FRAGMENT: false,
|
||||||
|
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button'],
|
||||||
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const opts: SanitizeOptions = {
|
||||||
|
allowedTags: ['p', 'br', 'strong', 'em', 'u', 'i', 'b', 'ul', 'ol', 'li', 'span', 'a'],
|
||||||
|
allowedAttributes: { a: ['href', 'title', 'target'], span: ['class'] },
|
||||||
|
allowedSchemes: ['http', 'https', 'mailto'],
|
||||||
|
stripUnknownTags: true,
|
||||||
|
stripEmptyTags: true,
|
||||||
|
};
|
||||||
|
return sanitizeHTML(html, opts);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitise les noms d'utilisateur et autres champs texte
|
* Sanitise les noms d'utilisateur et autres champs texte
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"veza-backend-api/internal/core/marketplace"
|
"veza-backend-api/internal/core/marketplace"
|
||||||
"veza-backend-api/internal/response"
|
"veza-backend-api/internal/response"
|
||||||
|
"veza-backend-api/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
@ -78,7 +79,7 @@ func (h *MarketplaceHandler) CreateProduct(c *gin.Context) {
|
||||||
product := &marketplace.Product{
|
product := &marketplace.Product{
|
||||||
SellerID: userID,
|
SellerID: userID,
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Description: req.Description,
|
Description: utils.SanitizeHTML(req.Description, 5000),
|
||||||
Price: req.Price,
|
Price: req.Price,
|
||||||
ProductType: req.ProductType,
|
ProductType: req.ProductType,
|
||||||
LicenseType: marketplace.LicenseType(req.LicenseType),
|
LicenseType: marketplace.LicenseType(req.LicenseType),
|
||||||
|
|
@ -268,6 +269,49 @@ func (h *MarketplaceHandler) UploadProductPreview(c *gin.Context) {
|
||||||
response.Created(c, preview)
|
response.Created(c, preview)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProduct returns a single product by ID (v0.401 M1 - includes previews and images)
|
||||||
|
// GET /marketplace/products/:id
|
||||||
|
func (h *MarketplaceHandler) GetProduct(c *gin.Context) {
|
||||||
|
productID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid product id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
product, err := h.service.GetProduct(c.Request.Context(), productID)
|
||||||
|
if err != nil {
|
||||||
|
if err == marketplace.ErrProductNotFound {
|
||||||
|
response.NotFound(c, "Product not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.InternalServerError(c, "Failed to get product")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, product)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamProductPreview streams the first audio preview for a product (v0.401 M1)
|
||||||
|
// GET /marketplace/products/:id/preview
|
||||||
|
func (h *MarketplaceHandler) StreamProductPreview(c *gin.Context) {
|
||||||
|
productID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, "Invalid product id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
product, err := h.service.GetProduct(c.Request.Context(), productID)
|
||||||
|
if err != nil || len(product.Previews) == 0 {
|
||||||
|
response.NotFound(c, "Preview not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preview := product.Previews[0]
|
||||||
|
fullPath := filepath.Join(h.uploadDir, preview.FilePath)
|
||||||
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||||
|
response.NotFound(c, "Preview file not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", "inline")
|
||||||
|
c.File(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateProductImagesRequest body for PUT /products/:id/images
|
// UpdateProductImagesRequest body for PUT /products/:id/images
|
||||||
type UpdateProductImagesRequest struct {
|
type UpdateProductImagesRequest struct {
|
||||||
Images []struct {
|
Images []struct {
|
||||||
|
|
@ -466,7 +510,7 @@ func (h *MarketplaceHandler) UpdateProduct(c *gin.Context) {
|
||||||
updates["title"] = *req.Title
|
updates["title"] = *req.Title
|
||||||
}
|
}
|
||||||
if req.Description != nil {
|
if req.Description != nil {
|
||||||
updates["description"] = *req.Description
|
updates["description"] = utils.SanitizeHTML(*req.Description, 5000)
|
||||||
}
|
}
|
||||||
if req.Price != nil {
|
if req.Price != nil {
|
||||||
updates["price"] = *req.Price
|
updates["price"] = *req.Price
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue