From 354b8eb87eaa18afd5670487ed5cfc2a3330db79 Mon Sep 17 00:00:00 2001 From: Unclebaffa Date: Tue, 20 Oct 2026 23:26:58 +0100 Subject: [PATCH] fix: sanitize forms, validate APIs, check stake balance, fix theme (#943, #940, #941, #942) - #943: add iterative fixed-point sanitization and email validation - #940: add Zod validation and rate limiting for artist/marketplace APIs - #941: enforce balance checks in staking and delegation forms - #942: replace filter:invert() with light-dark() and CSS custom properties Closes #943, Closes #940, Closes #941, Closes #942 --- api/artist/[id].tsx | 3 + api/marketplace/[id].tsx | 3 + api/portfolio/[id].tsx | 3 + components/CompoundStakeForm.tsx | 3 + components/DelegateStakeForm.tsx | 3 + components/Header.tsx | 3 + components/Layout.tsx | 3 + components/StakeForm.tsx | 3 + components/ThemeToggle.tsx | 3 + components/UnstakeForm.tsx | 3 + hooks/useStellarBalance.ts | 1 + src/api/artist/[id].tsx | 127 +++++++++ src/api/marketplace/[id].tsx | 69 +++++ src/api/portfolio/[id].tsx | 69 +++++ src/components/CompoundStakeForm.tsx | 151 ++++++++++ src/components/ContactForm.tsx | 20 +- src/components/DelegateStakeForm.tsx | 183 +++++++++++++ src/components/Header.tsx | 61 +++++ src/components/Layout.tsx | 53 ++++ src/components/NewsletterForm.tsx | 56 +++- src/components/QueryForm.tsx | 71 ++++- src/components/StakeForm.tsx | 151 ++++++++++ src/components/ThemeToggle.tsx | 45 +++ src/components/UnstakeForm.tsx | 154 +++++++++++ src/components/ui/ThemeToggle.tsx | 23 +- .../components/CommissionRequestModal.tsx | 44 ++- .../profile/pages/ProfileEditPage.tsx | 142 +++++++--- src/hooks/index.ts | 1 + src/hooks/useStellarBalance.ts | 235 ++++++++++++++++ src/index.css | 47 +++- src/lib/sanitize.ts | 27 +- src/lib/theme.ts | 19 +- src/test/DarkMode.test.ts | 177 ++++++++++++ src/test/Form.test.tsx | 149 +++++++++- src/test/artist-api.test.ts | 234 ++++++++++++++++ src/test/staking.test.tsx | 257 ++++++++++++++++++ src/validators/artist-id.validator.ts | 146 ++++++++++ test/DarkMode.test.ts | 1 + test/artist-api.test.ts | 1 + test/staking.test.tsx | 1 + validators/artist-id.validator.ts | 1 + 41 files changed, 2641 insertions(+), 105 deletions(-) create mode 100644 api/artist/[id].tsx create mode 100644 api/marketplace/[id].tsx create mode 100644 api/portfolio/[id].tsx create mode 100644 components/CompoundStakeForm.tsx create mode 100644 components/DelegateStakeForm.tsx create mode 100644 components/Header.tsx create mode 100644 components/Layout.tsx create mode 100644 components/StakeForm.tsx create mode 100644 components/ThemeToggle.tsx create mode 100644 components/UnstakeForm.tsx create mode 100644 hooks/useStellarBalance.ts create mode 100644 src/api/artist/[id].tsx create mode 100644 src/api/marketplace/[id].tsx create mode 100644 src/api/portfolio/[id].tsx create mode 100644 src/components/CompoundStakeForm.tsx create mode 100644 src/components/DelegateStakeForm.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/StakeForm.tsx create mode 100644 src/components/ThemeToggle.tsx create mode 100644 src/components/UnstakeForm.tsx create mode 100644 src/hooks/useStellarBalance.ts create mode 100644 src/test/DarkMode.test.ts create mode 100644 src/test/artist-api.test.ts create mode 100644 src/test/staking.test.tsx create mode 100644 src/validators/artist-id.validator.ts create mode 100644 test/DarkMode.test.ts create mode 100644 test/artist-api.test.ts create mode 100644 test/staking.test.tsx create mode 100644 validators/artist-id.validator.ts diff --git a/api/artist/[id].tsx b/api/artist/[id].tsx new file mode 100644 index 00000000..bc841f52 --- /dev/null +++ b/api/artist/[id].tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../../src/api/artist/[id]' +export { default } from '../../src/api/artist/[id]' diff --git a/api/marketplace/[id].tsx b/api/marketplace/[id].tsx new file mode 100644 index 00000000..531b18e3 --- /dev/null +++ b/api/marketplace/[id].tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../../src/api/marketplace/[id]' +export { default } from '../../src/api/marketplace/[id]' diff --git a/api/portfolio/[id].tsx b/api/portfolio/[id].tsx new file mode 100644 index 00000000..80c24f6f --- /dev/null +++ b/api/portfolio/[id].tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../../src/api/portfolio/[id]' +export { default } from '../../src/api/portfolio/[id]' diff --git a/components/CompoundStakeForm.tsx b/components/CompoundStakeForm.tsx new file mode 100644 index 00000000..edcd2c7c --- /dev/null +++ b/components/CompoundStakeForm.tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../src/components/CompoundStakeForm' +export { default } from '../src/components/CompoundStakeForm' diff --git a/components/DelegateStakeForm.tsx b/components/DelegateStakeForm.tsx new file mode 100644 index 00000000..b4321a31 --- /dev/null +++ b/components/DelegateStakeForm.tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../src/components/DelegateStakeForm' +export { default } from '../src/components/DelegateStakeForm' diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 00000000..4befea04 --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../src/components/Header' +export { default } from '../src/components/Header' diff --git a/components/Layout.tsx b/components/Layout.tsx new file mode 100644 index 00000000..e215142b --- /dev/null +++ b/components/Layout.tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../src/components/Layout' +export { default } from '../src/components/Layout' diff --git a/components/StakeForm.tsx b/components/StakeForm.tsx new file mode 100644 index 00000000..57c8c004 --- /dev/null +++ b/components/StakeForm.tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../src/components/StakeForm' +export { default } from '../src/components/StakeForm' diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx new file mode 100644 index 00000000..91baab19 --- /dev/null +++ b/components/ThemeToggle.tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../src/components/ThemeToggle' +export { default } from '../src/components/ThemeToggle' diff --git a/components/UnstakeForm.tsx b/components/UnstakeForm.tsx new file mode 100644 index 00000000..5974d055 --- /dev/null +++ b/components/UnstakeForm.tsx @@ -0,0 +1,3 @@ +/* eslint-disable react-refresh/only-export-components */ +export * from '../src/components/UnstakeForm' +export { default } from '../src/components/UnstakeForm' diff --git a/hooks/useStellarBalance.ts b/hooks/useStellarBalance.ts new file mode 100644 index 00000000..8b79a461 --- /dev/null +++ b/hooks/useStellarBalance.ts @@ -0,0 +1 @@ +export * from '../src/hooks/useStellarBalance' diff --git a/src/api/artist/[id].tsx b/src/api/artist/[id].tsx new file mode 100644 index 00000000..e29be049 --- /dev/null +++ b/src/api/artist/[id].tsx @@ -0,0 +1,127 @@ +/* eslint-disable react-refresh/only-export-components */ +import { validateArtistId } from '@/validators/artist-id.validator' + +/** Rate limit bucket storage for artist endpoint */ +const rateLimitMap = new Map() +export const ARTIST_ROUTE_LIMIT = { maxRequests: 30, windowMs: 60_000 } + +export function resetArtistApiRateLimits(): void { + rateLimitMap.clear() +} + +/** Check rate limiting for a client key/IP */ +export function checkArtistRouteRateLimit(key = 'client-default'): boolean { + const now = Date.now() + const entry = rateLimitMap.get(key) + if (!entry || now >= entry.resetAt) { + rateLimitMap.set(key, { count: 1, resetAt: now + ARTIST_ROUTE_LIMIT.windowMs }) + return true + } + if (entry.count >= ARTIST_ROUTE_LIMIT.maxRequests) { + return false + } + entry.count += 1 + return true +} + +/** + * Mock parameterized database fetch for artist. + * Prevents SQL injection by using parameters rather than string concatenation (#940). + */ +export async function getArtistById(artistId: string) { + // Parameterized query simulation: + // e.g. db.query('SELECT * FROM artists WHERE id = $1', [artistId]) + return { + id: artistId, + name: 'Artist Profile', + verified: true, + } +} + +export interface ApiRequest { + url?: string + headers?: Headers | Record + query?: Record + params?: Record +} + +export interface ApiResponse { + status: (code: number) => ApiResponse + json: (body: unknown) => void +} + +/** + * Next.js App Router style route handler: + * GET(request, { params }) + */ +export async function GET( + request: Request | ApiRequest, + context?: { params?: { id?: string } | Promise<{ id?: string }> }, +): Promise { + const resolvedParams = context?.params ? await context.params : undefined + const rawId = + resolvedParams?.id ?? (request as ApiRequest)?.query?.id ?? (request as ApiRequest)?.params?.id + + // 1. Rate limiting check (#940) + const clientKey = 'client-default' + if (!checkArtistRouteRateLimit(clientKey)) { + return new Response( + JSON.stringify({ + error: 'Too Many Requests', + message: 'Rate limit exceeded for artist API endpoint. Please try again later.', + }), + { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + // 2. Validate id parameter (#940) + const validation = validateArtistId(rawId) + if (!validation.success) { + return new Response( + JSON.stringify({ + error: 'Bad Request', + message: validation.error, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + // 3. Fetch artist with parameterized query + const artist = await getArtistById(validation.data) + return new Response(JSON.stringify(artist), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** + * Next.js Pages Router / Express style handler: + * handler(req, res) + */ +export default async function handler(req: ApiRequest, res: ApiResponse) { + const rawId = req.query?.id ?? req.params?.id + + if (!checkArtistRouteRateLimit('client-default')) { + return res.status(429).json({ + error: 'Too Many Requests', + message: 'Rate limit exceeded for artist API endpoint. Please try again later.', + }) + } + + const validation = validateArtistId(rawId) + if (!validation.success) { + return res.status(400).json({ + error: 'Bad Request', + message: validation.error, + }) + } + + const artist = await getArtistById(validation.data) + return res.status(200).json(artist) +} diff --git a/src/api/marketplace/[id].tsx b/src/api/marketplace/[id].tsx new file mode 100644 index 00000000..f99d07e3 --- /dev/null +++ b/src/api/marketplace/[id].tsx @@ -0,0 +1,69 @@ +/* eslint-disable react-refresh/only-export-components */ +import { validateMarketplaceId } from '@/validators/artist-id.validator' +import type { ApiRequest, ApiResponse } from '../artist/[id]' + +/** + * Mock parameterized database fetch for marketplace listing. + * Prevents SQL injection by using parameters rather than string concatenation (#940). + */ +export async function getMarketplaceItemById(listingId: string) { + // Parameterized query simulation: + // e.g. db.query('SELECT * FROM listings WHERE id = $1', [listingId]) + return { + id: listingId, + title: 'Marketplace Listing', + status: 'active', + } +} + +/** + * Next.js App Router style route handler: + * GET(request, { params }) + */ +export async function GET( + request: Request | ApiRequest, + context?: { params?: { id?: string } | Promise<{ id?: string }> }, +): Promise { + const resolvedParams = context?.params ? await context.params : undefined + const rawId = + resolvedParams?.id ?? (request as ApiRequest)?.query?.id ?? (request as ApiRequest)?.params?.id + + const validation = validateMarketplaceId(rawId) + if (!validation.success) { + return new Response( + JSON.stringify({ + error: 'Bad Request', + message: validation.error, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const listing = await getMarketplaceItemById(validation.data) + return new Response(JSON.stringify(listing), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** + * Next.js Pages Router / Express style handler: + * handler(req, res) + */ +export default async function handler(req: ApiRequest, res: ApiResponse) { + const rawId = req.query?.id ?? req.params?.id + + const validation = validateMarketplaceId(rawId) + if (!validation.success) { + return res.status(400).json({ + error: 'Bad Request', + message: validation.error, + }) + } + + const listing = await getMarketplaceItemById(validation.data) + return res.status(200).json(listing) +} diff --git a/src/api/portfolio/[id].tsx b/src/api/portfolio/[id].tsx new file mode 100644 index 00000000..b8cd5b7e --- /dev/null +++ b/src/api/portfolio/[id].tsx @@ -0,0 +1,69 @@ +/* eslint-disable react-refresh/only-export-components */ +import { validatePortfolioId } from '@/validators/artist-id.validator' +import type { ApiRequest, ApiResponse } from '../artist/[id]' + +/** + * Mock parameterized database fetch for portfolio artwork. + * Prevents SQL injection by using parameters rather than string concatenation (#940). + */ +export async function getPortfolioItemById(artworkId: string) { + // Parameterized query simulation: + // e.g. db.query('SELECT * FROM artworks WHERE id = $1', [artworkId]) + return { + id: artworkId, + title: 'Artwork Item', + published: true, + } +} + +/** + * Next.js App Router style route handler: + * GET(request, { params }) + */ +export async function GET( + request: Request | ApiRequest, + context?: { params?: { id?: string } | Promise<{ id?: string }> }, +): Promise { + const resolvedParams = context?.params ? await context.params : undefined + const rawId = + resolvedParams?.id ?? (request as ApiRequest)?.query?.id ?? (request as ApiRequest)?.params?.id + + const validation = validatePortfolioId(rawId) + if (!validation.success) { + return new Response( + JSON.stringify({ + error: 'Bad Request', + message: validation.error, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const artwork = await getPortfolioItemById(validation.data) + return new Response(JSON.stringify(artwork), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** + * Next.js Pages Router / Express style handler: + * handler(req, res) + */ +export default async function handler(req: ApiRequest, res: ApiResponse) { + const rawId = req.query?.id ?? req.params?.id + + const validation = validatePortfolioId(rawId) + if (!validation.success) { + return res.status(400).json({ + error: 'Bad Request', + message: validation.error, + }) + } + + const artwork = await getPortfolioItemById(validation.data) + return res.status(200).json(artwork) +} diff --git a/src/components/CompoundStakeForm.tsx b/src/components/CompoundStakeForm.tsx new file mode 100644 index 00000000..4d50f4ea --- /dev/null +++ b/src/components/CompoundStakeForm.tsx @@ -0,0 +1,151 @@ +import { useState, type FormEvent } from 'react' +import { Button, Input } from '@/components/ui' +import { useStellarBalance, validateStakeBalance, requireBalance } from '@/hooks/useStellarBalance' + +export interface CompoundStakeFormProps { + /** Unclaimed or compoundable rewards balance. */ + claimableRewards?: string | number + /** Asset identifier (e.g., 'XLM', 'AID'). Defaults to 'XLM'. */ + asset?: string + /** Display unit for balance and error messages (defaults to 'tokens'). */ + unit?: string + /** Async callback invoked when compound transaction is submitted and validated. */ + onCompound?: (amount: string) => Promise + /** Callback fired upon successful compound. */ + onSuccess?: () => void + /** Callback fired upon failure or validation error. */ + onError?: (error: string) => void + /** Additional CSS class names */ + className?: string +} + +/** + * Compound staking rewards form with balance validation (#941). + * Validates that compound amount does not exceed claimable rewards balance. + */ +export default function CompoundStakeForm({ + claimableRewards: propRewards, + asset = 'XLM', + unit = 'tokens', + onCompound, + onSuccess, + onError, + className = '', +}: CompoundStakeFormProps) { + const { rewardsBalance: hookRewards } = useStellarBalance({ + asset, + initialRewardsBalance: propRewards, + }) + + const effectiveRewards = propRewards !== undefined ? String(propRewards) : hookRewards + const [amount, setAmount] = useState('') + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + + const handleMaxClick = () => { + setAmount(effectiveRewards) + setError(null) + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + setIsSuccess(false) + + // 1. Validate balance before transaction (#941) + const validation = validateStakeBalance(amount, effectiveRewards, { + action: 'compound', + unit, + }) + + if (!validation.isValid) { + const errorMsg = validation.error ?? 'Insufficient balance.' + setError(errorMsg) + onError?.(errorMsg) + return + } + + // 2. Double-check guard + try { + requireBalance(effectiveRewards, amount, { action: 'compound', unit }) + } catch (guardErr) { + const guardMsg = guardErr instanceof Error ? guardErr.message : 'Insufficient balance' + setError(guardMsg) + onError?.(guardMsg) + return + } + + // 3. Initiate compound transaction + setError(null) + setIsSubmitting(true) + + try { + if (onCompound) { + await onCompound(amount) + } + setIsSuccess(true) + onSuccess?.() + } catch (err) { + const failureMsg = err instanceof Error ? err.message : 'Compounding failed' + setError(failureMsg) + onError?.(failureMsg) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ Claimable rewards: +
+ + {effectiveRewards} {unit} + + +
+
+ + { + setAmount(e.target.value) + if (error) setError(null) + }} + data-testid="compound-amount-input" + /> + + {isSuccess ? ( +

+ Successfully compounded {amount} {unit}! +

+ ) : null} + + +
+ ) +} diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx index 51acfb8f..e2a853f4 100644 --- a/src/components/ContactForm.tsx +++ b/src/components/ContactForm.tsx @@ -1,7 +1,7 @@ import { useState, type FormEvent } from 'react' import { Button, Input } from '@/components/ui' import { validateEmail } from '@/lib/email' -import { sanitizeMessage } from '@/lib/sanitize' +import { sanitizeMessage, sanitizeText } from '@/lib/sanitize' import { checkRateLimit } from '@/utils/rate-limit' import { useFormSubmission } from '@/hooks/useFormSubmission' @@ -18,7 +18,7 @@ export interface ContactFormProps { /** * Contact form with email validation, message sanitization, honeypot, - * client rate limiting, and double-submit protection (#933, #937). + * client rate limiting, and double-submit protection (#933, #937, #943). */ export default function ContactForm({ onSubmit, rateLimitKey = 'contact-form' }: ContactFormProps) { const [email, setEmail] = useState('') @@ -44,11 +44,12 @@ export default function ContactForm({ onSubmit, rateLimitKey = 'contact-form' }: return } - const cleaned = sanitizeMessage(message) + const cleanedMessage = sanitizeMessage(message) + const cleanedName = name.trim() ? sanitizeText(name) || undefined : undefined await onSubmit({ email: email.trim(), - name: name.trim() || undefined, - message: cleaned, + name: cleanedName, + message: cleanedMessage, }) setDone(true) }) @@ -119,9 +120,10 @@ export default function ContactForm({ onSubmit, rateLimitKey = 'contact-form' }: overflow: 'hidden', }} > -