-
-
Notifications
You must be signed in to change notification settings - Fork 4
fix: feedback, leaderboard #350
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,110 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||||||||||||||||||||||||||||||||||||||||||
| import nodemailer from 'nodemailer'; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||
| enforceRateLimit, | ||||||||||||||||||||||||||||||||||||||||||||
| getRateLimitSubject, | ||||||||||||||||||||||||||||||||||||||||||||
| rateLimitResponse, | ||||||||||||||||||||||||||||||||||||||||||||
| } from '@/lib/security/rate-limit'; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB | ||||||||||||||||||||||||||||||||||||||||||||
| const MAX_FILES = 5; | ||||||||||||||||||||||||||||||||||||||||||||
| const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[a-zA-Z0-9.-]{1,253}$/; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| function escapeHtml(str: string): string { | ||||||||||||||||||||||||||||||||||||||||||||
| return str | ||||||||||||||||||||||||||||||||||||||||||||
| .replace(/&/g, '&') | ||||||||||||||||||||||||||||||||||||||||||||
| .replace(/</g, '<') | ||||||||||||||||||||||||||||||||||||||||||||
| .replace(/>/g, '>') | ||||||||||||||||||||||||||||||||||||||||||||
| .replace(/"/g, '"') | ||||||||||||||||||||||||||||||||||||||||||||
| .replace(/'/g, '''); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export async function POST(req: NextRequest) { | ||||||||||||||||||||||||||||||||||||||||||||
| const subject = getRateLimitSubject(req); | ||||||||||||||||||||||||||||||||||||||||||||
| const rl = await enforceRateLimit({ | ||||||||||||||||||||||||||||||||||||||||||||
| key: `feedback:${subject}`, | ||||||||||||||||||||||||||||||||||||||||||||
| limit: 5, | ||||||||||||||||||||||||||||||||||||||||||||
| windowSeconds: 3600, // 5 submissions per IP per hour | ||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||
| if (!rl.ok) return rateLimitResponse({ retryAfterSeconds: rl.retryAfterSeconds }); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const gmailUser = process.env.GMAIL_USER; | ||||||||||||||||||||||||||||||||||||||||||||
| const gmailPass = process.env.GMAIL_APP_PASSWORD; | ||||||||||||||||||||||||||||||||||||||||||||
| const emailFrom = process.env.EMAIL_FROM; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if (!gmailUser || !gmailPass || !emailFrom) { | ||||||||||||||||||||||||||||||||||||||||||||
| console.error('Feedback API: Missing email environment variables'); | ||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: false }, { status: 500 }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| let formData: FormData; | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| formData = await req.formData(); | ||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: false }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const name = (formData.get('name') as string | null)?.trim(); | ||||||||||||||||||||||||||||||||||||||||||||
| const email = (formData.get('email') as string | null)?.trim(); | ||||||||||||||||||||||||||||||||||||||||||||
| const category = (formData.get('category') as string | null)?.trim(); | ||||||||||||||||||||||||||||||||||||||||||||
| const message = (formData.get('message') as string | null)?.trim(); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if (!name || !email || !category || !message) { | ||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: false }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if (!EMAIL_RE.test(email)) { | ||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: false }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Strip CRLF and RFC 5322 specials from email before use in headers | ||||||||||||||||||||||||||||||||||||||||||||
| const safeEmail = email.replace(/[\r\n<>"]/g, ''); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const rawFiles = formData.getAll('attachment'); | ||||||||||||||||||||||||||||||||||||||||||||
| const files = rawFiles.filter((f): f is File => f instanceof File && f.size > 0); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if (files.length > MAX_FILES) { | ||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: false }, { status: 400 }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const attachments: { filename: string; content: Buffer; contentType: string }[] = []; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| for (const f of files) { | ||||||||||||||||||||||||||||||||||||||||||||
| if (f.size > MAX_FILE_SIZE) { | ||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: false, tooLarge: true }, { status: 413 }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| const buffer = Buffer.from(await f.arrayBuffer()); | ||||||||||||||||||||||||||||||||||||||||||||
| attachments.push({ filename: f.name, content: buffer, contentType: f.type }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+71
to
+79
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MIME header injection: sanitize
Strip at minimum CRLF and null bytes from 🛡️ Proposed fix- const buffer = Buffer.from(await f.arrayBuffer());
- attachments.push({ filename: f.name, content: buffer, contentType: f.type });
+ const buffer = Buffer.from(await f.arrayBuffer());
+ const safeFilename = f.name.replace(/[\r\n\0"\\]/g, '').trim().slice(0, 255) || 'attachment';
+ const MIME_TYPE_RE = /^[a-zA-Z0-9][a-zA-Z0-9!#$%&'*+\-.^_`|~]*\/[a-zA-Z0-9][a-zA-Z0-9!#$%&'*+\-.^_`|~]*$/;
+ const safeContentType = MIME_TYPE_RE.test(f.type) ? f.type : 'application/octet-stream';
+ attachments.push({ filename: safeFilename, content: buffer, contentType: safeContentType });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| const mailer = nodemailer.createTransport({ | ||||||||||||||||||||||||||||||||||||||||||||
| service: 'gmail', | ||||||||||||||||||||||||||||||||||||||||||||
| auth: { user: gmailUser, pass: gmailPass }, | ||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Sanitize name for use in email header (strip CR/LF and RFC 5322 specials) | ||||||||||||||||||||||||||||||||||||||||||||
| const safeNameForHeader = name.replace(/[\r\n"<>\\]/g, ''); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| await mailer.sendMail({ | ||||||||||||||||||||||||||||||||||||||||||||
| from: emailFrom, | ||||||||||||||||||||||||||||||||||||||||||||
| replyTo: safeNameForHeader ? `"${safeNameForHeader}" <${safeEmail}>` : safeEmail, | ||||||||||||||||||||||||||||||||||||||||||||
| to: emailFrom, | ||||||||||||||||||||||||||||||||||||||||||||
| subject: `DevLovers Feedback: ${category.replace(/[\r\n]/g, '')}`, | ||||||||||||||||||||||||||||||||||||||||||||
| html: ` | ||||||||||||||||||||||||||||||||||||||||||||
| <p><strong>Name:</strong> ${escapeHtml(name)}</p> | ||||||||||||||||||||||||||||||||||||||||||||
| <p><strong>Email:</strong> ${escapeHtml(email)}</p> | ||||||||||||||||||||||||||||||||||||||||||||
| <p><strong>Category:</strong> ${escapeHtml(category)}</p> | ||||||||||||||||||||||||||||||||||||||||||||
| <p><strong>Message:</strong></p> | ||||||||||||||||||||||||||||||||||||||||||||
| <p>${escapeHtml(message).replace(/\n/g, '<br>')}</p> | ||||||||||||||||||||||||||||||||||||||||||||
| `, | ||||||||||||||||||||||||||||||||||||||||||||
| attachments, | ||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: true }); | ||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||
| console.error('Feedback API: Failed to send email', err); | ||||||||||||||||||||||||||||||||||||||||||||
| return NextResponse.json({ success: false }, { status: 500 }); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,13 +52,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit | |
| left: number; | ||
| } | null>(null); | ||
|
|
||
| const cardStyles = ` | ||
| relative z-10 flex flex-col justify-between overflow-hidden rounded-3xl | ||
| border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md | ||
| dark:border-neutral-800 dark:bg-neutral-900/10 | ||
| p-6 sm:p-8 transition-all duration-300 hover:-translate-y-1 hover:shadow-md | ||
| hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30 | ||
| `; | ||
| const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8'; | ||
|
|
||
| const todayStart = useMemo(() => { | ||
| const d = new Date(); | ||
|
|
@@ -278,7 +272,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit | |
|
|
||
| <div className="flex flex-col items-end gap-2 shrink-0 sm:flex-row sm:items-center"> | ||
| {currentStreak !== undefined && currentStreak > 0 && ( | ||
| <span className="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-orange-500/10 px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px] h-[22px] sm:h-auto font-bold tracking-wide text-orange-600 dark:bg-orange-500/20 dark:text-orange-400 border border-orange-500/20"> | ||
| <span className="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-orange-500/10 px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px] h-5.5 sm:h-auto font-bold tracking-wide text-orange-600 dark:bg-orange-500/20 dark:text-orange-400 border border-orange-500/20"> | ||
| <Flame className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> | ||
|
Comment on lines
+275
to
276
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Find and inspect Tailwind config for custom spacing/zIndex tokens
configs=$(fd -t f 'tailwind\.config\.(js|ts|cjs|mjs)' .)
if [ -z "$configs" ]; then
echo "No tailwind config found."
exit 0
fi
for f in $configs; do
echo "== File: $f =="
cat -n "$f"
echo ""
doneRepository: DevLoversTeam/devlovers.net Length of output: 96 🏁 Script executed: #!/bin/bash
# Verify the specific classes are used in ActivityHeatmapCard.tsx
file="frontend/components/dashboard/ActivityHeatmapCard.tsx"
if [ -f "$file" ]; then
echo "=== Lines 275-276 ==="
sed -n '275,276p' "$file"
echo ""
echo "=== Lines 291-292 ==="
sed -n '291,292p' "$file"
echo ""
echo "=== Lines 349-350 ==="
sed -n '349,350p' "$file"
echo ""
echo "=== Lines 455-456 ==="
sed -n '455,456p' "$file"
echo ""
echo "=== Lines 462-463 ==="
sed -n '462,463p' "$file"
echo ""
echo "=== All occurrences of h-5.5, min-w-17.5, min-w-27.5, z-9999 ==="
rg "(h-5\.5|min-w-17\.5|min-w-27\.5|z-9999)" "$file" -n
else
echo "File not found: $file"
fiRepository: DevLoversTeam/devlovers.net Length of output: 1788 🏁 Script executed: #!/bin/bash
# Check for Tailwind config in common alternative locations
echo "=== Checking next.config.js ==="
[ -f next.config.js ] && cat -n next.config.js || echo "Not found"
echo -e "\n=== Checking postcss.config.js ==="
[ -f postcss.config.js ] && cat -n postcss.config.js || echo "Not found"
echo -e "\n=== Checking for CSS/SCSS files with custom token definitions ==="
fd -t f '\.(css|scss)$' . | head -20
echo -e "\n=== Checking for tailwind directives in CSS files ==="
rg "@apply|@tailwind|theme\(" --type css --type scss | head -30
echo -e "\n=== Check package.json for Tailwind setup ==="
cat package.json | jq '.devDependencies, .dependencies' 2>/dev/null | grep -i tailwind || echo "No Tailwind in dependencies"Repository: DevLoversTeam/devlovers.net Length of output: 473 🏁 Script executed: #!/bin/bash
# Search for theme or spacing definitions that might define these custom values
rg "(spacing|zIndex|extend)" --type js --type ts | grep -E "(5\.5|17\.5|27\.5|9999)" | head -20Repository: DevLoversTeam/devlovers.net Length of output: 53 🏁 Script executed: #!/bin/bash
# Check globals.css
echo "=== frontend/app/globals.css ==="
cat -n frontend/app/globals.css | head -50
echo -e "\n=== Find package.json ==="
fd -t f 'package\.json' . | head -5
echo -e "\n=== Project structure ==="
find . -maxdepth 2 -type f -name '*.json' | grep -E '(package|tsconfig|tailwind)' | head -10Repository: DevLoversTeam/devlovers.net Length of output: 2417 🏁 Script executed: #!/bin/bash
# Check if Tailwind classes would be recognized even without explicit config
# Look for any tailwind setup in the project
echo "=== Search for tailwind references in all files ==="
rg -i "tailwind" --type ts --type tsx --type js --type json | head -20
echo -e "\n=== Check actual dimensions - is 5.5 even a valid Tailwind default? ==="
# 5.5 in spacing would be 22px (5.5 * 4px), but Tailwind default spacing goes 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10...
# 5.5 is NOT in the default scale
echo "Tailwind default spacing scale: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10"
echo "5.5 is not in default scale - would need custom config"Repository: DevLoversTeam/devlovers.net Length of output: 360 🏁 Script executed: #!/bin/bash
# Check globals.css
echo "=== frontend/app/globals.css ==="
cat -n frontend/app/globals.css
echo -e "\n=== Find package.json ==="
fd -t f 'package\.json' .
echo -e "\n=== Check tailwind references ==="
rg -i "tailwind" -t ts -t tsx -t js -t json | head -30Repository: DevLoversTeam/devlovers.net Length of output: 17919 🏁 Script executed: #!/bin/bash
# Check if Tailwind is setup as dependency elsewhere
echo "=== Check all package.json files ==="
find . -name 'package.json' -type f -exec sh -c 'echo "File: $1"; cat "$1" | jq ".devDependencies | keys[] | select(. | contains(\"tailwind\"))" 2>/dev/null || echo "Cannot parse or no tailwind"' _ {} \;Repository: DevLoversTeam/devlovers.net Length of output: 232 🏁 Script executed: #!/bin/bash
# Double-check: verify if any custom spacing values are defined in globals.css
echo "=== Search for spacing or spacing-related definitions in globals.css ==="
grep -n "spacing\|--spacing\|--width\|--height" frontend/app/globals.css || echo "No spacing definitions found"
echo -e "\n=== Search for zIndex definitions ==="
grep -n "zIndex\|--z-\|z-index" frontend/app/globals.css || echo "No zIndex definitions found"
echo -e "\n=== Confirm Tailwind v4 syntax in use ==="
head -10 frontend/app/globals.cssRepository: DevLoversTeam/devlovers.net Length of output: 596 Replace non-existent Tailwind utility classes with arbitrary values or define custom tokens. Classes 🔧 Suggested fix using arbitrary values- <span className="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-orange-500/10 px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px] h-5.5 sm:h-auto font-bold tracking-wide text-orange-600 dark:bg-orange-500/20 dark:text-orange-400 border border-orange-500/20">
+ <span className="hidden sm:inline-flex items-center gap-1.5 rounded-full bg-orange-500/10 px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px] h-[22px] sm:h-auto font-bold tracking-wide text-orange-600 dark:bg-orange-500/20 dark:text-orange-400 border border-orange-500/20">
...
- <span className="hidden sm:block min-w-17.5 text-center">
+ <span className="hidden sm:block min-w-[70px] text-center">
...
- className="fixed z-9999 pointer-events-none"
+ className="fixed z-[9999] pointer-events-none"
...
- <div className="bg-gray-900 dark:bg-white rounded-xl px-3 py-2 shadow-xl shadow-black/30 text-center min-w-27.5">
+ <div className="bg-gray-900 dark:bg-white rounded-xl px-3 py-2 shadow-xl shadow-black/30 text-center min-w-[110px]">Also applies to: lines 291–292, 349–350, 455–456, 462–463 🤖 Prompt for AI Agents |
||
| <span className="hidden xs:inline"> | ||
| {currentStreak} {currentStreak === 1 ? tProfile('dayStreak', { fallback: 'Day Streak' }) : tProfile('daysStreak', { fallback: 'Days Streak' })} | ||
|
|
@@ -294,7 +288,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit | |
| aria-expanded={isDropdownOpen} | ||
| aria-haspopup="listbox" | ||
| > | ||
| <span className="hidden sm:block min-w-[70px] text-center"> | ||
| <span className="hidden sm:block min-w-17.5 text-center"> | ||
| {periodOptions.find(o => o.value === periodOffset)?.label} | ||
| </span> | ||
| <motion.span | ||
|
|
@@ -352,7 +346,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit | |
| </div> | ||
|
|
||
| <div className="relative flex w-full flex-1 flex-col justify-center -mb-2"> | ||
| <div className="absolute left-0 inset-y-0 z-10 w-[44px] pointer-events-none flex flex-col justify-center pb-1"> | ||
| <div className="absolute left-0 inset-y-0 z-10 w-11 pointer-events-none flex flex-col justify-center pb-1"> | ||
| <svg width={44} height={svgHeight} className="block"> | ||
| {monthsData.map((m, i) => ( | ||
| <text key={`l-${i}`} x="32" y={16 + i * 34 + 3} fontSize="9" textAnchor="end" className="fill-gray-400 dark:fill-gray-500 font-bold uppercase tracking-widest"> | ||
|
|
@@ -458,14 +452,14 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit | |
| animate={{ opacity: 1, scale: 1, y: 0 }} | ||
| exit={{ opacity: 0, scale: 0.9, y: 4 }} | ||
| transition={{ duration: 0.1 }} | ||
| className="fixed z-[9999] pointer-events-none" | ||
| className="fixed z-9999 pointer-events-none" | ||
| style={{ | ||
| top: tooltip.top, | ||
| left: tooltip.left, | ||
| transform: 'translate(-50%, -100%)', | ||
| }} | ||
| > | ||
| <div className="bg-gray-900 dark:bg-white rounded-xl px-3 py-2 shadow-xl shadow-black/30 text-center min-w-[110px]"> | ||
| <div className="bg-gray-900 dark:bg-white rounded-xl px-3 py-2 shadow-xl shadow-black/30 text-center min-w-27.5"> | ||
| <p className="text-[11px] font-semibold text-white dark:text-gray-900"> | ||
| {tooltip.date.toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric' })} | ||
| </p> | ||
|
|
@@ -520,4 +514,4 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit | |
| </div> | ||
| </section> | ||
| ); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.