diff --git a/CHANGELOG.md b/CHANGELOG.md index bc2580b3..ff536d3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -579,3 +579,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Redis caching for quiz questions and review data - Environment configuration cleanup and standardization - Improved build stability and dependency management + +## [1.0.1] - 2026-02-15 + +### Added + +- Shop / Payments reliability improvements: + - Monobank janitor map documentation as a source of truth + - Internal Monobank janitor endpoint scaffold with strict auth and rate-limit guards + - Post-redirect payment status UX with secure `/status` polling (no-store) +- Homepage engagement: + - Online users counter popup integrated into Hero section + - Single fetch per visit to reduce Neon usage + +### Changed + +- Header responsiveness: + - Desktop breakpoint adjusted from 1024px to 1050px + - Reduced glow/shimmer intensity in light theme +- Navigation and layout polish: + - Improved loader positioning to avoid overlap with navigation + - Optimized counter positioning logic + +### Fixed + +- Q&A data integrity: + - Fixed duplicated questions in API responses + - Added Redis cache versioning (`qa:v2:*`) + - Implemented automatic deduplication and cache rewrite on inconsistent data + - Improved pagination total count accuracy +- Cache stability: + - Added TTL for Q&A cache + - Automatic cache invalidation after content seeding +- Header layout issues after counter integration + +### Security + +- Hardened Monobank flows: + - Stronger origin checks and structured logging without PII + - Ownership protection for `/orders/[id]/status` (IDOR prevention) + - Pre-production test gate improvements + +### Infrastructure + +- Improved Redis cache reliability for Q&A +- Extended automated tests for caching and payment flows diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 6aa22c43..e1d18256 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -4,7 +4,7 @@ import { Loader2, Minus, Plus, ShoppingBag, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useCart } from '@/components/shop/CartProvider'; import { Link, useRouter } from '@/i18n/routing'; @@ -54,7 +54,28 @@ const SHOP_HERO_CTA = cn( 'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]' ); -export default function CartPage() { +type Props = { + stripeEnabled: boolean; + monobankEnabled: boolean; +}; + +type CheckoutProvider = 'stripe' | 'monobank'; + +function resolveInitialProvider(args: { + stripeEnabled: boolean; + monobankEnabled: boolean; + currency: string | null | undefined; +}): CheckoutProvider { + const isUah = args.currency === 'UAH'; + const canUseStripe = args.stripeEnabled; + const canUseMonobank = args.monobankEnabled && isUah; + + if (canUseStripe) return 'stripe'; + if (canUseMonobank) return 'monobank'; + return 'stripe'; +} + +export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const { cart, updateQuantity, removeFromCart } = useCart(); const router = useRouter(); const t = useTranslations('shop.cart'); @@ -62,10 +83,32 @@ export default function CartPage() { const [isCheckingOut, setIsCheckingOut] = useState(false); const [checkoutError, setCheckoutError] = useState(null); const [createdOrderId, setCreatedOrderId] = useState(null); + const [selectedProvider, setSelectedProvider] = useState( + () => + resolveInitialProvider({ + stripeEnabled, + monobankEnabled, + currency: cart?.summary?.currency, + }) + ); const params = useParams<{ locale?: string }>(); const locale = params.locale ?? 'en'; const shopBase = '/shop'; + const isUahCheckout = cart.summary.currency === 'UAH'; + const canUseStripe = stripeEnabled; + const canUseMonobank = monobankEnabled && isUahCheckout; + const hasSelectableProvider = canUseStripe || canUseMonobank; + + useEffect(() => { + if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) { + setSelectedProvider('monobank'); + return; + } + if (selectedProvider === 'monobank' && !canUseMonobank && canUseStripe) { + setSelectedProvider('stripe'); + } + }, [canUseMonobank, canUseStripe, selectedProvider]); const translateColor = (color: string | null | undefined): string | null => { if (!color) return null; @@ -78,6 +121,23 @@ export default function CartPage() { }; async function handleCheckout() { + if (!hasSelectableProvider) { + setCheckoutError(t('checkout.paymentMethod.noAvailable')); + return; + } + if (selectedProvider === 'stripe' && !canUseStripe) { + setCheckoutError(t('checkout.paymentMethod.noAvailable')); + return; + } + if (selectedProvider === 'monobank' && !canUseMonobank) { + setCheckoutError( + monobankEnabled + ? t('checkout.paymentMethod.monobankUahOnlyHint') + : t('checkout.paymentMethod.monobankUnavailable') + ); + return; + } + setCheckoutError(null); setCreatedOrderId(null); setIsCheckingOut(true); @@ -92,6 +152,7 @@ export default function CartPage() { 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ + paymentProvider: selectedProvider, items: cart.items.map(item => ({ productId: item.productId, quantity: item.quantity, @@ -109,13 +170,13 @@ export default function CartPage() { ? data.message : typeof data?.error === 'string' ? data.error - : 'Unable to start checkout right now.'; + : t('checkout.errors.startFailed'); setCheckoutError(message); return; } if (!data?.orderId) { - setCheckoutError('Unexpected checkout response.'); + setCheckoutError(t('checkout.errors.unexpectedResponse')); return; } @@ -125,6 +186,10 @@ export default function CartPage() { data.clientSecret.trim().length > 0 ? data.clientSecret : null; + const monobankPageUrl: string | null = + typeof data.pageUrl === 'string' && data.pageUrl.trim().length > 0 + ? data.pageUrl + : null; const orderId = String(data.orderId); setCreatedOrderId(orderId); @@ -137,6 +202,14 @@ export default function CartPage() { ); return; } + if (paymentProvider === 'monobank' && monobankPageUrl) { + window.location.assign(monobankPageUrl); + return; + } + if (paymentProvider === 'monobank' && !monobankPageUrl) { + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; + } const paymentsDisabledFlag = paymentProvider !== 'stripe' || !clientSecret @@ -149,7 +222,7 @@ export default function CartPage() { )}&clearCart=1${paymentsDisabledFlag}` ); } catch { - setCheckoutError('Unable to start checkout right now.'); + setCheckoutError(t('checkout.errors.startFailed')); } finally { setIsCheckingOut(false); } @@ -385,11 +458,69 @@ export default function CartPage() { +
+ + {t('checkout.paymentMethod.label')} + + +
+ {canUseStripe ? ( + + ) : null} + + + + {!canUseMonobank ? ( +

+ {monobankEnabled + ? t('checkout.paymentMethod.monobankUahOnlyHint') + : t('checkout.paymentMethod.monobankUnavailable')} +

+ ) : null} + + {!hasSelectableProvider ? ( +

+ {t('checkout.paymentMethod.noAvailable')} +

+ ) : null} +
+
+
+ + + {t('success.continueShopping')} + + + + {t('success.viewCart')} + + + + ); +} diff --git a/frontend/app/[locale]/shop/checkout/success/page.tsx b/frontend/app/[locale]/shop/checkout/success/page.tsx index cedff489..9cba258c 100644 --- a/frontend/app/[locale]/shop/checkout/success/page.tsx +++ b/frontend/app/[locale]/shop/checkout/success/page.tsx @@ -19,6 +19,7 @@ import { import { cn } from '@/lib/utils'; import { orderIdParamSchema } from '@/lib/validation/shop'; +import MonobankRedirectStatus from './MonobankRedirectStatus'; import OrderStatusAutoRefresh from './OrderStatusAutoRefresh'; export const metadata: Metadata = { @@ -28,7 +29,6 @@ export const metadata: Metadata = { }; export const dynamic = 'force-dynamic'; -export const revalidate = 0; type SearchParams = Record; @@ -46,6 +46,20 @@ function parseOrderId(params: SearchParams): string | null { return parsed.data.id; } +function parseStatusToken(params: SearchParams): string | null { + const raw = getStringParam(params, 'statusToken').trim(); + return raw.length ? raw : null; +} + +function isMonobankRedirectFlow( + params: SearchParams, + statusToken: string | null +): boolean { + if (statusToken) return true; + const flow = getStringParam(params, 'flow').trim().toLowerCase(); + return flow === 'monobank'; +} + function isPaymentsDisabled(params: SearchParams): boolean { const raw = getStringParam(params, 'paymentsDisabled'); if (!raw) return false; @@ -136,6 +150,7 @@ export default async function CheckoutSuccessPage({ }) { const { locale } = await params; const resolvedParams = await searchParams; + const clearCart = shouldClearCart(resolvedParams); const t = await getTranslations('shop.checkout'); @@ -159,6 +174,24 @@ export default async function CheckoutSuccessPage({ ); } + const statusToken = parseStatusToken(resolvedParams); + if (isMonobankRedirectFlow(resolvedParams, statusToken)) { + return ( +
+ + +
+ ); + } + const paymentsDisabled = isPaymentsDisabled(resolvedParams); let order: Awaited>; diff --git a/frontend/app/api/questions/[category]/route.ts b/frontend/app/api/questions/[category]/route.ts index 2e82a697..f4dffc65 100644 --- a/frontend/app/api/questions/[category]/route.ts +++ b/frontend/app/api/questions/[category]/route.ts @@ -1,4 +1,4 @@ -import { and, eq, ilike, sql } from 'drizzle-orm'; +import { and, eq, ilike } from 'drizzle-orm'; import { NextResponse } from 'next/server'; import { db } from '@/db'; @@ -27,6 +27,47 @@ type QaApiResponse = { locale: string; }; +function dedupeItems(items: QaApiResponse['items']) { + const seenById = new Set(); + const seenByText = new Set(); + const unique: QaApiResponse['items'] = []; + + for (const item of items) { + if (seenById.has(item.id)) { + continue; + } + + const textKey = `${item.locale}:${item.question.trim().toLowerCase()}`; + if (seenByText.has(textKey)) { + continue; + } + + seenById.add(item.id); + seenByText.add(textKey); + unique.push(item); + } + + return unique; +} + +function normalizeResponse(data: QaApiResponse, limit: number): QaApiResponse { + const uniqueItems = dedupeItems(data.items); + if (uniqueItems.length === data.items.length) { + return data; + } + + const removed = data.items.length - uniqueItems.length; + const total = Math.max(0, data.total - removed); + const totalPages = Math.ceil(total / limit); + + return { + ...data, + items: uniqueItems, + total, + totalPages, + }; +} + export async function GET( req: Request, ctx: { params: Promise<{ category: string }> } @@ -59,9 +100,15 @@ export async function GET( const cached = await getQaCache(cacheKey); if (cached) { - const response = NextResponse.json(cached); + const normalizedCached = normalizeResponse(cached, limit); + const response = NextResponse.json(normalizedCached); response.headers.set('Cache-Control', 'no-store'); response.headers.set('x-qa-cache', 'HIT'); + + if (normalizedCached.items.length !== cached.items.length) { + await setQaCache(cacheKey, normalizedCached); + } + return response; } @@ -92,19 +139,7 @@ export async function GET( ? and(baseCondition, ilike(questionTranslations.question, `%${search}%`)) : baseCondition; - const [{ count }] = await db - .select({ count: sql`count(*)` }) - .from(questions) - .innerJoin( - questionTranslations, - eq(questions.id, questionTranslations.questionId) - ) - .where(whereCondition); - - const total = Number(count); - const totalPages = Math.ceil(total / limit); - - const items = await db + const allItems = await db .select({ id: questions.id, categoryId: questions.categoryId, @@ -120,27 +155,25 @@ export async function GET( eq(questions.id, questionTranslations.questionId) ) .where(whereCondition) - .orderBy(questions.sortOrder) - .limit(limit) - .offset(offset); + .orderBy(questions.sortOrder, questions.id); + + const uniqueItems = dedupeItems(allItems); + const total = uniqueItems.length; + const totalPages = Math.ceil(total / limit); + const items = uniqueItems.slice(offset, offset + limit); - const response = NextResponse.json({ + const payload = { items, total, page, totalPages, locale, - }); + } satisfies QaApiResponse; + const response = NextResponse.json(payload); response.headers.set('Cache-Control', 'no-store'); response.headers.set('x-qa-cache', 'MISS'); - await setQaCache(cacheKey, { - items, - total, - page, - totalPages, - locale, - }); + await setQaCache(cacheKey, payload); return response; } catch (error) { diff --git a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts index a560f375..ad745577 100644 --- a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts @@ -12,9 +12,16 @@ import { requireAdminApi, } from '@/lib/auth/admin'; import { getMonobankConfig } from '@/lib/env/monobank'; +import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; import { logError, logWarn } from '@/lib/logging'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + enforceRateLimit, + getRateLimitSubject, + normalizeRateLimitSubject, + rateLimitResponse, +} from '@/lib/security/rate-limit'; import { InvalidPayloadError, OrderNotFoundError, @@ -29,6 +36,9 @@ function noStoreJson(body: unknown, init?: { status?: number }) { return res; } +const DEFAULT_ADMIN_REFUND_RATE_LIMIT_MAX = 5; +const DEFAULT_ADMIN_REFUND_RATE_LIMIT_WINDOW_SECONDS = 60; + export async function POST( request: NextRequest, context: { params: Promise<{ id: string }> } @@ -58,7 +68,7 @@ export async function POST( }; let orderIdForLog: string | null = null; try { - await requireAdminApi(request); + const adminUser = await requireAdminApi(request); const csrfRes = requireAdminCsrf(request, 'admin:orders:refund'); if (csrfRes) { logWarn('admin_orders_refund_csrf_rejected', { @@ -71,6 +81,42 @@ export async function POST( csrfRes.headers.set('Cache-Control', 'no-store'); return csrfRes; } + const adminId = adminUser?.id; + + const adminSubject = + typeof adminId === 'string' && adminId.trim().length > 0 + ? `admin_${normalizeRateLimitSubject(adminId)}` + : getRateLimitSubject(request); + + const limit = readPositiveIntEnv( + 'ADMIN_REFUND_RATE_LIMIT_MAX', + DEFAULT_ADMIN_REFUND_RATE_LIMIT_MAX + ); + const windowSeconds = readPositiveIntEnv( + 'ADMIN_REFUND_RATE_LIMIT_WINDOW_SECONDS', + DEFAULT_ADMIN_REFUND_RATE_LIMIT_WINDOW_SECONDS + ); + + const decision = await enforceRateLimit({ + key: `admin_refund:${adminSubject}`, + limit, + windowSeconds, + }); + + if (!decision.ok) { + logWarn('admin_orders_refund_rate_limited', { + ...baseMeta, + code: 'RATE_LIMITED', + orderId: orderIdForLog, + retryAfterSeconds: decision.retryAfterSeconds, + durationMs: Date.now() - startedAtMs, + }); + + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'admin_refund' }, + }); + } const rawParams = await context.params; const parsed = orderIdParamSchema.safeParse(rawParams); @@ -111,12 +157,9 @@ export async function POST( { status: 409 } ); } - } - if (targetOrder?.paymentProvider === 'monobank') { - const { requestMonobankFullRefund } = await import( - '@/lib/services/orders/monobank-refund' - ); + const { requestMonobankFullRefund } = + await import('@/lib/services/orders/monobank-refund'); const result = await requestMonobankFullRefund({ orderId: orderIdForLog, requestId, diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 0cfcb0f5..23c1a1c4 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -5,7 +5,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { MoneyValueError } from '@/db/queries/shop/orders'; import { getCurrentUser } from '@/lib/auth'; import { isMonobankEnabled } from '@/lib/env/monobank'; +import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; import { logError, logInfo, logWarn } from '@/lib/logging'; +import { MONO_MISMATCH, monoLogWarn } from '@/lib/logging/monobank'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; import { enforceRateLimit, @@ -45,6 +47,9 @@ const EXPECTED_BUSINESS_ERROR_CODES = new Set([ 'PAYMENT_ATTEMPTS_EXHAUSTED', ]); +const DEFAULT_CHECKOUT_RATE_LIMIT_MAX = 10; +const DEFAULT_CHECKOUT_RATE_LIMIT_WINDOW_SECONDS = 300; + function parseRequestedProvider( raw: unknown ): CheckoutRequestedProvider | 'invalid' | null { @@ -288,6 +293,7 @@ function buildMonobankCheckoutResponse({ pageUrl, currency, totalAmountMinor, + statusToken, }: { order: CheckoutOrderShape; itemCount: number; @@ -296,6 +302,7 @@ function buildMonobankCheckoutResponse({ pageUrl: string; currency: 'UAH'; totalAmountMinor: number; + statusToken: string; }) { const res = NextResponse.json( { @@ -320,6 +327,7 @@ function buildMonobankCheckoutResponse({ provider: 'mono' as const, currency, totalAmountMinor, + statusToken, }, { status } ); @@ -354,6 +362,12 @@ async function runMonobankCheckoutFlow(args: { }); if (args.totalCents !== monobankAttempt.totalAmountMinor) { + monoLogWarn(MONO_MISMATCH, { + requestId: args.requestId, + orderId: args.order.id, + reason: 'checkout_total_amount_mismatch', + }); + logError( 'checkout_mono_amount_mismatch', new Error('Monobank amount mismatch'), @@ -380,6 +394,7 @@ async function runMonobankCheckoutFlow(args: { pageUrl: monobankAttempt.pageUrl, currency: monobankAttempt.currency, totalAmountMinor: monobankAttempt.totalAmountMinor, + statusToken, }); } catch (error) { const mapped = mapMonobankCheckoutError(error); @@ -708,20 +723,15 @@ export async function POST(request: NextRequest) { const checkoutSubject = sessionUserId ?? getRateLimitSubject(request); - const limitParsed = Number.parseInt( - process.env.CHECKOUT_RATE_LIMIT_MAX ?? '', - 10 + const limit = readPositiveIntEnv( + 'CHECKOUT_RATE_LIMIT_MAX', + DEFAULT_CHECKOUT_RATE_LIMIT_MAX ); - const windowParsed = Number.parseInt( - process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? '', - 10 + const windowSeconds = readPositiveIntEnv( + 'CHECKOUT_RATE_LIMIT_WINDOW_SECONDS', + DEFAULT_CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ); - const limit = - Number.isFinite(limitParsed) && limitParsed > 0 ? limitParsed : 10; - const windowSeconds = - Number.isFinite(windowParsed) && windowParsed > 0 ? windowParsed : 300; - const decision = await enforceRateLimit({ key: `checkout:${checkoutSubject}`, limit, diff --git a/frontend/app/api/shop/internal/monobank/janitor/route.ts b/frontend/app/api/shop/internal/monobank/janitor/route.ts index 38eaf80a..d1bb935f 100644 --- a/frontend/app/api/shop/internal/monobank/janitor/route.ts +++ b/frontend/app/api/shop/internal/monobank/janitor/route.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; import { db } from '@/db'; import { logError, logInfo, logWarn } from '@/lib/logging'; -import { guardNonBrowserOnly } from '@/lib/security/origin'; +import { guardNonBrowserFailClosed } from '@/lib/security/origin'; import { runMonobankJanitorJob1, runMonobankJanitorJob2, @@ -202,7 +202,9 @@ export async function POST(request: NextRequest) { jobName: 'monobank-janitor', }; - const blocked = guardNonBrowserOnly(request); + const blocked = guardNonBrowserFailClosed(request, { + surface: 'monobank_janitor', + }); if (blocked) { blocked.headers.set('X-Request-Id', requestId); logWarn('internal_monobank_janitor_origin_blocked', { diff --git a/frontend/app/api/shop/internal/orders/restock-stale/route.ts b/frontend/app/api/shop/internal/orders/restock-stale/route.ts index 256c6436..4f3220c5 100644 --- a/frontend/app/api/shop/internal/orders/restock-stale/route.ts +++ b/frontend/app/api/shop/internal/orders/restock-stale/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor'; import { logError, logInfo, logWarn } from '@/lib/logging'; -import { guardNonBrowserOnly } from '@/lib/security/origin'; +import { guardNonBrowserFailClosed } from '@/lib/security/origin'; import { restockStaleNoPaymentOrders, restockStalePendingOrders, @@ -260,7 +260,9 @@ export async function POST(request: NextRequest) { jobName: 'restock-stale', }; - const blocked = guardNonBrowserOnly(request); + const blocked = guardNonBrowserFailClosed(request, { + surface: 'orders_restock_stale_janitor', + }); if (blocked) { logWarn('internal_janitor_origin_blocked', { ...baseMeta, diff --git a/frontend/app/api/shop/webhooks/monobank/route.ts b/frontend/app/api/shop/webhooks/monobank/route.ts index 7e2cbc02..88b00dc7 100644 --- a/frontend/app/api/shop/webhooks/monobank/route.ts +++ b/frontend/app/api/shop/webhooks/monobank/route.ts @@ -5,14 +5,34 @@ import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; import { getMonobankConfig } from '@/lib/env/monobank'; +import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; import { logError, logInfo, logWarn } from '@/lib/logging'; +import { + MONO_SIG_INVALID, + MONO_STORE_MODE, + monoLogError, + monoLogInfo, + monoLogWarn, + monoSha256Raw, +} from '@/lib/logging/monobank'; import { verifyWebhookSignatureWithRefresh } from '@/lib/psp/monobank'; +import { guardNonBrowserFailClosed } from '@/lib/security/origin'; +import { + enforceRateLimit, + getRateLimitSubject, + rateLimitResponse, +} from '@/lib/security/rate-limit'; import { handleMonobankWebhook } from '@/lib/services/orders/monobank-webhook'; export const dynamic = 'force-dynamic'; type WebhookMode = 'drop' | 'store' | 'apply'; +const DEFAULT_MONO_WEBHOOK_MISSING_SIG_LIMIT = 30; +const DEFAULT_MONO_WEBHOOK_MISSING_SIG_WINDOW_SECONDS = 60; +const DEFAULT_MONO_WEBHOOK_INVALID_SIG_LIMIT = 30; +const DEFAULT_MONO_WEBHOOK_INVALID_SIG_WINDOW_SECONDS = 60; + function parseWebhookMode(raw: unknown): WebhookMode { const v = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; if (v === 'drop' || v === 'store' || v === 'apply') return v; @@ -47,6 +67,23 @@ function parseWebhookPayload( export async function POST(request: NextRequest) { const requestId = request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const originBlocked = guardNonBrowserFailClosed(request, { + surface: 'monobank_webhook', + }); + if (originBlocked) { + logWarn('monobank_webhook_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + surface: 'monobank_webhook', + }); + originBlocked.headers.set('X-Request-Id', requestId); + return originBlocked; + } + + const signature = request.headers.get('x-sign'); + const hasXSign = typeof signature === 'string' && signature.trim().length > 0; const baseMeta = { requestId, @@ -68,6 +105,13 @@ export async function POST(request: NextRequest) { webhookMode = 'apply'; } } + if (webhookMode !== 'apply') { + monoLogInfo(MONO_STORE_MODE, { + ...baseMeta, + mode: webhookMode, + storeDecision: webhookMode, + }); + } let rawBodyBytes: Buffer; try { @@ -75,17 +119,57 @@ export async function POST(request: NextRequest) { } catch (error) { logError('monobank_webhook_body_read_failed', error, { ...baseMeta, + mode: webhookMode, + hasXSign, + rawBytesLen: 0, + reason: 'BODY_READ_FAILED', code: 'MONO_BODY_READ_FAILED', }); return noStoreJson({ ok: true }, { status: 200 }); } - const rawSha256 = crypto - .createHash('sha256') - .update(rawBodyBytes) - .digest('hex'); + const rawSha256 = monoSha256Raw(rawBodyBytes); + const rawBytesLen = rawBodyBytes.byteLength; const eventKey = rawSha256; - const signature = request.headers.get('x-sign'); + const rateLimitSubject = getRateLimitSubject(request); + const diagMeta = { + ...baseMeta, + mode: webhookMode, + hasXSign, + rawSha256, + rawBytesLen, + }; + + if (!hasXSign) { + const decision = await enforceRateLimit({ + key: `monobank_webhook:missing_sig:${rateLimitSubject}`, + limit: readPositiveIntEnv( + 'MONOBANK_WEBHOOK_MISSING_SIG_RATE_LIMIT_MAX', + DEFAULT_MONO_WEBHOOK_MISSING_SIG_LIMIT + ), + windowSeconds: readPositiveIntEnv( + 'MONOBANK_WEBHOOK_MISSING_SIG_RATE_LIMIT_WINDOW_SECONDS', + DEFAULT_MONO_WEBHOOK_MISSING_SIG_WINDOW_SECONDS + ), + }); + + if (!decision.ok) { + monoLogWarn(MONO_SIG_INVALID, { + ...diagMeta, + reason: 'SIG_MISSING_RATE_LIMITED', + }); + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'monobank_webhook_missing_signature' }, + }); + } + + monoLogWarn(MONO_SIG_INVALID, { + ...diagMeta, + reason: 'SIG_MISSING', + }); + return noStoreJson({ ok: true }, { status: 200 }); + } let validSignature = false; try { @@ -94,18 +178,39 @@ export async function POST(request: NextRequest) { signature, }); } catch (error) { - logError('monobank_webhook_signature_error', error, { - ...baseMeta, - code: 'MONO_SIG_INVALID', - rawSha256, + monoLogError(MONO_SIG_INVALID, error, { + ...diagMeta, + reason: 'SIG_VERIFY_ERROR', }); } if (!validSignature) { - logWarn('monobank_webhook_signature_invalid', { - ...baseMeta, - code: 'MONO_SIG_INVALID', - rawSha256, + const decision = await enforceRateLimit({ + key: `monobank_webhook:invalid_sig:${rateLimitSubject}`, + limit: readPositiveIntEnv( + 'MONOBANK_WEBHOOK_INVALID_SIG_RATE_LIMIT_MAX', + DEFAULT_MONO_WEBHOOK_INVALID_SIG_LIMIT + ), + windowSeconds: readPositiveIntEnv( + 'MONOBANK_WEBHOOK_INVALID_SIG_RATE_LIMIT_WINDOW_SECONDS', + DEFAULT_MONO_WEBHOOK_INVALID_SIG_WINDOW_SECONDS + ), + }); + + if (!decision.ok) { + monoLogWarn(MONO_SIG_INVALID, { + ...diagMeta, + reason: 'SIG_INVALID_RATE_LIMITED', + }); + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'monobank_webhook_invalid_signature' }, + }); + } + + monoLogWarn(MONO_SIG_INVALID, { + ...diagMeta, + reason: 'SIG_INVALID', }); return noStoreJson({ ok: true }, { status: 200 }); } @@ -113,10 +218,10 @@ export async function POST(request: NextRequest) { const parsedPayload = parseWebhookPayload(rawBodyBytes); if (!parsedPayload) { logWarn('monobank_webhook_payload_invalid', { - ...baseMeta, + ...diagMeta, code: 'INVALID_PAYLOAD', eventKey, - rawSha256, + reason: 'INVALID_PAYLOAD', }); return noStoreJson({ ok: true }, { status: 200 }); } @@ -124,26 +229,40 @@ export async function POST(request: NextRequest) { try { const result = await handleMonobankWebhook({ rawBodyBytes, + rawSha256, parsedPayload, eventKey, requestId, mode: webhookMode, }); + if ( + result.appliedResult === 'stored' || + result.appliedResult === 'dropped' + ) { + monoLogInfo(MONO_STORE_MODE, { + ...diagMeta, + storeDecision: result.appliedResult, + eventKey, + invoiceId: result.invoiceId, + reason: 'STORE_MODE_RESULT', + }); + } + logInfo('monobank_webhook_processed', { - ...baseMeta, + ...diagMeta, eventKey, - rawSha256, invoiceId: result.invoiceId, appliedResult: result.appliedResult, deduped: result.deduped, + reason: 'PROCESSED', }); } catch (error) { logError('monobank_webhook_apply_failed', error, { - ...baseMeta, + ...diagMeta, code: 'WEBHOOK_APPLY_FAILED', eventKey, - rawSha256, + reason: 'WEBHOOK_APPLY_FAILED', }); } diff --git a/frontend/components/header/AppMobileMenu.tsx b/frontend/components/header/AppMobileMenu.tsx index 11253323..3d09bb23 100644 --- a/frontend/components/header/AppMobileMenu.tsx +++ b/frontend/components/header/AppMobileMenu.tsx @@ -160,7 +160,7 @@ export function AppMobileMenu({ {open && ( <>
+
{isBlog && } diff --git a/frontend/components/header/DesktopNav.tsx b/frontend/components/header/DesktopNav.tsx index 12353f60..aba99828 100644 --- a/frontend/components/header/DesktopNav.tsx +++ b/frontend/components/header/DesktopNav.tsx @@ -31,7 +31,7 @@ export function DesktopNav({ variant, blogCategories = [] }: DesktopNavProps) { }; if (variant === 'shop') { - return ; + return ; } if (variant === 'blog') { diff --git a/frontend/components/header/MobileActions.tsx b/frontend/components/header/MobileActions.tsx index 5531dded..a6b8e0f8 100644 --- a/frontend/components/header/MobileActions.tsx +++ b/frontend/components/header/MobileActions.tsx @@ -27,7 +27,7 @@ export function MobileActions({ const isBlog = variant === 'blog'; return ( -
+
{isBlog && } {isShop && } diff --git a/frontend/components/header/UnifiedHeader.tsx b/frontend/components/header/UnifiedHeader.tsx index 23265470..958d045e 100644 --- a/frontend/components/header/UnifiedHeader.tsx +++ b/frontend/components/header/UnifiedHeader.tsx @@ -35,7 +35,7 @@ function HeaderContent({