diff --git a/knip.json b/knip.json index b5f4e20e8..82c3a2d67 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/knip@latest/schema.json", + "$schema": "https://unpkg.com/knip@5.37.1/schema.json", "ignore": ["src/assets/**"], "entry": ["src/**/*.{js,jsx,ts,tsx}", "postcss.config.js", "tailwind.config.js"], "project": ["src/**/*.{js,jsx,ts,tsx}", "*.config.{js,ts}"] diff --git a/public/game/peanut-game.html b/public/game/peanut-game.html index 2dabff7f5..b1b0629e2 100644 --- a/public/game/peanut-game.html +++ b/public/game/peanut-game.html @@ -2625,6 +2625,9 @@ diff --git a/public/onesignal/OneSignalSDKWorker.js b/public/onesignal/OneSignalSDKWorker.js index 7032e7a66..4856c9450 100644 --- a/public/onesignal/OneSignalSDKWorker.js +++ b/public/onesignal/OneSignalSDKWorker.js @@ -1,3 +1,7 @@ -// use the stable v16 service worker path per onesignal docs -// note: onesignal does not publish minor-pinned sw paths; use v16 channel +// Version Pinning: OneSignal uses major version channels (v16) for their CDN. +// Per OneSignal documentation, they do not publish minor or patch-pinned service worker URLs. +// The v16 channel receives security updates and bug fixes automatically while maintaining +// API compatibility. This is the recommended approach per OneSignal's best practices. +// For stricter version control, we can consider self-hosting the SDK, but this requires manual updates. +// Reference: https://documentation.onesignal.com/docs/web-push-quickstart importScripts('https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.sw.js') diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index e44b0b963..2a98ec647 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -20,9 +20,7 @@ import { Banner } from '@/components/Global/Banner' import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useSetupStore } from '@/redux/hooks' import ForceIOSPWAInstall from '@/components/ForceIOSPWAInstall' - -// Allow access to some public paths without authentication -const publicPathRegex = /^\/(request\/pay|claim|pay\/.+$|support|invite|dev)/ +import { PUBLIC_ROUTES_REGEX } from '@/constants/routes' const Layout = ({ children }: { children: React.ReactNode }) => { const pathName = usePathname() @@ -78,7 +76,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => { }, []) // Allow access to public paths without authentication - const isPublicPath = publicPathRegex.test(pathName) + const isPublicPath = PUBLIC_ROUTES_REGEX.test(pathName) useEffect(() => { if (!isPublicPath && !isFetchingUser && !user) { diff --git a/src/app/(mobile-ui)/qr/[code]/page.tsx b/src/app/(mobile-ui)/qr/[code]/page.tsx new file mode 100644 index 000000000..4a5e6673a --- /dev/null +++ b/src/app/(mobile-ui)/qr/[code]/page.tsx @@ -0,0 +1,265 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' +import Card from '@/components/Global/Card' +import NavHeader from '@/components/Global/NavHeader' +import { PEANUT_API_URL } from '@/constants' +import { useAuth } from '@/context/authContext' +import { useRouter, useParams } from 'next/navigation' +import { useCallback, useEffect, useState } from 'react' +import PeanutLoading from '@/components/Global/PeanutLoading' +import ErrorAlert from '@/components/Global/ErrorAlert' +import { Icon } from '@/components/Global/Icons/Icon' +import { saveRedirectUrl, generateInviteCodeLink, sanitizeRedirectURL } from '@/utils' +import { getShakeClass } from '@/utils/perk.utils' +import Cookies from 'js-cookie' +import { useRedirectQrStatus } from '@/hooks/useRedirectQrStatus' +import { useHoldToClaim } from '@/hooks/useHoldToClaim' + +export default function RedirectQrClaimPage() { + const router = useRouter() + const params = useParams() + const code = params?.code as string + const { user } = useAuth() + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // Fetch redirect QR status using shared hook + const { data: redirectQrData, isLoading: isCheckingStatus, error: redirectQrError } = useRedirectQrStatus(code) + + // Handle redirects based on QR status and authentication + useEffect(() => { + // Wait for QR status to load + if (isCheckingStatus || !redirectQrData) { + return + } + + // If QR is already claimed, redirect to the target URL + if (redirectQrData.claimed && redirectQrData.redirectUrl) { + // Sanitize redirect URL to prevent open redirect attacks + // For same-origin URLs, sanitizeRedirectURL returns safe path + // For external URLs, it returns null (we handle separately) + const sanitizedPath = sanitizeRedirectURL(redirectQrData.redirectUrl) + + if (sanitizedPath) { + // Internal redirect - use sanitized path with Next.js router + router.push(sanitizedPath) + } else { + // External redirect - validate it's expected domain before redirecting + try { + const url = new URL(redirectQrData.redirectUrl) + // Allow external redirects ONLY for trusted domains (peanut.me) + // This relies on backend validation during QR claiming + if (url.hostname.includes('peanut.me') || url.hostname.includes('localhost')) { + window.location.href = redirectQrData.redirectUrl + } else { + console.error('Untrusted external redirect blocked:', redirectQrData.redirectUrl) + setError('Invalid QR code destination.') + } + } catch (error) { + console.error('Invalid redirect URL:', redirectQrData.redirectUrl) + setError('Invalid QR code destination.') + } + } + return + } + + // QR is not claimed - check authentication + if (!user) { + // User not logged in - redirect to setup to create account/login + saveRedirectUrl() + router.push('/setup') + } + }, [isCheckingStatus, redirectQrData, user, router]) + + const handleClaim = useCallback(async () => { + // Auth check is already handled by useEffect above + // If we reach here, user is authenticated + setIsLoading(true) + setError(null) + + try { + // Generate invite link with correct 3-digit suffix + const username = user?.user?.username + if (!username) { + throw new Error('Username not found') + } + + const { inviteLink } = generateInviteCodeLink(username) + + const response = await fetch(`${PEANUT_API_URL}/qr/${code}/claim`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${Cookies.get('jwt-token')}`, + }, + body: JSON.stringify({ + targetUrl: inviteLink, // Pass the correctly formatted invite link + }), + }) + + const data = await response.json() + + if (!response.ok) { + // Log backend error for debugging but show generic message to user + console.error('Backend claim error:', data.message || data.error) + throw new Error('Failed to claim QR code. Please try again.') + } + + // Success! Show success page, then redirect to invite (which goes to profile for logged-in users) + router.push(`/qr/${code}/success`) + } catch (err: any) { + console.error('Error claiming QR:', err) + // Always show generic error message (don't expose backend details) + setError('Failed to claim QR code. Please try again.') + } finally { + setIsLoading(false) + } + }, [code, router, user]) + + // Hold-to-claim mechanics with shake animation + const { holdProgress, isShaking, shakeIntensity, buttonProps } = useHoldToClaim({ + onComplete: handleClaim, + disabled: isLoading, + }) + + // Show loading while checking status or if we're in the process of redirecting + if (isCheckingStatus || (redirectQrData?.claimed && redirectQrData?.redirectUrl)) { + return ( +
+ +
+ +
+
+ ) + } + + // If not logged in and QR is unclaimed, the useEffect above will redirect to setup + // This loading screen will show briefly during that redirect + if (!user) { + return ( +
+ +
+ +
+
+ ) + } + + // Show error only if there's an actual error or QR is not available (and not claimed) + if (redirectQrError || !redirectQrData || (!redirectQrData.available && !redirectQrData.claimed)) { + // Log error for debugging but don't expose details to user + if (redirectQrError) { + console.error('QR status check error:', redirectQrError) + } + return ( +
+ +
+ +
+
+ +
+
+
+

QR Code Unavailable

+

+ {redirectQrData?.claimed + ? 'This QR code has already been claimed by another user.' + : 'This QR code is not available for claiming.'} +

+
+
+ +
+
+ ) + } + + return ( +
+ +
+ {/* QR Code Visual */} + +
+
+ +
+
+
+

Claim Your Code

+

+ This QR code will be permanently linked to your Peanut profile. Anyone who scans it will be + able to use your invite. +

+
+
+ + {/* How it works */} + +

How it works

+
+
+
+ 1 +
+

You claim this QR code and it becomes yours forever

+
+
+
+ 2 +
+

Put the sticker anywhere you want people to find you

+
+
+
+ 3 +
+

+ They can join Peanut with your invite and contribute towards your points, forever. +

+
+
+
+ + {/* Important note */} + +
+ +

+ Important: Once claimed, this QR code cannot be transferred or changed. +

+
+
+ + {/* Claim button - Hold to claim */} + + + {error && } +
+
+ ) +} diff --git a/src/app/(mobile-ui)/qr/[code]/success/page.tsx b/src/app/(mobile-ui)/qr/[code]/success/page.tsx new file mode 100644 index 000000000..1f2e50eed --- /dev/null +++ b/src/app/(mobile-ui)/qr/[code]/success/page.tsx @@ -0,0 +1,121 @@ +'use client' + +import { Button } from '@/components/0_Bruddle' +import Card from '@/components/Global/Card' +import NavHeader from '@/components/Global/NavHeader' +import { BASE_URL } from '@/constants' +import { useRouter, useParams } from 'next/navigation' +import { useEffect } from 'react' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { Icon } from '@/components/Global/Icons/Icon' +import { confettiPresets } from '@/utils/confetti' +import { useRedirectQrStatus } from '@/hooks/useRedirectQrStatus' +import QRCodeWrapper from '@/components/Global/QRCodeWrapper' +import { useToast } from '@/components/0_Bruddle/Toast' + +export default function RedirectQrSuccessPage() { + const router = useRouter() + const params = useParams() + const code = params?.code as string + const toast = useToast() + + // Fetch redirect QR details using shared hook + const { data: redirectQrData, isLoading } = useRedirectQrStatus(code) + + const qrUrl = `${BASE_URL}/qr/${code}` + + // Trigger confetti on mount + useEffect(() => { + const timer = setTimeout(() => { + confettiPresets.success() + }, 300) + + return () => clearTimeout(timer) + }, []) + + if (isLoading || !redirectQrData) { + return ( +
+ +
+ +
+
+ ) + } + + return ( +
+ +
+ {/* Title */} +
+

This QR is now yours!

+

Your sticker is now linked to your profile forever.

+
+ + {/* QR Code Display */} +
+ +
+ + {/* Sticker Info Card */} + +
+ +
+

Put it anywhere!

+

+ Stick it on your laptop, water bottle, or anywhere you want people to find you. Anyone + who scans will be able to join Peanut with your invite and contribute towards your + points forever. +

+
+
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ) +} diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index a1dcf33b1..64b5c73df 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -7,17 +7,31 @@ import { isAddress } from 'viem' import { printableAddress, resolveAddressToUsername } from '@/utils' import { chargesApi } from '@/services/charges' import { parseAmountAndToken } from '@/lib/url-parser/parser' +import { notFound } from 'next/navigation' +import { RESERVED_ROUTES } from '@/constants/routes' type PageProps = { params: Promise<{ recipient?: string[] }> } export async function generateMetadata({ params, searchParams }: any) { - let title = 'Request Payment | Peanut' - const siteUrl: string = (await getOrigin()) || BASE_URL // getOrigin for getting the origin of the site regardless of its a vercel preview or not const resolvedSearchParams = await searchParams const resolvedParams = await params + // Guard: Don't generate metadata for reserved routes (handled by their specific routes) + const firstSegment = resolvedParams.recipient?.[0]?.toLowerCase() + if (firstSegment && RESERVED_ROUTES.includes(firstSegment)) { + return {} + } + + // Guard: Ensure recipient exists + if (!resolvedParams.recipient?.[0]) { + return {} + } + + let title = 'Request Payment | Peanut' + const siteUrl: string = (await getOrigin()) || BASE_URL // getOrigin for getting the origin of the site regardless of its a vercel preview or not + let recipient = resolvedParams.recipient[0].toLowerCase() if (recipient.includes('%40') || recipient.includes('@')) { @@ -165,6 +179,13 @@ export default function Page(props: PageProps) { const params = use(props.params) const recipient = params.recipient ?? [] + // Guard: Reserved routes should be handled by their specific route files + // If we reach here, it means Next.js routing didn't catch it properly + const firstSegment = recipient[0]?.toLowerCase() + if (firstSegment && RESERVED_ROUTES.includes(firstSegment)) { + notFound() + } + return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 430beb702..a62095b59 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -69,6 +69,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + {/* Note: Google Tag Manager (gtag.js) does not support version pinning.*/} {process.env.NODE_ENV !== 'development' && process.env.NEXT_PUBLIC_GA_KEY && ( <>