feat(marketplace): add rich text description with sanitization

This commit is contained in:
senke 2026-02-22 14:14:27 +01:00
parent d57c45c32e
commit f25956e9e2
4 changed files with 117 additions and 5 deletions

View file

@ -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) => (

View file

@ -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: &lt;b&gt;, &lt;ul&gt;&lt;li&gt;</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)}
/> />

View file

@ -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
*/ */

View file

@ -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