diff --git a/.Jules/palette.md b/.Jules/palette.md
new file mode 100644
index 0000000..81570f8
--- /dev/null
+++ b/.Jules/palette.md
@@ -0,0 +1,3 @@
+## 2024-05-24 - Accessible Icon-Only Buttons
+**Learning:** Icon-only buttons are a common source of accessibility issues. Without a text label, screen reader users have no way of knowing the button's function.
+**Action:** Always add a descriptive `aria-label` to any button that does not contain descriptive text. This provides a clear, accessible name for the button that screen readers can announce.
diff --git a/.Jules/streamline.md b/.Jules/streamline.md
new file mode 100644
index 0000000..a531943
--- /dev/null
+++ b/.Jules/streamline.md
@@ -0,0 +1,5 @@
+## 2024-07-25 - Provide Immediate Feedback for Invalid User Actions
+
+**Learning:** When a user action cannot be completed (e.g., submitting a form), the system must provide immediate, clear, and actionable feedback. Silently blocking an action, even with client-side validation, creates a confusing and frustrating user experience. Users are left wondering if the system is broken or if they did something wrong.
+
+**Action:** In any form or user input flow, always connect client-side validation logic directly to the UI. If a check fails, display a descriptive, temporary message that explains *why* the action was blocked and what the user should do next. This transforms a moment of friction into a moment of guidance, improving user confidence and flow.
diff --git a/app/(footer)/about/page.tsx b/app/(footer)/about/page.tsx
index 419dd2d..e1e708d 100644
--- a/app/(footer)/about/page.tsx
+++ b/app/(footer)/about/page.tsx
@@ -97,9 +97,9 @@ export default function About() {
About FlagForge
-
+
"Where curiosity meets cybersecurity."
-
+
FlagForge is a dynamic and engaging CTF platform dedicated to promoting{" "}
diff --git a/app/(main)/authentication/layout.tsx b/app/(main)/authentication/layout.tsx
new file mode 100644
index 0000000..1e487ec
--- /dev/null
+++ b/app/(main)/authentication/layout.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+
+export const metadata: Metadata = {
+ title: "Sign In & Sign Up",
+ description:
+ "Sign in to FlagForge to access CTF challenges, track progress, and compete on the leaderboard.",
+};
+
+export default function AuthenticationLayout({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return children;
+}
diff --git a/app/(main)/authentication/page.tsx b/app/(main)/authentication/page.tsx
index 51f91ee..4b607cf 100644
--- a/app/(main)/authentication/page.tsx
+++ b/app/(main)/authentication/page.tsx
@@ -3,9 +3,8 @@ import React, { useEffect, useState } from "react";
import { signIn, useSession } from "next-auth/react";
import { FcGoogle } from "react-icons/fc";
import { useRouter, useSearchParams } from "next/navigation";
-import { Flame, ShieldCheck, Sparkles, Orbit, ArrowRight, AlertCircle, Home as HomeIcon, X, Loader2 } from "lucide-react";
+import { Flame, ShieldCheck, Sparkles, Orbit, ArrowRight, AlertCircle, Home as HomeIcon, X, Loader2, ExternalLink } from "lucide-react";
import Link from "next/link";
-import Loading from "@/components/loading";
const AuthPage = () => {
const router = useRouter();
@@ -47,10 +46,6 @@ const AuthPage = () => {
}
}, [sessionStatus, router, errorStatus, callbackUrl]);
- if (sessionStatus === "loading") {
- return ;
- }
-
if (sessionStatus === "authenticated" && !errorStatus) {
return null;
}
@@ -141,7 +136,7 @@ const AuthPage = () => {
setErrorStatus(null)}
aria-label="Dismiss error"
- className="absolute top-0 right-0 p-1 hover:bg-red-500/10 rounded-lg transition-colors group/btn"
+ className="absolute top-0 right-0 p-1 hover:bg-red-500/10 rounded-lg transition-colors group/btn focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 focus-visible:ring-offset-red-500/10"
>
@@ -222,9 +217,17 @@ const AuthPage = () => {
By signing up, you agree to our
- Privacy Policy
+
+ Privacy Policy
+
+ (opens in new tab)
+
&
- Terms & Conditions
+
+ Terms & Conditions
+
+ (opens in new tab)
+
diff --git a/app/(main)/blogs/[id]/BlogPostClient.tsx b/app/(main)/blogs/[id]/BlogPostClient.tsx
index 412c03d..f0baf30 100644
--- a/app/(main)/blogs/[id]/BlogPostClient.tsx
+++ b/app/(main)/blogs/[id]/BlogPostClient.tsx
@@ -329,8 +329,8 @@ export default function BlogPostClient({
{
-
+
{
{latestRoom ? (
-
+
-
+
{latestRoom.title}
@@ -477,7 +481,7 @@ const Home = () => {
-
+
{
>
{latestRoom.category}
-
-
+
) : (
@@ -610,6 +610,9 @@ const Home = () => {
{/* Scoreboards Section */}
+
+ {/* Instagram Feed */}
+
diff --git a/app/(main)/problems/[id]/page.tsx b/app/(main)/problems/[id]/page.tsx
index be353be..688a58c 100644
--- a/app/(main)/problems/[id]/page.tsx
+++ b/app/(main)/problems/[id]/page.tsx
@@ -78,12 +78,15 @@ const Page = ({ params }: { params: Promise
}) => {
const [isDone, setIsDone] = useState(false);
const [showConfetti, setShowConfetti] = useState(false);
const [isCorrect, setIsCorrect] = useState(false);
+ const [isIncorrect, setIsIncorrect] = useState(false);
const [isExpired, setIsExpired] = useState(false);
const [timeRemaining, setTimeRemaining] = useState(null);
const [showHint, setShowHint] = useState(false);
const [availableHints, setAvailableHints] = useState([]);
const [hintLoading, setHintLoading] = useState(false);
const [usedHints, setUsedHints] = useState([]);
+ const [hintCount, setHintCount] = useState(0);
+ const [practiceMode, setPracticeMode] = useState(false);
const [chatHintStats, setChatHintStats] = useState({
totalPointsDeducted: 0,
totalHintsUsed: 0,
@@ -95,6 +98,7 @@ const Page = ({ params }: { params: Promise }) => {
const submissionInProgress = useRef(false);
const abortController = useRef(null);
+ const isPracticeMode = isDone && practiceMode;
const MIN_SUBMISSION_INTERVAL = 1000;
// URL validation function
@@ -165,6 +169,7 @@ const Page = ({ params }: { params: Promise }) => {
setIsDone(data.isDone);
setIsCorrect(data.isDone);
setUsedHints(data.usedHints || []);
+ setHintCount(typeof data.hintCount === "number" ? data.hintCount : 0);
// Ensure we have proper hints array
const questionData = data.question || {};
@@ -212,6 +217,7 @@ const Page = ({ params }: { params: Promise }) => {
const data = await response.json();
setAvailableHints(data.hints || []);
setUsedHints(data.usedHints || []);
+ setHintCount(Array.isArray(data.hints) ? data.hints.length : 0);
} catch (error) {
console.error("Error fetching hints:", error);
setMessage("Failed to load hints. Please try again.");
@@ -314,18 +320,21 @@ const Page = ({ params }: { params: Promise }) => {
const timeSinceLastSubmission = now - lastSubmissionTime.current;
const flagTrimmed = flag.trim();
- if (submitting || submissionInProgress.current || isCorrect || isExpired) {
- return { allowed: false};
+ if (isCorrect && !isPracticeMode) {
+ return { allowed: false, reason: "You have already solved this problem" };
+ }
+ if (isExpired) {
+ return { allowed: false, reason: "This challenge has expired" };
+ }
+ if (submitting || submissionInProgress.current) {
+ return { allowed: false, reason: "Submission in progress..." };
}
-
if (!flagTrimmed) {
return { allowed: false, reason: "Please enter a flag" };
}
-
if (lastSubmittedFlag.current === flagTrimmed) {
return { allowed: false, reason: "This flag was already submitted" };
}
-
if (timeSinceLastSubmission < MIN_SUBMISSION_INTERVAL) {
return { allowed: false, reason: "Please wait before submitting again" };
}
@@ -335,9 +344,10 @@ const Page = ({ params }: { params: Promise }) => {
flag,
submitting,
isCorrect,
+ isPracticeMode,
isExpired,
MIN_SUBMISSION_INTERVAL
- ]);
+ ]);
const handleSubmit = async () => {
// Check authentication before allowing flag submission
@@ -346,7 +356,12 @@ const Page = ({ params }: { params: Promise }) => {
return;
}
- if (!canSubmit()) {
+ const submissionCheck = canSubmit();
+ if (!submissionCheck.allowed) {
+ if (submissionCheck.reason) {
+ setMessage(submissionCheck.reason);
+ setTimeout(() => setMessage(null), 3000);
+ }
return;
}
@@ -366,7 +381,10 @@ const Page = ({ params }: { params: Promise }) => {
abortController.current = new AbortController();
setMessage(null);
- const response = await fetch(`/api/problems/${unwrappedParams.id}`, {
+ const requestUrl = isPracticeMode
+ ? `/api/problems/${unwrappedParams.id}?practice=true`
+ : `/api/problems/${unwrappedParams.id}`;
+ const response = await fetch(requestUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -382,15 +400,26 @@ const Page = ({ params }: { params: Promise }) => {
const result = await response.json();
if (response.ok) {
+ const isRight =
+ typeof result.correct === "boolean"
+ ? result.correct
+ : result.message?.includes("Right");
setMessage(result.message);
- if (result.message.includes("Right")) {
- setIsCorrect(true);
- setShowConfetti(true);
- setTimeout(() => setShowConfetti(false), 3000);
- setFlag("");
- setTimeout(() => setIsDone(true), 5000);
- setTimeout(() => router.push("/problems"), 8000);
+ if (isRight) {
+ setIsIncorrect(false);
+ if (!isPracticeMode) {
+ setIsCorrect(true);
+ setShowConfetti(true);
+ setTimeout(() => setShowConfetti(false), 3000);
+ setFlag("");
+ setTimeout(() => setIsDone(true), 5000);
+ setTimeout(() => router.push("/problems"), 8000);
+ } else {
+ setFlag("");
+ lastSubmittedFlag.current = "";
+ }
} else {
+ setIsIncorrect(true);
setTimeout(
() => (lastSubmittedFlag.current = ""),
MIN_SUBMISSION_INTERVAL
@@ -398,6 +427,7 @@ const Page = ({ params }: { params: Promise }) => {
}
} else {
setMessage(result.message || "An error occurred");
+ setIsIncorrect(true);
setTimeout(
() => (lastSubmittedFlag.current = ""),
MIN_SUBMISSION_INTERVAL
@@ -425,6 +455,29 @@ const Page = ({ params }: { params: Promise }) => {
}
};
+ const handleFlagChange = (e: React.ChangeEvent) => {
+ setFlag(e.target.value);
+ if (isIncorrect) {
+ setIsIncorrect(false);
+ }
+ };
+
+ const enterPracticeMode = () => {
+ setPracticeMode(true);
+ setFlag("");
+ setIsIncorrect(false);
+ lastSubmittedFlag.current = "";
+ lastSubmissionTime.current = 0;
+ };
+
+ const exitPracticeMode = () => {
+ setPracticeMode(false);
+ setFlag("");
+ setIsIncorrect(false);
+ lastSubmittedFlag.current = "";
+ lastSubmissionTime.current = 0;
+ };
+
useEffect(() => {
return () => {
if (abortController.current) abortController.current.abort();
@@ -438,7 +491,7 @@ const Page = ({ params }: { params: Promise }) => {
const messageTone = useMemo(() => {
if (!message) return "";
- if (message.includes("Right")) {
+ if (message.includes("Right") || message.includes("Correct")) {
return "border-green-200 bg-green-50 text-green-800 dark:border-green-800/60 dark:bg-green-900/20 dark:text-green-200";
}
@@ -453,8 +506,11 @@ const Page = ({ params }: { params: Promise }) => {
return "border-red-200 bg-red-50 text-red-800 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200";
}, [message]);
+ const isSubmissionLocked =
+ submitting || isExpired || (!isPracticeMode && isCorrect);
+
if (loading || sessionStatus === "loading") return ;
-
+
// Allow viewing the problem without authentication
// Authentication will be required only when submitting flags
@@ -549,24 +605,105 @@ const Page = ({ params }: { params: Promise }) => {
/>
)}
- {isDone ? (
+ {isDone && !isPracticeMode ? (
-
-
-
-
- Doubting your skills? Let's return to{" "}
-
- problems
-
-
+
+
+
+
+
+
+
+
+
+
+ Challenge Completed
+
+
+
+
+ {problems.title}
+
+
+ You already solved this challenge. Pick a new one and keep
+ the momentum going.
+
+
+
+
+
+ Points Earned
+
+
+ {problems.points}
+
+
+
+
+ Category
+
+
+ {problems.category}
+
+
+
+
+ Hints Used
+
+
+ {usedHints.length}
+
+
+
+ {problems.expiryDate && (
+
+
+ Time Limited
+
+
+ Expired on: {formatExpiryDate(problems.expiryDate)}
+
+
+ )}
+
+
+ Redo challenge
+
+
+ Back to problems
+
+
+ View leaderboard
+
+
+
+ Redo opens practice mode. Your score stays locked.
+
+
+
+
+
+
+
+ Ready for another challenge? Explore the problem list and
+ push your score higher.
+
+
+
@@ -609,6 +746,11 @@ const Page = ({ params }: { params: Promise
}) => {
Solved
)}
+ {isPracticeMode && (
+
+ Practice mode
+
+ )}
@@ -627,6 +769,28 @@ const Page = ({ params }: { params: Promise
}) => {
+ {isPracticeMode && (
+
+
+
+
+ Practice mode is on
+
+
+ Submissions are checked, but your score will not change.
+
+
+
+ Exit practice
+
+
+
+ )}
+
{(problems.expiryDate || chatHintStats.totalHintsUsed > 0) && (
{problems.expiryDate && (
@@ -750,13 +914,13 @@ const Page = ({ params }: { params: Promise
}) => {
className="h-5 w-5 text-rose-600 dark:text-rose-300"
aria-hidden="true"
/>
- Available Hints ({availableHints.length})
+ Available Hints
- {hintLoading ? "Loading..." : problems.hints?.length || 0}
+ {hintLoading ? "Loading..." : hintCount}
}) => {
) : (
- Click "Use Hint" to reveal this hint
+ {isPracticeMode
+ ? "Hints are locked in practice mode."
+ : 'Click "Use Hint" to reveal this hint'}
)}
- {!isUsed && (
+ {!isUsed && !isPracticeMode && (
requestHint(hintIdx)}
disabled={hintLoading}
@@ -819,6 +985,11 @@ const Page = ({ params }: { params: Promise }) => {
{hintLoading ? "..." : "Use Hint"}
)}
+ {!isUsed && isPracticeMode && (
+
+ Locked
+
+ )}
{isUsed && (
Used
@@ -853,36 +1024,40 @@ const Page = ({ params }: { params: Promise }) => {
Submit Flag
setFlag(e.target.value)}
+ onChange={handleFlagChange}
onKeyPress={handleKeyPress}
- disabled={submitting || isCorrect || isExpired}
+ disabled={isSubmissionLocked}
maxLength={100}
/>
{submitting
? "Submitting..."
- : isCorrect
- ? "Solved!"
- : isExpired
- ? "Expired"
+ : isExpired
+ ? "Expired"
+ : isCorrect && !isPracticeMode
+ ? "Solved!"
: sessionStatus === "unauthenticated"
? "Login to Submit"
- : "Submit"}
+ : isPracticeMode
+ ? "Submit (Practice)"
+ : "Submit"}
{/* Time remaining display */}
diff --git a/app/(main)/problems/page.tsx b/app/(main)/problems/page.tsx
index 47299d2..1346595 100644
--- a/app/(main)/problems/page.tsx
+++ b/app/(main)/problems/page.tsx
@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
-import QustionCards from "@/components/QustionCards";
+import QustionCards from "@/components/QuestionCards";
import Loading from "@/components/loading";
import AuthError from "@/components/authError";
import { IoFilter, IoChevronDown, IoSearch } from "react-icons/io5";
@@ -453,30 +453,37 @@ const PaginationControls: React.FC<{
hasNextPage: boolean;
onPrevious: () => void;
onNext: () => void;
-}> = ({ currentPage, hasNextPage, onPrevious, onNext }) => (
-
-
- Previous
-
-
- Next
-
-
-);
+}> = ({ currentPage, hasNextPage, onPrevious, onNext }) => {
+ const isFirstPage = currentPage === 1;
+ const isLastPage = !hasNextPage;
+
+ return (
+
+ e.preventDefault() : onPrevious}
+ aria-disabled={isFirstPage}
+ title={isFirstPage ? "You are on the first page" : "Go to previous page"}
+ className={`font-semibold text-sm sm:text-base rounded-full px-5 py-2 text-white shadow-sm transition-colors duration-300 ${isFirstPage
+ ? "bg-gray-300 text-gray-500 cursor-not-allowed"
+ : "bg-red-500/90 hover:bg-red-600"
+ }`}
+ >
+ Previous
+
+ e.preventDefault() : onNext}
+ aria-disabled={isLastPage}
+ title={isLastPage ? "You are on the last page" : "Go to next page"}
+ className={`font-semibold text-sm sm:text-base rounded-full px-5 py-2 text-white shadow-sm transition-colors duration-300 ${isLastPage
+ ? "bg-gray-300 text-gray-500 dark:bg-gray-700 dark:text-gray-300 cursor-not-allowed"
+ : "bg-red-500/90 dark:bg-red-500 hover:bg-red-600 dark:hover:bg-red-600"
+ }`}
+ >
+ Next
+
+
+ );
+};
const Page: React.FC = () => {
const { status: sessionStatus } = useSession();
@@ -641,7 +648,8 @@ const Page: React.FC = () => {
// Note: Removed authentication check to allow public browsing
// Authentication will be required only when solving challenges
- const shouldShowPagination = problems.length > 0 || currentPage > 1;
+ const shouldShowPagination =
+ !isSearchActive && (problems.length > 0 || currentPage > 1);
return (
diff --git a/app/(main)/profile/page.tsx b/app/(main)/profile/page.tsx
index efacbf0..6f55e5e 100644
--- a/app/(main)/profile/page.tsx
+++ b/app/(main)/profile/page.tsx
@@ -1239,7 +1239,7 @@ const ProfilePage = () => {
) : (
)}
-
+
{copiedText === item.label ? 'Copied!' : `Copy ${item.label}`}
diff --git a/app/api/instagram/image/route.ts b/app/api/instagram/image/route.ts
new file mode 100644
index 0000000..3c05082
--- /dev/null
+++ b/app/api/instagram/image/route.ts
@@ -0,0 +1,65 @@
+import { NextResponse } from "next/server";
+import sharp from "sharp";
+
+export const runtime = "nodejs";
+
+const isAllowedHost = (hostname: string) => {
+ const host = hostname.toLowerCase();
+ const allowedSuffixes = ["instagram.com", "fbcdn.net", "cdninstagram.com"];
+ return allowedSuffixes.some(
+ (suffix) => host === suffix || host.endsWith(`.${suffix}`)
+ );
+};
+
+export async function GET(request: Request) {
+ const { searchParams } = new URL(request.url);
+ const src = searchParams.get("src");
+
+ if (!src) {
+ return NextResponse.json({ error: "Missing src" }, { status: 400 });
+ }
+
+ let url: URL;
+ try {
+ url = new URL(src);
+ } catch {
+ return NextResponse.json({ error: "Invalid src" }, { status: 400 });
+ }
+
+ if (url.protocol !== "https:" || !isAllowedHost(url.hostname)) {
+ return NextResponse.json({ error: "Invalid image host" }, { status: 400 });
+ }
+
+ try {
+ const response = await fetch(url.toString(), { redirect: "follow" });
+ if (!response.ok) {
+ return NextResponse.json({ error: "Failed to fetch image" }, { status: 502 });
+ }
+
+ const contentType = response.headers.get("content-type") || "";
+ if (!contentType.startsWith("image/")) {
+ return NextResponse.json({ error: "Invalid image response" }, { status: 502 });
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ const buffer = Buffer.from(new Uint8Array(arrayBuffer));
+ let output: Buffer = buffer;
+ let outputType = contentType;
+
+ if (contentType.includes("image/webp")) {
+ output = await sharp(buffer).jpeg({ quality: 85 }).toBuffer();
+ outputType = "image/jpeg";
+ }
+
+ const outputBytes = new Uint8Array(output);
+ return new NextResponse(outputBytes, {
+ headers: {
+ "Content-Type": outputType,
+ "Cache-Control": "public, max-age=3600, must-revalidate",
+ },
+ });
+ } catch (error) {
+ console.error("Instagram image proxy error:", error);
+ return NextResponse.json({ error: "Image proxy failed" }, { status: 502 });
+ }
+}
diff --git a/app/api/instagram/route.ts b/app/api/instagram/route.ts
new file mode 100644
index 0000000..94849c0
--- /dev/null
+++ b/app/api/instagram/route.ts
@@ -0,0 +1,73 @@
+import { NextResponse } from "next/server";
+import fallbackPosts from "@/lib/instagram-data.json";
+
+// Simple in-memory cache to avoid hitting rate limits
+let cache: { data: any; timestamp: number } | null = null;
+const CACHE_DURATION = 3600 * 1000; // 1 hour
+
+const buildFallbackPosts = () =>
+ fallbackPosts.map((post: any) => {
+ const shortcode =
+ post?.id ||
+ (typeof post?.link === "string"
+ ? post.link.match(/\/p\/([^/]+)/)?.[1]
+ : null);
+ const imgUrl = shortcode
+ ? `https://www.instagram.com/p/${shortcode}/media/?size=l`
+ : post.imgUrl;
+ return { ...post, imgUrl };
+ });
+
+export async function GET() {
+ const accessToken = process.env.INSTAGRAM_ACCESS_TOKEN;
+ const businessId = process.env.INSTAGRAM_BUSINESS_ACCOUNT_ID;
+
+ // If credentials are not provided, use fallback data
+ if (!accessToken || !businessId) {
+ console.warn("Instagram credentials missing. Using fallback data.");
+ return NextResponse.json(buildFallbackPosts().slice(0, 3));
+ }
+
+ // Check cache
+ if (cache && Date.now() - cache.timestamp < CACHE_DURATION) {
+ return NextResponse.json(cache.data);
+ }
+
+ try {
+ const response = await fetch(
+ `https://graph.facebook.com/v21.0/${businessId}/media?fields=id,caption,media_type,media_url,permalink,timestamp&access_token=${accessToken}&limit=10`
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ console.error("Instagram Graph API Error:", errorData);
+ throw new Error(errorData.error?.message || "Failed to fetch from Instagram");
+ }
+
+ const { data } = await response.json();
+
+ // Filter for images and map to our internal format
+ const formattedPosts = data
+ .filter((item: any) => item.media_type === "IMAGE" || item.media_type === "CAROUSEL_ALBUM")
+ .slice(0, 3)
+ .map((item: any) => ({
+ id: item.id,
+ link: item.permalink,
+ imgUrl: item.media_url,
+ caption: item.caption || "",
+ timestamp: item.timestamp,
+ }));
+
+ // Update cache
+ cache = {
+ data: formattedPosts,
+ timestamp: Date.now(),
+ };
+
+ return NextResponse.json(formattedPosts);
+ } catch (error) {
+ console.error("Instagram API Route Error:", error);
+ // Fallback to static data if API fails
+ return NextResponse.json(buildFallbackPosts().slice(0, 3));
+ }
+}
diff --git a/app/api/problems/[id]/route.ts b/app/api/problems/[id]/route.ts
index c8a47f0..2f0180d 100644
--- a/app/api/problems/[id]/route.ts
+++ b/app/api/problems/[id]/route.ts
@@ -113,6 +113,11 @@ export async function GET(
);
}
+ const hints = Array.isArray(question.hints) ? question.hints : [];
+ const hintCount = hints.filter((hint: any) => {
+ return hint?.text && String(hint.text).trim() !== "";
+ }).length;
+
const questionData = question.toObject();
delete questionData.flag;
delete questionData.hints; // Remove hints from main data
@@ -135,6 +140,7 @@ export async function GET(
return NextResponse.json({
question: questionData,
+ hintCount,
isDone,
expired,
timeRemaining,
@@ -170,6 +176,7 @@ export async function POST(
// Get the submitted flag from request body
const body = await request.json();
const { flag: submittedFlag } = body;
+ const isPractice = request.nextUrl.searchParams.get("practice") === "true";
if (!submittedFlag || typeof submittedFlag !== "string") {
return createErrorResponse("Flag is required", HttpStatusCode.BadRequest);
@@ -194,8 +201,24 @@ export async function POST(
);
if (userError) return userError;
+ const trimmedSubmittedFlag = submittedFlag.trim();
+ const correctFlag = question.flag.trim();
+
// Check if user has already solved this question
const existingSolution = await checkExistingSolution(user._id, id);
+ if (existingSolution && isPractice) {
+ const isCorrect = trimmedSubmittedFlag === correctFlag;
+ return NextResponse.json(
+ {
+ message: isCorrect
+ ? "Practice mode: Correct flag."
+ : "Practice mode: Incorrect flag. Try again!",
+ correct: isCorrect,
+ practice: true,
+ },
+ { status: HttpStatusCode.Ok }
+ );
+ }
if (existingSolution) {
return NextResponse.json(
{ message: "You have already solved this challenge!" },
@@ -204,9 +227,6 @@ export async function POST(
}
// Check if the submitted flag is correct
- const trimmedSubmittedFlag = submittedFlag.trim();
- const correctFlag = question.flag.trim();
-
if (trimmedSubmittedFlag === correctFlag) {
// Flag is correct - save the solution
try {
@@ -260,6 +280,7 @@ export async function POST(
message: "Right! Congratulations on solving the challenge!",
points: finalPoints,
success: true,
+ correct: true,
},
{ status: HttpStatusCode.Ok }
);
@@ -276,6 +297,7 @@ export async function POST(
{
message: "Incorrect flag. Try again!",
success: false,
+ correct: false,
},
{ status: HttpStatusCode.Ok }
);
diff --git a/app/page.tsx b/app/page.tsx
index 16a3202..bfb6ea8 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -4,7 +4,6 @@ import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Hero from "@/components/Hero";
-import Loading from "@/components/loading";
import JsonLd from "@/components/JsonLd";
import { landingFaqItems } from "@/lib/faq";
@@ -67,18 +66,13 @@ export default function Home() {
]
};
- if (status === "loading") {
- return ;
- }
-
- if (status === "unauthenticated") {
+ if (status !== "authenticated") {
return (
<>
- FlagForge CTF Platform Overview
diff --git a/components/FloatingChat.tsx b/components/FloatingChat.tsx
index 6bb7de9..c807ce3 100644
--- a/components/FloatingChat.tsx
+++ b/components/FloatingChat.tsx
@@ -138,6 +138,7 @@ export default function FloatingChat({
{!isOpen && (
setIsOpen(true)}
+ aria-label="Open hint chat"
className="bg-red-600 hover:bg-red-700 text-white p-4 rounded-full shadow-lg transition-all duration-300 hover:shadow-xl hover:scale-105 cursor-pointer relative"
>
setIsOpen(false)}
+ aria-label="Close hint chat"
className="text-white hover:bg-white hover:bg-opacity-20 rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
+
+
+
+
+ ),
+ color: "hover:text-[#E4405F]",
+ bg: "hover:bg-[#E4405F]/10",
+ },
];
const footerLinks = [
diff --git a/components/InstagramFeed.tsx b/components/InstagramFeed.tsx
new file mode 100644
index 0000000..905c664
--- /dev/null
+++ b/components/InstagramFeed.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { Instagram, ExternalLink, Heart, MessageCircle } from "lucide-react";
+
+interface InstaPost {
+ id: string;
+ link: string;
+ imgUrl: string;
+ caption: string;
+ timestamp: string;
+}
+
+export default function InstagramFeed() {
+ const [posts, setPosts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [fallbackImages, setFallbackImages] = useState>({});
+
+ useEffect(() => {
+ const fetchPosts = async () => {
+ try {
+ const response = await fetch("/api/instagram");
+ if (response.ok) {
+ const data = await response.json();
+ setPosts(data);
+ }
+ } catch (error) {
+ console.error("Failed to fetch instagram posts:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchPosts();
+ }, []);
+
+ if (loading) {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+ }
+
+ if (posts.length === 0) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ Latest from Instagram
+
+
+ Follow @flag.forge for security tips and updates
+
+
+
+
+ View Profile
+
+
+
+
+
+ {posts.map((post) => {
+ const fallbackSrc =
+ fallbackImages[post.id] ??
+ post.imgUrl;
+ const proxySrc = `/api/instagram/image?src=${encodeURIComponent(post.imgUrl)}`;
+
+ return (
+
+
{
+ setFallbackImages((prev) => {
+ if (prev[post.id]) return prev;
+ return { ...prev, [post.id]: proxySrc };
+ });
+ }}
+ />
+
+ {/* Overlay */}
+
+
+
+ Like
+
+
+
+ Comment
+
+
+
+ {/* Caption Gradient */}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/components/Navbar.tsx b/components/Navbar.tsx
index 1d7e637..04a6f26 100644
--- a/components/Navbar.tsx
+++ b/components/Navbar.tsx
@@ -37,7 +37,7 @@ import {
const NavItem = ({ href, tags, onClick, style }: NavbarItems) => {
const isExternal = href.startsWith('http');
-
+
return (
{
className="p-2.5 rounded-xl text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/5 transition-all duration-300 active:scale-90"
aria-label="Toggle dark mode"
>
- {theme === "dark" ? (
-
- ) : (
-
- )}
+
+
+
+
{session.status === "authenticated" ? (
@@ -263,14 +276,31 @@ const Navbar: React.FC = () => {
- {theme === "dark" ? : }
+
+
+
+
-
+
@@ -319,7 +349,7 @@ const Navbar: React.FC = () => {
);
})}
-
+
{/* Show sign in/up button for unauthenticated users */}
{session.status === "unauthenticated" && (
diff --git a/components/QustionCards.tsx b/components/QuestionCards.tsx
similarity index 66%
rename from components/QustionCards.tsx
rename to components/QuestionCards.tsx
index c290b68..f3bebf9 100644
--- a/components/QustionCards.tsx
+++ b/components/QuestionCards.tsx
@@ -17,13 +17,17 @@ const QuestionCards = ({
return (
+ {isDone && (
+
+
+ Completed
+
+ )}
{title}
diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx
index 290e935..87c2dc6 100644
--- a/components/ui/dropdown-menu.tsx
+++ b/components/ui/dropdown-menu.tsx
@@ -27,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
{
- const lines = [
- 'User-agent: *',
- 'Allow: /',
- 'Allow: /llms.txt',
- 'Disallow:',
- '',
+ const disallowRules = SITEMAP_EXCLUDE.map((path) => `Disallow: ${path}`);
+
+ const sitemapRules = [
`Sitemap: ${config.siteUrl}/sitemap.xml`,
`Sitemap: ${config.siteUrl}/sitemap1.xml`,
`Sitemap: ${config.siteUrl}/sitemap.txt`,
- '',
];
- return lines.join('\n');
+ const customRules = [
+ 'User-agent: *',
+ 'Allow: /llms.txt',
+ ...disallowRules,
+ ];
+
+ return [...customRules, '', ...sitemapRules].join('\n');
},
},
};
diff --git a/next.config.mjs b/next.config.mjs
index b60c879..d6d4923 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
+ domains: ["writeup.flagforge.xyz", "flagforge.xyz", "github.com"],
remotePatterns: [
{
protocol: "https",
@@ -67,7 +68,7 @@ const nextConfig = {
"default-src 'self'; " +
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://pagead2.googlesyndication.com https://www.googletagmanager.com https://accounts.google.com https://va.vercel-scripts.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
- "img-src 'self' https://lh3.googleusercontent.com https://prod-files-secure.s3.us-west-2.amazonaws.com data: https://pagead2.googlesyndication.com https://www.google.com; " +
+ "img-src 'self' https://lh3.googleusercontent.com https://prod-files-secure.s3.us-west-2.amazonaws.com data: https://pagead2.googlesyndication.com https://www.google.com https://www.instagram.com https://*.cdninstagram.com https://*.fbcdn.net; " +
"font-src 'self' https://fonts.gstatic.com data:; " +
"connect-src 'self' https://pagead2.googlesyndication.com https://www.google-analytics.com https://stats.g.doubleclick.net https://*.vercel-analytics.com; " +
"frame-src 'self' https://googleads.g.doubleclick.net https://accounts.google.com; " +
@@ -90,7 +91,7 @@ const nextConfig = {
},
{
key: "Permissions-Policy",
- value: "geolocation=(), microphone=(), camera=(), payment=()",
+ value: "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()",
},
{
key: "Cache-Control",
diff --git a/utils/data.ts b/utils/data.ts
index d59e4d1..142d124 100644
--- a/utils/data.ts
+++ b/utils/data.ts
@@ -103,4 +103,4 @@ export const initialQuestion: Questions = {
flag: "",
isSolved: false,
done: false
-};
\ No newline at end of file
+};