├── src ├── app │ ├── teams │ │ ├── page_new.tsx │ │ ├── utils │ │ │ ├── timeExample.ts │ │ │ └── timeUtils.ts │ │ ├── components │ │ │ ├── VipFilters.tsx │ │ │ ├── VipFilters_new.tsx │ │ │ ├── VipHeader_new.tsx │ │ │ ├── index.ts │ │ │ ├── TeamGrid.tsx │ │ │ ├── LoadMore.tsx │ │ │ └── AccessDenied.tsx │ │ ├── teams.component.tsx │ │ ├── teams.types.tsx │ │ └── teams.constants.tsx │ ├── progress │ │ ├── progress.buttons.tsx │ │ └── progress.types.tsx │ ├── page.tsx │ ├── api │ │ ├── logout │ │ │ └── route.ts │ │ ├── auth │ │ │ ├── type.auth.ts │ │ │ ├── verify │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── notifications │ │ │ ├── notifications.types.ts │ │ │ ├── mark-seen │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── create │ │ │ │ └── route.ts │ │ ├── progress │ │ │ └── progress.types.ts │ │ ├── database │ │ │ ├── chat_reactions.sql │ │ │ ├── chat.sql │ │ │ ├── notifications.sql │ │ │ ├── instructions.sql │ │ │ └── chat_moderation.sql │ │ ├── projects │ │ │ └── route.ts │ │ ├── user-badges │ │ │ └── route.ts │ │ ├── check-admin │ │ │ └── route.ts │ │ ├── feedback │ │ │ ├── rateLimit.ts │ │ │ └── feedback.types.ts │ │ ├── rate-limit-stats │ │ │ └── route.ts │ │ ├── slots │ │ │ └── slots.types.ts │ │ ├── check-creator │ │ │ └── route.ts │ │ ├── vip-users │ │ │ └── route.ts │ │ ├── rate-limit-bans │ │ │ └── route.ts │ │ ├── cv-maker │ │ │ └── route.ts │ │ ├── chat │ │ │ └── route.ts │ │ └── who │ │ │ └── route.ts │ ├── dashboard │ │ ├── feedback-reviews │ │ │ └── feedback-reviews.types.ts │ │ └── page.tsx │ ├── layout.tsx │ ├── globals.css │ └── evaluations │ │ └── evaluations.types.ts ├── component │ ├── dashboard │ │ ├── dashboard.imports.tsx │ │ ├── dashboard.types.tsx │ │ └── FeedbackPopup.types.ts │ ├── navbar │ │ ├── navbar.imports.tsx │ │ ├── navbar.buttons.tsx │ │ └── navbar.types.tsx │ ├── context │ │ ├── context.tsx │ │ └── context.types.tsx │ ├── hooks │ │ └── useRateLimitHandler.ts │ ├── feedback │ │ └── GlobalFeedbackButton.tsx │ ├── layout │ │ └── layout.tsx │ ├── RateLimitPopup.tsx │ ├── badges │ │ └── UserBadges.tsx │ ├── landing │ │ ├── landing.tsx │ │ └── particels.tsx │ └── BannedUsersPopup.tsx ├── middleware.ts └── utils │ ├── profileCache.ts │ └── moderationUtils.ts ├── public ├── cat.png ├── muh.png ├── nopic.jpg ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg ├── next.svg ├── test-auth.html └── socket-test.html ├── postcss.config.mjs ├── next-env.d.ts ├── pages └── api │ └── socket.types.ts ├── eslint.config.mjs ├── next.config.ts ├── restart-server.sh ├── .gitignore ├── tsconfig.json ├── scripts ├── insert-initial-notifications.sh ├── clean-bad-words.js ├── test-moderation.js ├── insert-notifications-api.js ├── debug-words.js ├── insert-notifications.sql ├── quick-test.js ├── migrate-reactions.js └── migrate-moderation.js ├── SETUP_REACTIONS.sql ├── setup-chat-db.sh ├── package.json ├── apiText ├── RATE_LIMITING.md ├── diagnose-socket.sh ├── diagnose-socketio.sh └── secure-chat-server.js /src/app/teams/page_new.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/progress/progress.buttons.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/teams/utils/timeExample.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/teams/components/VipFilters.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/teams/components/VipFilters_new.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/teams/components/VipHeader_new.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/component/dashboard/dashboard.imports.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/cat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohammed-Maghri/The-Official-New-Leets/HEAD/public/cat.png -------------------------------------------------------------------------------- /public/muh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohammed-Maghri/The-Official-New-Leets/HEAD/public/muh.png -------------------------------------------------------------------------------- /public/nopic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohammed-Maghri/The-Official-New-Leets/HEAD/public/nopic.jpg -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | export default config; 5 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { LandingComponent } from "@/component/landing/landing"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /pages/api/socket.types.ts: -------------------------------------------------------------------------------- 1 | import { Server as NetServer, Socket } from "net"; 2 | import { NextApiResponse } from "next"; 3 | import { Server as SocketIOServer } from "socket.io"; 4 | 5 | export type NextApiResponseServerIO = NextApiResponse & { 6 | socket: Socket & { 7 | server: NetServer & { 8 | io: SocketIOServer; 9 | }; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/teams/components/index.ts: -------------------------------------------------------------------------------- 1 | export { TeamsHeader } from './TeamsHeader'; 2 | export { TeamCard } from './TeamCard'; 3 | export { TeamGrid } from './TeamGrid'; 4 | export { LoadMore } from './LoadMore'; 5 | export { AccessDenied } from './AccessDenied'; 6 | export { default as TeamsAdmin } from './TeamsAdmin'; 7 | export { default as FeedbackReviews } from './FeedbackReviews'; 8 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: 'https', 8 | hostname: 'cdn.intra.42.fr', 9 | port: '', 10 | pathname: '/**', 11 | }, 12 | { 13 | protocol: 'https', 14 | hostname: 'avatars.githubusercontent.com', 15 | port: '', 16 | pathname: '/**', 17 | }, 18 | ], 19 | }, 20 | }; 21 | 22 | export default nextConfig; 23 | -------------------------------------------------------------------------------- /src/component/navbar/navbar.imports.tsx: -------------------------------------------------------------------------------- 1 | import { GiRank3 } from "react-icons/gi"; 2 | import { RiVipCrown2Line } from "react-icons/ri"; 3 | import { TbMoodCrazyHappy } from "react-icons/tb"; 4 | import { CiCalculator1 } from "react-icons/ci"; 5 | import { FaUsersViewfinder } from "react-icons/fa6"; 6 | import { CiMenuFries } from "react-icons/ci"; 7 | import { MdAssessment } from "react-icons/md"; 8 | 9 | export { CiMenuFries, GiRank3, RiVipCrown2Line, TbMoodCrazyHappy, CiCalculator1, FaUsersViewfinder, MdAssessment }; 10 | -------------------------------------------------------------------------------- /restart-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "🔄 Restarting Next.js dev server..." 4 | echo "" 5 | 6 | # Kill any existing Next.js processes 7 | echo "🛑 Stopping existing server..." 8 | pkill -f "next dev" 2>/dev/null || echo " No running server found" 9 | 10 | # Wait a moment 11 | sleep 2 12 | 13 | # Clear terminal 14 | clear 15 | 16 | echo "✅ Server stopped" 17 | echo "" 18 | echo "🚀 Starting fresh server..." 19 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 20 | echo "" 21 | 22 | # Start dev server 23 | npm run dev 24 | -------------------------------------------------------------------------------- /src/app/api/logout/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { cookies } from "next/headers"; 3 | 4 | export const GET = async () => { 5 | try { 6 | const response = NextResponse.json({ message: "Logged out successfully" }); 7 | (await cookies()).set("auth_code", "" as string, { 8 | httpOnly: true, 9 | path: "/", 10 | maxAge: -1, 11 | }); 12 | return response; 13 | } catch (error) { 14 | console.error("Logout error:", error); 15 | return NextResponse.json({ error: "Failed to log out" }, { status: 500 }); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/component/context/context.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { ContextProps, UserData } from "./context.types"; 4 | 5 | const ContextCreator = React.createContext(null); 6 | 7 | const ContextProvider = ({ children }: { children: React.ReactNode }) => { 8 | const [userData, setUserData] = React.useState(null); 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | }; 15 | 16 | export { ContextProvider, ContextCreator }; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/component/dashboard/dashboard.types.tsx: -------------------------------------------------------------------------------- 1 | let users: string[] = []; 2 | 3 | const fetchVIPUsers = async () => { 4 | if (typeof window === 'undefined') { 5 | return; 6 | } 7 | 8 | try { 9 | const response = await fetch("/api/vip-users"); 10 | if (response.ok) { 11 | const data = await response.json(); 12 | users = data.vipUsers || []; 13 | } else { 14 | console.error("Failed to fetch VIP users"); 15 | } 16 | } catch (error) { 17 | console.error("Error fetching VIP users:", error); 18 | } 19 | }; 20 | 21 | if (typeof window !== 'undefined') { 22 | fetchVIPUsers(); 23 | } 24 | 25 | export { users, fetchVIPUsers }; 26 | -------------------------------------------------------------------------------- /src/app/api/auth/type.auth.ts: -------------------------------------------------------------------------------- 1 | import CryptoJS from "crypto-js"; 2 | 3 | interface AuthResponse { 4 | access_token: string; 5 | } 6 | 7 | const EncryptionFunction = (token: string) => { 8 | const ciphertext = CryptoJS.AES.encrypt( 9 | token, 10 | process.env.SECRET_KEY as string 11 | ).toString(); 12 | return ciphertext; 13 | }; 14 | 15 | const DecryptionFunction = (ciphertext: string) => { 16 | const bytes = CryptoJS.AES.decrypt( 17 | ciphertext, 18 | process.env.SECRET_KEY as string 19 | ); 20 | const originalText = bytes.toString(CryptoJS.enc.Utf8); 21 | return originalText; 22 | }; 23 | 24 | 25 | export type { AuthResponse }; 26 | export { EncryptionFunction, DecryptionFunction }; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/notifications/notifications.types.ts: -------------------------------------------------------------------------------- 1 | export interface Notification { 2 | id: number; 3 | title: string; 4 | message: string; 5 | type: 'info' | 'success' | 'warning' | 'error'; 6 | target_type: 'all' | 'specific'; 7 | target_user_id: number | null; 8 | sender_username: string; 9 | sender_image: string; 10 | created_at: string; 11 | expires_at: string | null; 12 | link: string | null; 13 | is_seen?: boolean; // Added on frontend after checking read status 14 | } 15 | 16 | export interface NotificationRead { 17 | id: number; 18 | notification_id: number; 19 | user_id: number; 20 | seen_at: string; 21 | } 22 | 23 | export interface NotificationResponse { 24 | notifications: Notification[]; 25 | unread_count: number; 26 | } 27 | -------------------------------------------------------------------------------- /scripts/insert-initial-notifications.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to insert initial notifications 4 | # Run this after setting up the database 5 | 6 | echo "📢 Inserting notifications..." 7 | 8 | # Check if DATABASE_URL is set 9 | if [ -z "$DATABASE_URL" ]; then 10 | echo "❌ Error: DATABASE_URL environment variable is not set" 11 | exit 1 12 | fi 13 | 14 | # Run the SQL script 15 | psql "$DATABASE_URL" -f scripts/insert-notifications.sql 16 | 17 | if [ $? -eq 0 ]; then 18 | echo "✅ Notifications inserted successfully!" 19 | echo "" 20 | echo "Notifications added:" 21 | echo "1. 🎉 New Features announcement (all users)" 22 | echo "2. 🏆 Feedback badges (specific users who submitted feedback)" 23 | else 24 | echo "❌ Failed to insert notifications" 25 | exit 1 26 | fi 27 | -------------------------------------------------------------------------------- /src/app/api/progress/progress.types.ts: -------------------------------------------------------------------------------- 1 | const Month = [ 2 | "0", 3 | "january", 4 | "february", 5 | "march", 6 | "april", 7 | "may", 8 | "june", 9 | "july", 10 | "august", 11 | "september", 12 | "october", 13 | "november", 14 | "december", 15 | ]; 16 | 17 | interface UserProgress { 18 | user: { 19 | email: string; 20 | login: string; 21 | kind: string; 22 | image: { 23 | versions: { 24 | medium: string; 25 | }; 26 | }; 27 | usual_full_name: string; 28 | staff: boolean; 29 | correction_point: number; 30 | pool_month: string; 31 | pool_year: string; 32 | location: string | null; 33 | wallet: number; 34 | }; 35 | level: number; 36 | } 37 | export { Month }; 38 | export type { UserProgress }; 39 | -------------------------------------------------------------------------------- /SETUP_REACTIONS.sql: -------------------------------------------------------------------------------- 1 | -- Run this SQL to set up the reactions system 2 | 3 | -- Step 1: Create the reactions table 4 | CREATE TABLE IF NOT EXISTS leets.chat_reactions ( 5 | id SERIAL PRIMARY KEY, 6 | message_id TEXT NOT NULL, 7 | username TEXT NOT NULL, 8 | emoji TEXT NOT NULL, 9 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 10 | UNIQUE(message_id, username, emoji), 11 | FOREIGN KEY (message_id) REFERENCES leets.chat_messages(message_id) ON DELETE CASCADE 12 | ); 13 | 14 | -- Step 2: Create indexes for better performance 15 | CREATE INDEX IF NOT EXISTS idx_chat_reactions_message_id ON leets.chat_reactions(message_id); 16 | CREATE INDEX IF NOT EXISTS idx_chat_reactions_username ON leets.chat_reactions(username); 17 | 18 | -- Step 3: Verify the table was created 19 | SELECT 'Reactions table created successfully!' as status; 20 | -------------------------------------------------------------------------------- /src/app/dashboard/feedback-reviews/feedback-reviews.types.ts: -------------------------------------------------------------------------------- 1 | export interface FeedbackReview { 2 | id: number; 3 | user_login: string; 4 | user_email: string; 5 | user_image: string; 6 | campus_id: number; 7 | campus_name: string; 8 | feedback: string | null; 9 | dislikes: string | null; 10 | improvements: string | null; 11 | rating: number; 12 | wants_to_contribute: boolean; 13 | skills: string | null; 14 | contribution_area: string[] | null; 15 | badge_awarded: boolean; 16 | badge_type: string | null; 17 | created_at: string; 18 | } 19 | 20 | export const BADGE_TYPES = { 21 | GENIUS: "Genius", 22 | HELPFUL: "Helpful", 23 | INNOVATIVE: "Innovative", 24 | CRITICAL_THINKER: "Critical Thinker", 25 | CONTRIBUTOR: "Contributor", 26 | VIP: "VIP", 27 | } as const; 28 | 29 | export type BadgeType = typeof BADGE_TYPES[keyof typeof BADGE_TYPES]; 30 | -------------------------------------------------------------------------------- /src/app/teams/teams.component.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { FaArrowsSpin } from "react-icons/fa6"; 4 | 5 | const LaoderComp: React.FC = () => { 6 | return ( 7 |
8 |
9 |
10 | 11 |
12 |
13 |
14 |
15 |

Loading teams...

16 |

This may take a few seconds due to 42 API response time

17 |
18 |
19 | ); 20 | }; 21 | 22 | export { LaoderComp }; 23 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import type { NextRequest } from "next/server"; 3 | import { cookies } from "next/headers"; 4 | 5 | export async function middleware(request: NextRequest) { 6 | const paths: string[] = [ 7 | "/dashboard", 8 | "/progress", 9 | "/calculator", 10 | "/peerfinder", 11 | "/teams", 12 | "/evaluations", 13 | ]; 14 | const cookie = (await cookies()).get("auth_code"); 15 | if (request.nextUrl.pathname === "/") { 16 | if (cookie?.value) { 17 | return NextResponse.redirect(new URL("/dashboard", request.url)); 18 | } 19 | return NextResponse.next(); 20 | } else if (paths.includes(request.nextUrl.pathname)) { 21 | if (!cookie?.value) { 22 | return NextResponse.redirect(new URL("/", request.url)); 23 | } 24 | return NextResponse.next(); 25 | } 26 | return NextResponse.next(); 27 | } 28 | 29 | export const config = { 30 | matcher: ["/:path*"], 31 | }; 32 | -------------------------------------------------------------------------------- /src/component/context/context.types.tsx: -------------------------------------------------------------------------------- 1 | import { UserData } from "../navbar/navbar.types"; 2 | import { Socket } from "socket.io-client"; 3 | 4 | interface Message { 5 | id: string; 6 | message: string; 7 | username: string; 8 | avatar: string; 9 | level: number; 10 | campus: string; 11 | timestamp: number; 12 | reactions?: Array<{ 13 | emoji: string; 14 | count: number; 15 | users: string[]; 16 | }>; 17 | flagged?: boolean; 18 | moderationCategories?: string[]; 19 | moderationSeverity?: string; 20 | } 21 | 22 | interface ContextProps { 23 | setUserData: React.Dispatch>; 24 | userData: UserData | null; 25 | socket?: Socket; 26 | isSocketConnected?: boolean; 27 | messages?: Message[]; 28 | hasMore?: boolean; 29 | isLoadingMessages?: boolean; 30 | isLoadingMore?: boolean; 31 | setIsLoadingMore?: React.Dispatch>; 32 | } 33 | 34 | export type { ContextProps, UserData, Message }; 35 | -------------------------------------------------------------------------------- /src/component/navbar/navbar.buttons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { FC } from "react"; 3 | import { ButtonsProps } from "./navbar.types"; 4 | import { motion } from "motion/react"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | const Buttons: FC = ({ icon, title, route }) => { 8 | const router = useRouter(); 9 | return ( 10 | router.push(route)} 12 | initial={{ opacity: 0 }} 13 | animate={{ opacity: 1 }} 14 | transition={{ duration: 1 }} 15 | className="flex bg-blue-950/20 border-solid border-[1px] border-blue-800/40 16 | w-[120px] cursor-pointer rounded-lg hover:border-blue-700/60 hover:bg-blue-950/30 17 | duration-200 items-center justify-center gap-1 h-[30px]" 18 | > 19 |
{icon}
20 |

{title}

21 |
22 | ); 23 | }; 24 | 25 | export { Buttons }; 26 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup-chat-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Chat Database Setup Script 4 | # This script creates the chat_messages table in your PostgreSQL database 5 | 6 | echo "🚀 Setting up Chat Messages Database..." 7 | 8 | # Load DATABASE_KEY from .env file 9 | if [ -f .env ]; then 10 | export $(grep -v '^#' .env | xargs) 11 | else 12 | echo "❌ Error: .env file not found" 13 | exit 1 14 | fi 15 | 16 | if [ -z "$DATABASE_KEY" ]; then 17 | echo "❌ Error: DATABASE_KEY not found in .env" 18 | exit 1 19 | fi 20 | 21 | echo "📦 DATABASE_KEY found" 22 | echo "🔧 Executing SQL script..." 23 | 24 | # Execute the SQL script 25 | psql "$DATABASE_KEY" -f src/app/api/database/chat.sql 26 | 27 | if [ $? -eq 0 ]; then 28 | echo "✅ Chat messages table created successfully!" 29 | echo "" 30 | echo "📊 Verifying table creation..." 31 | psql "$DATABASE_KEY" -c "SELECT COUNT(*) as message_count FROM leets.chat_messages;" 32 | echo "" 33 | echo "🎉 Setup complete! Your chat database is ready." 34 | else 35 | echo "❌ Error: Failed to execute SQL script" 36 | exit 1 37 | fi 38 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import { Layout } from "@/component/layout/layout"; 4 | import { ContextProvider } from "@/component/context/context"; 5 | import GlobalFeedbackButton from "@/component/feedback/GlobalFeedbackButton"; 6 | 7 | export const metadata: Metadata = { 8 | title: "1337Leets", 9 | description: 10 | "Check your progress, find peers, and use various tools to enhance your 1337Leets experience.", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode; 17 | }>) { 18 | return ( 19 | 20 | 24 | 25 | {children} 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /scripts/clean-bad-words.js: -------------------------------------------------------------------------------- 1 | // Script to remove all patterns with asterisks from bad_words.json 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const badWordsPath = path.join(__dirname, '../bad_words.json'); 6 | const badWordsData = JSON.parse(fs.readFileSync(badWordsPath, 'utf-8')); 7 | 8 | let totalRemoved = 0; 9 | 10 | // Process each category 11 | Object.keys(badWordsData).forEach(category => { 12 | const originalLength = badWordsData[category].length; 13 | 14 | // Filter out patterns containing asterisks 15 | badWordsData[category] = badWordsData[category].filter(word => { 16 | return !word.includes('*'); 17 | }); 18 | 19 | const removed = originalLength - badWordsData[category].length; 20 | if (removed > 0) { 21 | console.log(`${category}: Removed ${removed} patterns with asterisks`); 22 | totalRemoved += removed; 23 | } 24 | }); 25 | 26 | // Write back to file 27 | fs.writeFileSync(badWordsPath, JSON.stringify(badWordsData, null, 2)); 28 | 29 | console.log(`\n✅ Total removed: ${totalRemoved} patterns`); 30 | console.log('✅ Cleaned bad_words.json saved!'); 31 | -------------------------------------------------------------------------------- /src/component/hooks/useRateLimitHandler.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState, useCallback } from "react"; 3 | 4 | interface RateLimitState { 5 | isRateLimited: boolean; 6 | retryAfter: number; 7 | } 8 | 9 | export const useRateLimitHandler = () => { 10 | const [rateLimitState, setRateLimitState] = useState({ 11 | isRateLimited: false, 12 | retryAfter: 0, 13 | }); 14 | 15 | const handleRateLimitResponse = useCallback(async (response: Response) => { 16 | if (response.status === 429) { 17 | const data = await response.json().catch(() => ({})); 18 | 19 | if (data.showPopup) { 20 | setRateLimitState({ 21 | isRateLimited: true, 22 | retryAfter: data.retryAfter || 60, 23 | }); 24 | } 25 | 26 | return true; 27 | } 28 | return false; 29 | }, []); 30 | 31 | const closeRateLimitPopup = useCallback(() => { 32 | setRateLimitState({ 33 | isRateLimited: false, 34 | retryAfter: 0, 35 | }); 36 | }, []); 37 | 38 | return { 39 | rateLimitState, 40 | handleRateLimitResponse, 41 | closeRateLimitPopup, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/app/api/database/chat_reactions.sql: -------------------------------------------------------------------------------- 1 | -- Create chat_reactions table for Discord-style reactions 2 | CREATE TABLE IF NOT EXISTS leets.chat_reactions ( 3 | id SERIAL PRIMARY KEY, 4 | message_id TEXT NOT NULL, 5 | username TEXT NOT NULL, 6 | emoji TEXT NOT NULL, 7 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 8 | UNIQUE(message_id, username, emoji), 9 | FOREIGN KEY (message_id) REFERENCES leets.chat_messages(message_id) ON DELETE CASCADE 10 | ); 11 | 12 | CREATE INDEX IF NOT EXISTS idx_chat_reactions_message_id ON leets.chat_reactions(message_id); 13 | CREATE INDEX IF NOT EXISTS idx_chat_reactions_username ON leets.chat_reactions(username); 14 | 15 | -- Function to get reaction counts for a message 16 | CREATE OR REPLACE FUNCTION leets.get_message_reactions(msg_id TEXT) 17 | RETURNS TABLE(emoji TEXT, count BIGINT, users TEXT[]) AS $$ 18 | BEGIN 19 | RETURN QUERY 20 | SELECT 21 | r.emoji, 22 | COUNT(*)::BIGINT as count, 23 | ARRAY_AGG(r.username) as users 24 | FROM leets.chat_reactions r 25 | WHERE r.message_id = msg_id 26 | GROUP BY r.emoji 27 | ORDER BY count DESC, emoji; 28 | END; 29 | $$ LANGUAGE plpgsql; 30 | -------------------------------------------------------------------------------- /src/app/api/database/chat.sql: -------------------------------------------------------------------------------- 1 | -- Create chat_messages table 2 | CREATE TABLE IF NOT EXISTS leets.chat_messages ( 3 | id SERIAL PRIMARY KEY, 4 | message_id TEXT NOT NULL UNIQUE, 5 | message TEXT NOT NULL, 6 | username TEXT NOT NULL, 7 | avatar TEXT NOT NULL, 8 | level NUMERIC DEFAULT 0, 9 | campus TEXT DEFAULT 'Unknown', 10 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | -- Create index on created_at for faster queries 14 | CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON leets.chat_messages(created_at DESC); 15 | 16 | -- Create index on username for faster filtering 17 | CREATE INDEX IF NOT EXISTS idx_chat_messages_username ON leets.chat_messages(username); 18 | 19 | -- Optional: Add a function to auto-delete messages older than 24 hours 20 | CREATE OR REPLACE FUNCTION leets.cleanup_old_messages() 21 | RETURNS void AS $$ 22 | BEGIN 23 | DELETE FROM leets.chat_messages 24 | WHERE created_at < NOW() - INTERVAL '24 hours'; 25 | END; 26 | $$ LANGUAGE plpgsql; 27 | 28 | -- Optional: Create a scheduled job to run cleanup (requires pg_cron extension) 29 | -- SELECT cron.schedule('cleanup-old-chat-messages', '0 * * * *', 'SELECT leets.cleanup_old_messages()'); 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leets", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@emotion/react": "^11.14.0", 13 | "@emotion/styled": "^11.14.1", 14 | "@mui/material": "^7.2.0", 15 | "crypto-js": "^4.2.0", 16 | "jose": "^6.0.11", 17 | "motion": "^12.23.1", 18 | "next": "15.3.8", 19 | "node-jose": "^2.2.0", 20 | "ogl": "^1.0.11", 21 | "pg": "^8.16.3", 22 | "postprocessing": "^6.37.6", 23 | "react": "^19.0.0", 24 | "react-dom": "^19.0.0", 25 | "react-icons": "^5.5.0", 26 | "socket.io": "^4.8.1", 27 | "socket.io-client": "^4.8.1", 28 | "three": "^0.178.0", 29 | "zod": "^4.1.12" 30 | }, 31 | "devDependencies": { 32 | "@eslint/eslintrc": "^3", 33 | "@tailwindcss/postcss": "^4", 34 | "@types/crypto-js": "^4.2.2", 35 | "@types/node": "^20", 36 | "@types/node-jose": "^1.1.13", 37 | "@types/pg": "^8.15.4", 38 | "@types/react": "^19", 39 | "@types/react-dom": "^19", 40 | "eslint": "^9", 41 | "eslint-config-next": "15.3.4", 42 | "tailwindcss": "^4", 43 | "typescript": "^5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/auth/verify/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { jwtVerify } from "jose"; 3 | 4 | // Use the same SECRET_KEY as auth/route.ts 5 | const JWT_SECRET = new TextEncoder().encode( 6 | process.env.SECRET_KEY as string 7 | ); 8 | 9 | export async function GET(request: NextRequest) { 10 | try { 11 | // Get auth_code from httpOnly cookie (server can read this) 12 | const authToken = request.cookies.get("auth_code")?.value; 13 | 14 | if (authToken) { 15 | } 16 | 17 | if (!authToken) { 18 | return NextResponse.json( 19 | { error: "Not authenticated" }, 20 | { status: 401 } 21 | ); 22 | } 23 | 24 | // Verify JWT token 25 | const { payload } = await jwtVerify(authToken, JWT_SECRET); 26 | 27 | // Return token and user data 28 | return NextResponse.json({ 29 | token: authToken, 30 | userData: payload.userData, 31 | }); 32 | } catch (error) { 33 | console.error("❌ Auth verification failed:"); 34 | const errorMessage = error instanceof Error ? error.message : String(error); 35 | console.error(" - Error type:", error instanceof Error ? error.constructor.name : typeof error); 36 | console.error(" - Error message:", errorMessage); 37 | console.error(" - Full error:", error); 38 | return NextResponse.json( 39 | { error: "Invalid authentication", details: errorMessage }, 40 | { status: 401 } 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/api/projects/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import * as jose from "jose"; 3 | import { DecryptionFunction } from "../auth/type.auth"; 4 | import { rateLimit, RateLimitPresets } from "@/utils/rateLimit"; 5 | 6 | export const GET = async (request: NextRequest) => { 7 | // Rate limiting: 20 requests per minute (makes 42 API calls) 8 | const rateLimitResult = await rateLimit(request, RateLimitPresets.STRICT); 9 | if (rateLimitResult) return rateLimitResult; 10 | 11 | try { 12 | const Cookie = request.cookies.get("auth_code")?.value; 13 | const data = DecryptionFunction( 14 | jose.decodeJwt(Cookie as string).token as string 15 | ); 16 | 17 | const projects = await fetch( 18 | `${process.env.INTRA_TOKEN as string}/v2/me/projects?page[size]=100&page[number]=2`, 19 | { 20 | method: "GET", 21 | headers: { 22 | "Content-Type": "application/json", 23 | Authorization: `Bearer ${data}`, 24 | }, 25 | } 26 | ); 27 | if (!projects.ok) { 28 | return NextResponse.json( 29 | { error: "Failed to fetch projects" }, 30 | { status: 500 } 31 | ); 32 | } 33 | const projectsData = await projects.json(); 34 | return NextResponse.json(projectsData, { 35 | status: 200, 36 | }); 37 | } catch (error) { 38 | console.error("Error in GET request:", error); 39 | return NextResponse.json( 40 | { error: "Internal Server Error" }, 41 | { status: 500 } 42 | ); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/component/navbar/navbar.types.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | GiRank3, 3 | FaUsersViewfinder, 4 | RiVipCrown2Line, 5 | CiCalculator1, 6 | MdAssessment, 7 | } from "./navbar.imports"; 8 | 9 | interface UserData { 10 | fullname?: string; 11 | level: number; 12 | email: string; 13 | login: string; 14 | kind: string; 15 | image: string; 16 | staff: boolean; 17 | correction_point: number; 18 | pool_month: string; 19 | pool_year: string; 20 | location: string | null; 21 | wallet: number; 22 | campus_id: number; 23 | campus_name: string; 24 | badge?: { 25 | type: 'creator' | 'vip' | 'feedback'; 26 | name: string; 27 | } | null; 28 | } 29 | 30 | interface ButtonsProps { 31 | icon: React.ReactNode; 32 | title: string; 33 | route: string; 34 | } 35 | 36 | const PathsObject: ButtonsProps[] = [ 37 | { 38 | icon: , 39 | title: "Teams", 40 | route: "/teams", 41 | }, 42 | { 43 | icon: , 44 | title: "Evaluations", 45 | route: "/evaluations", 46 | }, 47 | { 48 | icon: , 49 | title: "Calculator", 50 | route: "/calculator", 51 | }, 52 | { 53 | icon: , 54 | title: "Peer-finder", 55 | route: "/peerfinder", 56 | }, 57 | { 58 | icon: , 59 | title: "Rank", 60 | route: "/progress", 61 | }, 62 | ]; 63 | 64 | export type { ButtonsProps, UserData }; 65 | export { PathsObject }; 66 | -------------------------------------------------------------------------------- /src/app/api/user-badges/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { Pool } from "pg"; 3 | 4 | export async function GET(req: Request) { 5 | let client; 6 | 7 | try { 8 | const { searchParams } = new URL(req.url); 9 | const login = searchParams.get("login"); 10 | 11 | if (!login) { 12 | return NextResponse.json( 13 | { error: "Login parameter is required" }, 14 | { status: 400 } 15 | ); 16 | } 17 | 18 | client = new Pool({ connectionString: process.env.DATABASE_KEY }); 19 | 20 | // Get VIP status and role (member, vip, creator) 21 | const vipQuery = `SELECT token FROM leets.vip WHERE login = $1`; 22 | const vipResult = await client.query(vipQuery, [login]); 23 | 24 | const vipStatus = vipResult.rows.length > 0 ? vipResult.rows[0].token : null; 25 | 26 | // Get feedback badges 27 | const badgeQuery = ` 28 | SELECT badge_type 29 | FROM leets.feedback 30 | WHERE user_login = $1 AND badge_awarded = TRUE 31 | ORDER BY created_at DESC 32 | `; 33 | const badgeResult = await client.query(badgeQuery, [login]); 34 | 35 | const badges = badgeResult.rows.map(row => row.badge_type); 36 | 37 | return NextResponse.json( 38 | { 39 | success: true, 40 | login, 41 | vipStatus, // 'creator', 'vip', or null 42 | badges, // Array of badge types 43 | }, 44 | { status: 200 } 45 | ); 46 | 47 | } catch (error) { 48 | console.error("Error fetching user badges:", error); 49 | return NextResponse.json( 50 | { error: "Internal server error" }, 51 | { status: 500 } 52 | ); 53 | } finally { 54 | if (client) { 55 | await client.end(); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Tektur:wght@400..900&display=swap"); 2 | 3 | @import "tailwindcss"; 4 | 5 | @theme { 6 | --font-Tektur: "Tektur", system-ui; 7 | --breakpoint-tillme: 866px; 8 | } 9 | 10 | /* 11 | @theme { 12 | --font-Manufacturing: "Manufacturing Consent", system-ui; 13 | --color-background: var(--background); 14 | --color-foreground: var(--foreground); 15 | --font-sans: var(--font-geist-sans); 16 | --font-mono: var(--font-geist-mono); 17 | } */ 18 | 19 | body { 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | /* Enhanced 3D Card Effects */ 24 | .perspective-1000 { 25 | perspective: 1500px; 26 | } 27 | 28 | /* Smooth 3D hover animation for rank cards */ 29 | @keyframes float-3d { 30 | 0%, 100% { 31 | transform: perspective(1500px) rotateX(0deg) rotateY(0deg) translateY(0px) translateZ(0px); 32 | } 33 | 50% { 34 | transform: perspective(1500px) rotateX(3deg) rotateY(3deg) translateY(-8px) translateZ(20px); 35 | } 36 | } 37 | 38 | /* Add stronger glow on hover with multiple layers */ 39 | .rank-card-glow { 40 | box-shadow: 41 | 0 15px 50px -15px rgba(59, 130, 246, 0.5), 42 | 0 25px 80px -20px rgba(59, 130, 246, 0.3), 43 | inset 0 1px 0 rgba(255, 255, 255, 0.1); 44 | } 45 | 46 | /* Preserve 3D transforms with enhanced depth */ 47 | .transform-3d { 48 | transform-style: preserve-3d; 49 | backface-visibility: hidden; 50 | } 51 | 52 | /* Add inner shadow for depth */ 53 | .card-3d-depth { 54 | box-shadow: 55 | inset 0 -1px 2px rgba(0, 0, 0, 0.3), 56 | inset 0 1px 1px rgba(255, 255, 255, 0.1); 57 | } 58 | 59 | /* Shimmer effect for enhanced 3D feel */ 60 | @keyframes shimmer { 61 | 0% { 62 | background-position: -200% center; 63 | } 64 | 100% { 65 | background-position: 200% center; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/teams/teams.types.tsx: -------------------------------------------------------------------------------- 1 | export interface User { 2 | login: string; 3 | leader: boolean; 4 | profile_picture?: string | null; 5 | vip_status?: string | null; 6 | badges?: string[]; 7 | } 8 | 9 | export interface ResponseData { 10 | name: string; 11 | project_id: string; 12 | status: string; 13 | users: User[]; 14 | locked: boolean; 15 | validated: string; 16 | closed_at: string; 17 | final_mark: number | null; 18 | } 19 | 20 | export interface CampusType { 21 | name: string; 22 | id: number; 23 | } 24 | 25 | export interface ProjectInfo { 26 | project_id: number; 27 | name: string; 28 | difficulty: number; 29 | duration: string; 30 | slug: string; 31 | } 32 | 33 | export interface FilterOption { 34 | value: string; 35 | label: string; 36 | icon: string; 37 | color: string; 38 | } 39 | 40 | export type ProjectFilterType = "all" | "known" | "unknown"; 41 | export type SpecificProjectFilterType = string; 42 | 43 | export interface AnimationConfig { 44 | cardStagger: number; 45 | cardDuration: number; 46 | dropdownInitial: { opacity: number; y: number; scale: number }; 47 | dropdownAnimate: { opacity: number; y: number; scale: number }; 48 | dropdownExit: { opacity: number; y: number; scale: number }; 49 | buttonInitial: { opacity: number; scale: number }; 50 | buttonAnimate: { opacity: number; scale: number }; 51 | buttonExit: { opacity: number; scale: number }; 52 | } 53 | 54 | export interface VipPageState { 55 | dataReturned: ResponseData[] | null | undefined; 56 | filteredData: ResponseData[] | null | undefined; 57 | isLoading: boolean; 58 | isLoadingMore: boolean; 59 | pageNumber: number; 60 | selectedCampus: CampusType; 61 | campusDropdownOpen: boolean; 62 | projectsMap: Map; 63 | projectFilter: ProjectFilterType; 64 | projectFilterDropdownOpen: boolean; 65 | specificProjectFilter: SpecificProjectFilterType; 66 | specificProjectDropdownOpen: boolean; 67 | } 68 | -------------------------------------------------------------------------------- /scripts/test-moderation.js: -------------------------------------------------------------------------------- 1 | // Test script for moderation system 2 | const { moderateMessage } = require('../src/utils/moderationUtils'); 3 | 4 | const testCases = [ 5 | { message: "Hello everyone!", expected: false }, 6 | { message: "fuck you", expected: true }, 7 | { message: "f*ck you", expected: true }, 8 | { message: "f u c k", expected: true }, 9 | { message: "sh1t", expected: true }, 10 | { message: "You're a dumbass", expected: true }, 11 | { message: "go kill yourself", expected: true }, 12 | { message: "9ahba", expected: true }, 13 | { message: "sharmouta", expected: true }, 14 | { message: "🖕", expected: true }, 15 | { message: "Nice work!", expected: false }, 16 | { message: "What's the assignment?", expected: false }, 17 | { message: "f0ck this sh1t", expected: true }, 18 | ]; 19 | 20 | console.log("🧪 Testing Moderation System\n"); 21 | 22 | let passed = 0; 23 | let failed = 0; 24 | 25 | testCases.forEach((test, index) => { 26 | const result = moderateMessage(test.message); 27 | const success = result.blocked === test.expected; 28 | 29 | if (success) { 30 | passed++; 31 | console.log(`✅ Test ${index + 1}: PASS`); 32 | } else { 33 | failed++; 34 | console.log(`❌ Test ${index + 1}: FAIL`); 35 | console.log(` Message: "${test.message}"`); 36 | console.log(` Expected blocked: ${test.expected}, Got: ${result.blocked}`); 37 | } 38 | 39 | if (result.blocked) { 40 | console.log(` Categories: ${result.categories?.join(', ')}`); 41 | console.log(` Severity: ${result.severity}`); 42 | console.log(` Matched: ${result.matched_terms?.slice(0, 3).join(', ')}${result.matched_terms?.length > 3 ? '...' : ''}`); 43 | } 44 | console.log(); 45 | }); 46 | 47 | console.log(`\n📊 Results: ${passed}/${testCases.length} passed, ${failed} failed`); 48 | 49 | if (failed === 0) { 50 | console.log("🎉 All tests passed!"); 51 | process.exit(0); 52 | } else { 53 | console.log("⚠️ Some tests failed!"); 54 | process.exit(1); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/api/check-admin/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | import { Pool } from "pg"; 3 | import * as jose from "jose"; 4 | 5 | export const GET = async (request: NextRequest) => { 6 | let client: Pool | null = null; 7 | 8 | try { 9 | // Verify JWT first 10 | const authCookie = request.cookies.get("auth_code")?.value; 11 | if (!authCookie) { 12 | return NextResponse.json( 13 | { isAdmin: false }, 14 | { status: 200 } 15 | ); 16 | } 17 | 18 | await jose.jwtVerify( 19 | authCookie, 20 | new TextEncoder().encode(process.env.SECRET_KEY as string) 21 | ); 22 | 23 | const whoUrl = new URL("/api/who", request.url); 24 | const fetchme = await fetch(whoUrl, { 25 | method: "GET", 26 | headers: { 27 | "Content-Type": "application/json", 28 | Cookie: `auth_code=${authCookie};`, 29 | }, 30 | credentials: "include", 31 | }); 32 | 33 | if (!fetchme.ok) { 34 | return NextResponse.json( 35 | { isAdmin: false }, 36 | { status: 200 } 37 | ); 38 | } 39 | 40 | const currentUser = await fetchme.json(); 41 | 42 | client = new Pool({ connectionString: process.env.DATABASE_KEY }); 43 | 44 | const query = ` 45 | SELECT * FROM leets.vip 46 | WHERE login = $1 AND token IN ('owner', 'creator', 'vip') 47 | `; 48 | const result = await client.query(query, [currentUser.login]); 49 | 50 | const isAdmin = result.rows.length > 0; 51 | 52 | return NextResponse.json( 53 | { isAdmin, login: currentUser.login }, 54 | { status: 200 } 55 | ); 56 | 57 | } catch (error) { 58 | console.error("Error checking admin status:", error); 59 | return NextResponse.json( 60 | { isAdmin: false }, 61 | { status: 200 } 62 | ); 63 | } finally { 64 | if (client) { 65 | try { 66 | await client.end(); 67 | } catch (endError) { 68 | console.error("Error closing pool:", endError); 69 | } 70 | } 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/app/api/database/notifications.sql: -------------------------------------------------------------------------------- 1 | -- Notifications Table 2 | CREATE TABLE IF NOT EXISTS notifications ( 3 | id SERIAL PRIMARY KEY, 4 | title VARCHAR(255) NOT NULL, 5 | message TEXT NOT NULL, 6 | type VARCHAR(50) DEFAULT 'info', -- info, success, warning, error 7 | target_type VARCHAR(20) NOT NULL, -- 'all' or 'specific' 8 | target_user_id INTEGER, -- NULL if target_type is 'all', otherwise specific user ID (42 user ID) 9 | sender_username VARCHAR(100) DEFAULT 'mmaghri', -- Username of sender 10 | sender_image VARCHAR(500) DEFAULT 'https://cdn.intra.42.fr/users/83b4706433bb90d165a91eafb7c9bb86/large_mmaghri.jpg', -- Profile picture URL 11 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 12 | expires_at TIMESTAMP, -- Optional expiration date for the notification 13 | link VARCHAR(500) -- Optional link to redirect when notification is clicked 14 | ); 15 | 16 | -- User Notifications Read Status Table 17 | CREATE TABLE IF NOT EXISTS user_notification_reads ( 18 | id SERIAL PRIMARY KEY, 19 | notification_id INTEGER NOT NULL, 20 | user_id INTEGER NOT NULL, -- 42 user ID 21 | seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 22 | 23 | CONSTRAINT fk_notification FOREIGN KEY (notification_id) 24 | REFERENCES notifications(id) ON DELETE CASCADE, 25 | CONSTRAINT unique_user_notification UNIQUE(notification_id, user_id) 26 | ); 27 | 28 | -- Indexes for better performance 29 | CREATE INDEX IF NOT EXISTS idx_notifications_target_user ON notifications(target_user_id); 30 | CREATE INDEX IF NOT EXISTS idx_notifications_target_type ON notifications(target_type); 31 | CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC); 32 | CREATE INDEX IF NOT EXISTS idx_user_notification_reads_user ON user_notification_reads(user_id); 33 | CREATE INDEX IF NOT EXISTS idx_user_notification_reads_notification ON user_notification_reads(notification_id); 34 | 35 | -- Example data 36 | -- INSERT INTO notifications (title, message, type, target_type) 37 | -- VALUES ('Welcome!', 'Welcome to 1337leets platform', 'success', 'all'); 38 | -------------------------------------------------------------------------------- /scripts/insert-notifications-api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Script to insert initial notifications via API 3 | * Run with: node scripts/insert-notifications-api.js 4 | * 5 | * Make sure you're logged in as admin and the server is running 6 | */ 7 | 8 | const notifications = [ 9 | { 10 | title: "New Features Added! 🎉", 11 | message: "We've added Peer Finder to connect with your classmates, XP Calculator for level tracking, and a search bar on the Progress page - all based on your suggestions!", 12 | type: "success", 13 | target_type: "all", 14 | link: "/peerfinder" 15 | }, 16 | // For feedback badge notifications, you'll need to run this with specific user IDs 17 | // Example for specific user: 18 | // { 19 | // title: "Feedback Badge Awarded! 🏆", 20 | // message: "Thank you for your valuable feedback! Check your inbox - you've earned a special badge for your contribution to improving the platform.", 21 | // type: "success", 22 | // target_type: "specific", 23 | // target_user_id: 12345, // Replace with actual user ID 24 | // link: "/dashboard" 25 | // } 26 | ]; 27 | 28 | async function insertNotifications() { 29 | const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; 30 | 31 | console.log('📢 Inserting notifications...\n'); 32 | 33 | for (const notification of notifications) { 34 | try { 35 | const response = await fetch(`${BASE_URL}/api/notifications/create`, { 36 | method: 'POST', 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | }, 40 | credentials: 'include', 41 | body: JSON.stringify(notification) 42 | }); 43 | 44 | const data = await response.json(); 45 | 46 | if (response.ok) { 47 | console.log(`✅ Created: "${notification.title}"`); 48 | } else { 49 | console.error(`❌ Failed: "${notification.title}"`, data.error); 50 | } 51 | } catch (error) { 52 | console.error(`❌ Error creating "${notification.title}":`, error.message); 53 | } 54 | } 55 | 56 | console.log('\n✨ Done!'); 57 | } 58 | 59 | insertNotifications(); 60 | -------------------------------------------------------------------------------- /scripts/debug-words.js: -------------------------------------------------------------------------------- 1 | // Debug word splitting 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | const badWordsPath = path.join(__dirname, '../bad_words.json'); 6 | const badWordsData = JSON.parse(fs.readFileSync(badWordsPath, 'utf-8')); 7 | 8 | function normalizeWord(word) { 9 | return word 10 | .toLowerCase() 11 | .replace(/[.\-_]+/g, '') 12 | .replace(/[0@]/g, 'o') 13 | .replace(/[1!]/g, 'i') 14 | .replace(/[3]/g, 'e') 15 | .replace(/[4]/g, 'a') 16 | .replace(/[5]/g, 's') 17 | .replace(/[7]/g, 't') 18 | .replace(/[8]/g, 'b') 19 | .replace(/\*/g, ''); 20 | } 21 | 22 | function containsBadWord(text, badWord) { 23 | // If the bad word contains spaces, it's a phrase 24 | if (badWord.includes(' ')) { 25 | const normalizedText = text.toLowerCase().replace(/[.\-_]+/g, ''); 26 | const normalizedBadWord = badWord.toLowerCase().replace(/[.\-_]+/g, ''); 27 | return normalizedText.includes(normalizedBadWord); 28 | } 29 | 30 | // For single words: check each word in the text individually 31 | const words = text.split(/\s+/); 32 | const normalizedBadWord = normalizeWord(badWord); 33 | 34 | console.log(` Checking "${badWord}" (normalized: "${normalizedBadWord}")`); 35 | console.log(` Text words: [${words.map(w => `"${w}" (norm: "${normalizeWord(w)}")`).join(', ')}]`); 36 | 37 | for (const word of words) { 38 | const normalizedWord = normalizeWord(word); 39 | 40 | // Exact match 41 | if (normalizedWord === normalizedBadWord) { 42 | console.log(` ✅ MATCH: "${normalizedWord}" === "${normalizedBadWord}"`); 43 | return true; 44 | } 45 | 46 | // Substring match for long words 47 | if (normalizedBadWord.length >= 4 && normalizedWord.includes(normalizedBadWord)) { 48 | console.log(` ✅ MATCH: "${normalizedWord}" contains "${normalizedBadWord}"`); 49 | return true; 50 | } 51 | } 52 | 53 | console.log(` ❌ NO MATCH`); 54 | return false; 55 | } 56 | 57 | console.log('Testing "mo king" against "mok":\n'); 58 | const result = containsBadWord("mo king", "mok"); 59 | console.log(`\nFinal result: ${result}`); 60 | -------------------------------------------------------------------------------- /src/app/evaluations/evaluations.types.ts: -------------------------------------------------------------------------------- 1 | export interface EvaluationData { 2 | id: number; 3 | created_at: string; 4 | begin_at: string; 5 | filled_at: string | null; 6 | final_mark: number | null; 7 | comment: string | null; 8 | feedback: string | null; 9 | flag: { 10 | id: number; 11 | name: string; 12 | positive: boolean; 13 | icon: string; 14 | } | null; 15 | corrector: { 16 | id: number; 17 | login: string; 18 | full_name: string; 19 | profile_picture: string | null; 20 | }; 21 | correcteds: Array<{ 22 | id: number; 23 | login: string; 24 | full_name: string; 25 | profile_picture: string | null; 26 | }>; 27 | project: { 28 | id: number; 29 | name: string; 30 | final_mark: number | null; 31 | }; 32 | team: { 33 | id: number; 34 | name: string; 35 | status: string; 36 | validated: boolean | null; 37 | closed: boolean; 38 | }; 39 | passed: boolean; 40 | } 41 | 42 | export interface CampusType { 43 | name: string; 44 | id: number; 45 | } 46 | 47 | export const CampusList: CampusType[] = [ 48 | { name: "All Campuses", id: 0 }, 49 | { name: "Khouribga", id: 16 }, 50 | { name: "Bengrir", id: 21 }, 51 | { name: "Tetouan", id: 55 }, 52 | { name: "Rabat", id: 75 }, 53 | { name: "Paris", id: 1 }, 54 | { name: "Lyon", id: 9 }, 55 | { name: "Barcelona", id: 46 }, 56 | { name: "Mulhouse", id: 48 }, 57 | { name: "Lausanne", id: 47 }, 58 | { name: "Istanbul", id: 49 }, 59 | { name: "Berlin", id: 51 }, 60 | { name: "Florence", id: 52 }, 61 | { name: "Vienna", id: 53 }, 62 | { name: "Prague", id: 56 }, 63 | { name: "London", id: 57 }, 64 | { name: "Porto", id: 58 }, 65 | { name: "Luxembourg", id: 59 }, 66 | { name: "Perpignan", id: 60 }, 67 | { name: "Tokyo", id: 26 }, 68 | { name: "Moscow", id: 17 }, 69 | { name: "Madrid", id: 22 }, 70 | { name: "Seoul", id: 29 }, 71 | { name: "Rome", id: 30 }, 72 | { name: "Bangkok", id: 33 }, 73 | { name: "Amman", id: 35 }, 74 | { name: "Malaga", id: 37 }, 75 | { name: "Nice", id: 41 }, 76 | { name: "Abu Dhabi", id: 43 }, 77 | { name: "Wolfsburg", id: 44 }, 78 | ]; 79 | -------------------------------------------------------------------------------- /src/app/api/feedback/rateLimit.ts: -------------------------------------------------------------------------------- 1 | interface RateLimitEntry { 2 | count: number; 3 | timestamps: number[]; 4 | lastReset: number; 5 | } 6 | 7 | class RateLimiter { 8 | private store: Map; 9 | private readonly windowMs: number; 10 | private readonly maxRequests: number; 11 | 12 | constructor(windowMs: number = 60 * 60 * 1000, maxRequests: number = 1) { 13 | this.store = new Map(); 14 | this.windowMs = windowMs; 15 | this.maxRequests = maxRequests; 16 | 17 | setInterval(() => this.cleanup(), 60 * 60 * 1000); 18 | } 19 | 20 | check(identifier: string): { allowed: boolean; retryAfter?: number; current: number } { 21 | const now = Date.now(); 22 | const entry = this.store.get(identifier); 23 | 24 | if (!entry) { 25 | this.store.set(identifier, { 26 | count: 1, 27 | timestamps: [now], 28 | lastReset: now, 29 | }); 30 | return { allowed: true, current: 1 }; 31 | } 32 | 33 | const validTimestamps = entry.timestamps.filter( 34 | (timestamp) => now - timestamp < this.windowMs 35 | ); 36 | 37 | if (validTimestamps.length < this.maxRequests) { 38 | validTimestamps.push(now); 39 | this.store.set(identifier, { 40 | count: validTimestamps.length, 41 | timestamps: validTimestamps, 42 | lastReset: entry.lastReset, 43 | }); 44 | return { allowed: true, current: validTimestamps.length }; 45 | } 46 | 47 | const oldestTimestamp = validTimestamps[0]; 48 | const retryAfter = Math.ceil((oldestTimestamp + this.windowMs - now) / 1000); 49 | 50 | return { 51 | allowed: false, 52 | retryAfter, 53 | current: validTimestamps.length, 54 | }; 55 | } 56 | 57 | private cleanup() { 58 | const now = Date.now(); 59 | for (const [key, entry] of this.store.entries()) { 60 | if (now - entry.lastReset > this.windowMs * 2) { 61 | this.store.delete(key); 62 | } 63 | } 64 | } 65 | 66 | reset(identifier: string) { 67 | this.store.delete(identifier); 68 | } 69 | } 70 | 71 | export const feedbackRateLimiter = new RateLimiter(10 * 60 * 1000, 1); 72 | -------------------------------------------------------------------------------- /src/app/teams/components/TeamGrid.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { motion } from "motion/react"; 4 | import { ResponseData } from "../teams.types"; 5 | import { GridConfig } from "../teams.constants"; 6 | import { TeamCard } from "./TeamCard"; 7 | 8 | interface TeamGridProps { 9 | filteredTeams: ResponseData[]; 10 | getProjectName: (projectId: string) => string; 11 | getProjectDifficulty: (projectId: string) => number; 12 | getProjectDuration: (projectId: string) => string; 13 | handleTeamClick: (team: ResponseData) => void; 14 | } 15 | 16 | export const TeamGrid: React.FC = ({ 17 | filteredTeams, 18 | getProjectName, 19 | getProjectDifficulty, 20 | getProjectDuration, 21 | handleTeamClick, 22 | }) => { 23 | if (filteredTeams.length === 0) { 24 | return ( 25 | 31 |
32 |
33 |

34 | No Teams Found 35 |

36 |

37 | Try adjusting your filters or search in a different campus. 38 |

39 |
40 |
41 | ); 42 | } 43 | 44 | return ( 45 |
46 | {filteredTeams.map((team, index) => ( 47 | 56 | ))} 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /scripts/insert-notifications.sql: -------------------------------------------------------------------------------- 1 | -- Add sender columns to existing notifications table if not already present 2 | ALTER TABLE notifications 3 | ADD COLUMN IF NOT EXISTS sender_username VARCHAR(100) DEFAULT 'mmaghri', 4 | ADD COLUMN IF NOT EXISTS sender_image VARCHAR(500) DEFAULT 'https://cdn.intra.42.fr/users/83b4706433bb90d165a91eafb7c9bb86/large_mmaghri.jpg'; 5 | 6 | -- Insert notification about new features (Peer Finder, Calculator, Search) 7 | INSERT INTO notifications ( 8 | title, 9 | message, 10 | type, 11 | target_type, 12 | sender_username, 13 | sender_image, 14 | link 15 | ) VALUES ( 16 | 'New Features Added! 🎉', 17 | 'We''ve added Peer Finder to connect with your classmates, XP Calculator for level tracking, and a search bar on the Progress page - all based on your suggestions!', 18 | 'success', 19 | 'all', 20 | 'mmaghri', 21 | 'https://cdn.intra.42.fr/users/83b4706433bb90d165a91eafb7c9bb86/large_mmaghri.jpg', 22 | '/peerfinder' 23 | ); 24 | 25 | -- Note: The following query will insert notifications for users who have submitted feedback 26 | -- It targets users from the feedback table who have submissions 27 | -- This assumes you have a feedback table with user_id column 28 | -- Uncomment and run this when the feedback table exists: 29 | 30 | /* 31 | INSERT INTO notifications ( 32 | title, 33 | message, 34 | type, 35 | target_type, 36 | target_user_id, 37 | sender_username, 38 | sender_image, 39 | link 40 | ) 41 | SELECT DISTINCT 42 | 'Feedback Badge Awarded! 🏆', 43 | 'Thank you for your valuable feedback! Check your inbox - you''ve earned a special badge for your contribution to improving the platform.', 44 | 'success', 45 | 'specific', 46 | user_id, 47 | 'mmaghri', 48 | 'https://cdn.intra.42.fr/users/83b4706433bb90d165a91eafb7c9bb86/large_mmaghri.jpg', 49 | '/dashboard' 50 | FROM feedback 51 | WHERE created_at >= NOW() - INTERVAL '30 days' 52 | AND user_id IS NOT NULL; 53 | */ 54 | 55 | -- Verify inserted notifications 56 | SELECT 57 | id, 58 | title, 59 | message, 60 | type, 61 | target_type, 62 | sender_username, 63 | created_at 64 | FROM notifications 65 | ORDER BY created_at DESC 66 | LIMIT 10; 67 | -------------------------------------------------------------------------------- /src/app/api/rate-limit-stats/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { Pool } from "pg"; 3 | import * as jose from "jose"; 4 | 5 | const pool = new Pool({ 6 | connectionString: process.env.DATABASE_KEY || process.env.DATABASE_URL, 7 | }); 8 | 9 | export async function GET(request: NextRequest) { 10 | try { 11 | // Check if user is admin 12 | const authCookie = request.cookies.get("auth_code")?.value; 13 | if (!authCookie) { 14 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 15 | } 16 | 17 | const secret = new TextEncoder().encode(process.env.SECRET_KEY as string); 18 | 19 | try { 20 | await jose.jwtVerify(authCookie, secret); 21 | } catch { 22 | return NextResponse.json({ error: "Invalid token" }, { status: 401 }); 23 | } 24 | 25 | const decodedToken = jose.decodeJwt(authCookie); 26 | const userLogin = decodedToken.login as string; 27 | 28 | // Check if user is admin 29 | const adminCheck = await pool.query( 30 | "SELECT * FROM leets.vip WHERE login = $1 AND (token = 'owner' OR token = 'creator')", 31 | [userLogin] 32 | ); 33 | 34 | if (adminCheck.rows.length === 0) { 35 | return NextResponse.json({ error: "Admin access required" }, { status: 403 }); 36 | } 37 | 38 | // Get all rate limit entries 39 | const result = await pool.query( 40 | `SELECT 41 | identifier, 42 | count, 43 | reset_time, 44 | block_count, 45 | last_block_time, 46 | updated_at 47 | FROM rate_limits 48 | ORDER BY count DESC, updated_at DESC` 49 | ); 50 | 51 | const users = result.rows.map(row => ({ 52 | identifier: row.identifier, 53 | count: row.count, 54 | blockCount: row.block_count, 55 | resetTime: parseInt(row.reset_time), 56 | lastBlockTime: parseInt(row.last_block_time), 57 | updatedAt: row.updated_at, 58 | })); 59 | 60 | return NextResponse.json({ users }, { status: 200 }); 61 | } catch (error) { 62 | console.error("Error fetching rate limit stats:", error); 63 | return NextResponse.json( 64 | { error: "Failed to fetch rate limit stats" }, 65 | { status: 500 } 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/app/api/slots/slots.types.ts: -------------------------------------------------------------------------------- 1 | interface slotstypes { 2 | day: string; 3 | month: string; 4 | year: string; 5 | } 6 | 7 | interface UserResponse { 8 | email: string; 9 | login: string; 10 | kind: string; 11 | image: string; 12 | staff: boolean; 13 | correction_point: number; 14 | pool_month: string; 15 | pool_year: string; 16 | location: string | null; 17 | wallet: number; 18 | campus_id: number; 19 | campus_name: string; 20 | level: number; 21 | } 22 | 23 | interface TeamMember { 24 | login: string; 25 | leader: boolean; 26 | } 27 | 28 | interface RawTeamData { 29 | locked_at: string; 30 | name: string; 31 | project_id: number; 32 | status: string; 33 | users: TeamMember[]; 34 | locked: boolean; 35 | validated: boolean; 36 | closed_at: string | null; 37 | final_mark: number | null; 38 | } 39 | 40 | interface TransformedTeamData { 41 | name: string; 42 | project_id: number; 43 | status: string; 44 | users: TeamMember[]; 45 | locked: boolean; 46 | validated: "yes" | "no"; 47 | closed_at: string | null; 48 | final_mark: number | null; 49 | } 50 | 51 | 52 | const TimeFrameToday = new Date(); 53 | const TimeFrameTomorrow = new Date(); 54 | 55 | const today: slotstypes = { 56 | day: 57 | TimeFrameToday.getDate().toString().length == 1 58 | ? "0" + TimeFrameToday.getDate().toString() 59 | : TimeFrameToday.getDate().toString(), 60 | month: 61 | (TimeFrameToday.getMonth() + 1).toString().length == 1 62 | ? "0" + (TimeFrameToday.getMonth() + 1).toString() 63 | : (TimeFrameToday.getMonth() + 1).toString(), 64 | year: TimeFrameToday.getFullYear().toString(), 65 | }; 66 | 67 | TimeFrameTomorrow.setDate(TimeFrameToday.getDate() + 1); 68 | 69 | const tomorow: slotstypes = { 70 | day: 71 | TimeFrameTomorrow.getDate().toString().length == 1 72 | ? "0" + TimeFrameTomorrow.getDate().toString() 73 | : TimeFrameTomorrow.getDate().toString(), 74 | month: 75 | (TimeFrameTomorrow.getMonth() + 1).toString().length == 1 76 | ? "0" + (TimeFrameTomorrow.getMonth() + 1).toString() 77 | : (TimeFrameTomorrow.getMonth() + 1).toString(), 78 | year: TimeFrameTomorrow.getFullYear().toString(), 79 | }; 80 | 81 | export type { UserResponse, TeamMember, RawTeamData, TransformedTeamData }; 82 | export { today, tomorow }; 83 | -------------------------------------------------------------------------------- /apiText: -------------------------------------------------------------------------------- 1 | const schema = { 2 | type: "object", 3 | properties: { 4 | male: { 5 | type: "array", 6 | items: { 7 | type: "object", 8 | properties: { 9 | full_name: { 10 | type: ["string", "null"] 11 | }, 12 | login: { 13 | type: "string" 14 | } 15 | }, 16 | required: ["login"] 17 | } 18 | }, 19 | female: { 20 | type: "array", 21 | items: { 22 | type: "object", 23 | properties: { 24 | full_name: { 25 | type: ["string", "null"] 26 | }, 27 | login: { 28 | type: "string" 29 | } 30 | }, 31 | required: ["login"] 32 | } 33 | } 34 | }, 35 | required: ["male", "female"] 36 | }; 37 | 38 | 39 | const CalculatorPage = () => { 40 | const main = async () => { 41 | const ai = new GoogleGenAI({ 42 | apiKey: process.env.NEXT_PUBLIC_GEMINI_API_KEY, 43 | }); 44 | const response = await ai.models.generateContent({ 45 | model: "gemini-2.5-flash", 46 | contents: 47 | 'You are given an array of user data in the following format: { full_name: string | null, login: string }. Your task is to analyze the full_name field and classify each user by gender (male or female) based on common name-gender associations. Use your best judgment to determine the gender from the name. Return the result as a JSON object with the structure: { male: Data[], female: Data[] }. Only include users whose gender you can confidently identify; skip those with null or ambiguous names. Example input: [{"login": "rboutaik", "full_name": "Rachid Boutaikhar"}, {"login": "mel-jira", "full_name": "Mhammed Reda El Jirari"}, {"login": "wbelfatm", "full_name": "Walid Belfatmi"}, {"login": "aitaouss", "full_name": "Aimen Taoussi"}]. Please return only the raw JSON response in this exact format, with no explanation or additional text: { "male": [...], "female": [...] }.', 48 | config: { 49 | temperature: 0.2, 50 | "responseMimeType": "application/json", 51 | responseSchema: schema}}); 52 | // //console.log(" -----> ", response.text); 53 | const json = JSON.parse(response.text!) 54 | //console.log("used data after filterring",json) 55 | }; 56 | 57 | 58 | useEffect(() => { 59 | main(); 60 | }, []); 61 | -------------------------------------------------------------------------------- /scripts/quick-test.js: -------------------------------------------------------------------------------- 1 | // Quick moderation test 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Read bad words JSON 6 | const badWordsPath = path.join(__dirname, '../bad_words.json'); 7 | const badWordsData = JSON.parse(fs.readFileSync(badWordsPath, 'utf-8')); 8 | 9 | // Normalize text for detection 10 | function normalizeText(text) { 11 | return text 12 | .toLowerCase() 13 | .replace(/[.\-_\s]+/g, '') 14 | .replace(/[0@]/g, 'o') 15 | .replace(/[1!]/g, 'i') 16 | .replace(/[3]/g, 'e') 17 | .replace(/[4]/g, 'a') 18 | .replace(/[5]/g, 's') 19 | .replace(/[7]/g, 't') 20 | .replace(/[8]/g, 'b') 21 | .replace(/\*/g, ''); 22 | } 23 | 24 | // Check if text contains a bad word 25 | function containsBadWord(text, badWord) { 26 | const normalizedText = normalizeText(text); 27 | const normalizedBadWord = normalizeText(badWord); 28 | 29 | if (normalizedText.includes(normalizedBadWord)) { 30 | return true; 31 | } 32 | 33 | const wordBoundaryRegex = new RegExp(`\\b${normalizedBadWord}\\b`, 'i'); 34 | if (wordBoundaryRegex.test(normalizedText)) { 35 | return true; 36 | } 37 | 38 | return false; 39 | } 40 | 41 | // Test messages 42 | const testMessages = [ 43 | "fuck you", 44 | "f*ck", 45 | "sh1t", 46 | "hello world", 47 | "9ahba", 48 | "mo king", 49 | "mok", 50 | ]; 51 | 52 | console.log("🧪 Testing Moderation Detection\n"); 53 | 54 | testMessages.forEach(msg => { 55 | console.log(`Message: "${msg}"`); 56 | 57 | let detected = false; 58 | let matchedWords = []; 59 | 60 | // Check profanity 61 | for (const word of badWordsData.profanity) { 62 | if (containsBadWord(msg, word)) { 63 | detected = true; 64 | matchedWords.push(word); 65 | } 66 | } 67 | 68 | // Check darija_franco 69 | for (const word of badWordsData.darija_franco) { 70 | if (containsBadWord(msg, word)) { 71 | detected = true; 72 | matchedWords.push(word); 73 | } 74 | } 75 | 76 | // Check darija_arabic 77 | for (const word of badWordsData.darija_arabic) { 78 | if (containsBadWord(msg, word)) { 79 | detected = true; 80 | matchedWords.push(word); 81 | } 82 | } 83 | 84 | if (detected) { 85 | console.log(`✅ DETECTED: ${matchedWords.join(', ')}`); 86 | } else { 87 | console.log(`❌ NOT DETECTED`); 88 | } 89 | console.log(''); 90 | }); 91 | -------------------------------------------------------------------------------- /src/app/api/database/instructions.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA IF NOT EXISTS leets; 2 | 3 | CREATE TABLE IF NOT EXISTS leets.vip ( 4 | id SERIAL PRIMARY KEY, 5 | category TEXT DEFAULT NULL, 6 | login TEXT DEFAULT NULL UNIQUE, 7 | profile TEXT DEFAULT NULL, 8 | token TEXT DEFAULT NULL, 9 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 10 | updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 11 | ); 12 | 13 | INSERT INTO 14 | leets.vip ( 15 | category, 16 | login, 17 | profile, 18 | token 19 | ) 20 | VALUES ( 21 | 'student', 22 | 'mmaghri', 23 | '', 24 | 'owner' 25 | ); 26 | 27 | INSERT INTO 28 | leets.vip ( 29 | category, 30 | login, 31 | profile, 32 | token 33 | ) 34 | VALUES ( 35 | 'student', 36 | 'mmaghri', 37 | '', 38 | 'creator' 39 | ) ON CONFLICT (login) DO UPDATE SET token = 'creator'; 40 | 41 | INSERT INTO 42 | leets.vip ( 43 | category, 44 | login, 45 | profile, 46 | token 47 | ) 48 | VALUES ( 49 | 'student', 50 | 'asnaji', 51 | '', 52 | 'owner' 53 | ); 54 | 55 | -- Feedback Table 56 | CREATE TABLE IF NOT EXISTS leets.feedback ( 57 | id SERIAL PRIMARY KEY, 58 | user_login TEXT NOT NULL, 59 | user_email TEXT NOT NULL, 60 | user_image TEXT DEFAULT '/nopic.jpg', 61 | campus_id INTEGER DEFAULT 0, 62 | campus_name TEXT DEFAULT 'Unknown', 63 | feedback TEXT DEFAULT NULL, 64 | dislikes TEXT DEFAULT NULL, 65 | improvements TEXT DEFAULT NULL, 66 | rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), 67 | wants_to_contribute BOOLEAN DEFAULT FALSE, 68 | skills TEXT, 69 | contribution_area TEXT[], 70 | badge_awarded BOOLEAN DEFAULT FALSE, 71 | badge_type VARCHAR(50), 72 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 73 | ); 74 | 75 | -- Index for faster queries by user and campus 76 | CREATE INDEX IF NOT EXISTS idx_feedback_user_login ON leets.feedback(user_login); 77 | CREATE INDEX IF NOT EXISTS idx_feedback_campus_id ON leets.feedback(campus_id); 78 | CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON leets.feedback(created_at DESC); 79 | CREATE INDEX IF NOT EXISTS idx_feedback_badge_awarded ON leets.feedback(badge_awarded); 80 | CREATE INDEX IF NOT EXISTS idx_feedback_badge_type ON leets.feedback(badge_type); -------------------------------------------------------------------------------- /src/component/dashboard/FeedbackPopup.types.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Zod Validation Schema 4 | export const feedbackSchema = z.object({ 5 | feedback: z 6 | .string() 7 | .min(20, "Feature ideas must be at least 20 characters") 8 | .max(1000, "Feature ideas must be less than 1000 characters") 9 | .optional() 10 | .or(z.literal("")), 11 | dislikes: z 12 | .string() 13 | .min(10, "Dislikes must be at least 10 characters") 14 | .max(1000, "Dislikes must be less than 1000 characters") 15 | .optional() 16 | .or(z.literal("")), 17 | improvements: z 18 | .string() 19 | .min(10, "Improvements must be at least 10 characters") 20 | .max(1000, "Improvements must be less than 1000 characters") 21 | .optional() 22 | .or(z.literal("")), 23 | rating: z 24 | .number() 25 | .min(1, "Please rate your experience") 26 | .max(5), 27 | skills: z 28 | .string() 29 | .min(5, "Skills must be at least 5 characters") 30 | .max(500, "Skills must be less than 500 characters") 31 | .optional() 32 | .or(z.literal("")), 33 | contributionArea: z.array(z.string()).optional(), 34 | }).refine( 35 | (data: { 36 | feedback?: string; 37 | dislikes?: string; 38 | improvements?: string; 39 | rating: number; 40 | skills?: string; 41 | contributionArea?: string[]; 42 | }) => { 43 | // At least one field (feedback, dislikes, or improvements) must be filled 44 | return ( 45 | (data.feedback && data.feedback.length >= 20) || 46 | (data.dislikes && data.dislikes.length >= 10) || 47 | (data.improvements && data.improvements.length >= 10) 48 | ); 49 | }, 50 | { 51 | message: "You must fill at least one feedback field with meaningful content", 52 | path: ["feedback"], 53 | } 54 | ); 55 | 56 | // TypeScript Interfaces 57 | export interface FeedbackFormData { 58 | feedback: string; 59 | dislikes: string; 60 | improvements: string; 61 | rating: number; 62 | skills: string; 63 | contributionArea: string[]; 64 | } 65 | 66 | export interface FeedbackPopupProps { 67 | onClose: () => void; 68 | username?: string; 69 | } 70 | 71 | export interface FormErrors { 72 | feedback?: string; 73 | dislikes?: string; 74 | improvements?: string; 75 | rating?: string; 76 | skills?: string; 77 | contributionArea?: string; 78 | } 79 | 80 | // Infer type from Zod schema 81 | export type FeedbackSchemaType = z.infer; 82 | -------------------------------------------------------------------------------- /scripts/migrate-reactions.js: -------------------------------------------------------------------------------- 1 | const { Pool } = require('pg'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Load .env.local file manually 6 | const envPath = path.join(__dirname, '..', '.env.local'); 7 | if (fs.existsSync(envPath)) { 8 | const envContent = fs.readFileSync(envPath, 'utf8'); 9 | envContent.split('\n').forEach(line => { 10 | const match = line.match(/^([^=:#]+)=(.*)$/); 11 | if (match) { 12 | const key = match[1].trim(); 13 | const value = match[2].trim(); 14 | process.env[key] = value; 15 | } 16 | }); 17 | } 18 | 19 | const pool = new Pool({ 20 | connectionString: process.env.DATABASE_KEY, 21 | }); 22 | 23 | async function runMigration() { 24 | const client = await pool.connect(); 25 | 26 | try { 27 | console.log('🔄 Starting reactions migration...'); 28 | 29 | // Create chat_reactions table 30 | await client.query(` 31 | CREATE TABLE IF NOT EXISTS leets.chat_reactions ( 32 | id SERIAL PRIMARY KEY, 33 | message_id TEXT NOT NULL, 34 | username TEXT NOT NULL, 35 | emoji TEXT NOT NULL, 36 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 37 | UNIQUE(message_id, username, emoji), 38 | FOREIGN KEY (message_id) REFERENCES leets.chat_messages(message_id) ON DELETE CASCADE 39 | ); 40 | `); 41 | console.log('✅ Table leets.chat_reactions created successfully'); 42 | 43 | // Create indexes 44 | await client.query(` 45 | CREATE INDEX IF NOT EXISTS idx_chat_reactions_message_id ON leets.chat_reactions(message_id); 46 | `); 47 | console.log('✅ Index on message_id created'); 48 | 49 | await client.query(` 50 | CREATE INDEX IF NOT EXISTS idx_chat_reactions_username ON leets.chat_reactions(username); 51 | `); 52 | console.log('✅ Index on username created'); 53 | 54 | // Verify table exists 55 | const result = await client.query(` 56 | SELECT COUNT(*) as count FROM leets.chat_reactions; 57 | `); 58 | console.log(`✅ Migration completed successfully! Current reactions count: ${result.rows[0].count}`); 59 | 60 | } catch (error) { 61 | console.error('❌ Migration failed:', error.message); 62 | throw error; 63 | } finally { 64 | client.release(); 65 | await pool.end(); 66 | } 67 | } 68 | 69 | runMigration() 70 | .then(() => { 71 | console.log('🎉 All done!'); 72 | process.exit(0); 73 | }) 74 | .catch((error) => { 75 | console.error('💥 Fatal error:', error); 76 | process.exit(1); 77 | }); 78 | -------------------------------------------------------------------------------- /src/app/api/check-creator/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | import { Pool } from "pg"; 3 | import * as jose from "jose"; 4 | import { DecryptionFunction } from "../auth/type.auth"; 5 | 6 | export const GET = async (request: NextRequest) => { 7 | let client: Pool | null = null; 8 | 9 | try { 10 | // Verify JWT first 11 | const authCookie = request.cookies.get("auth_code"); 12 | 13 | if (!authCookie || !authCookie.value) { 14 | return NextResponse.json( 15 | { isCreator: false, error: "Authentication required" }, 16 | { status: 401 } 17 | ); 18 | } 19 | 20 | await jose.jwtVerify( 21 | authCookie.value, 22 | new TextEncoder().encode(process.env.SECRET_KEY as string) 23 | ); 24 | 25 | // Decode and decrypt token 26 | const decodedToken = jose.decodeJwt(authCookie.value); 27 | if (!decodedToken.token) { 28 | return NextResponse.json( 29 | { isCreator: false, error: "Invalid token structure" }, 30 | { status: 401 } 31 | ); 32 | } 33 | 34 | const accessToken = DecryptionFunction(decodedToken.token as string); 35 | 36 | // Fetch user data from 42 API 37 | const userData = await fetch((process.env.INTRA_TOKEN as string) + "/v2/me", { 38 | method: "GET", 39 | headers: { 40 | Authorization: `Bearer ${accessToken}`, 41 | }, 42 | }); 43 | 44 | if (!userData.ok) { 45 | return NextResponse.json( 46 | { isCreator: false, error: "Failed to fetch user data" }, 47 | { status: userData.status } 48 | ); 49 | } 50 | 51 | const userInfo = await userData.json(); 52 | 53 | client = new Pool({ connectionString: process.env.DATABASE_KEY }); 54 | 55 | // Check if user is a creator in the database (NOT owner/vip) 56 | const creatorCheckQuery = `SELECT * FROM leets.vip WHERE login = $1 AND token = 'creator'`; 57 | const creatorCheckResult = await client.query(creatorCheckQuery, [userInfo.login]); 58 | 59 | return NextResponse.json( 60 | { 61 | isCreator: creatorCheckResult.rows.length > 0, 62 | login: userInfo.login 63 | }, 64 | { status: 200 } 65 | ); 66 | 67 | } catch (error) { 68 | console.error("Error checking creator status:", error); 69 | return NextResponse.json( 70 | { isCreator: false, error: "Internal Server Error" }, 71 | { status: 500 } 72 | ); 73 | } finally { 74 | if (client) { 75 | try { 76 | await client.end(); 77 | } catch (endError) { 78 | console.error("Error closing pool:", endError); 79 | } 80 | } 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /src/app/teams/components/LoadMore.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { motion } from "motion/react"; 4 | 5 | interface LoadMoreProps { 6 | hasMore: boolean; 7 | isLoading: boolean; 8 | onLoadMore: () => void; 9 | } 10 | 11 | export const LoadMore: React.FC = ({ 12 | hasMore, 13 | isLoading, 14 | onLoadMore, 15 | }) => { 16 | if (!hasMore) { 17 | return ( 18 | 23 |
24 |
25 |

26 | That's All! 27 |

28 |

29 | You've reached the end of the team list. 30 |

31 |
32 |
33 | ); 34 | } 35 | 36 | return ( 37 | 42 | 56 |
57 | 58 |
59 | {isLoading ? ( 60 | <> 61 |
62 | Loading Teams... 63 | 64 | ) : ( 65 | <> 66 | Load More Teams 67 | 68 | )} 69 |
70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/app/teams/teams.constants.tsx: -------------------------------------------------------------------------------- 1 | export const CampusList = [ 2 | { name: "Khouribga", id: 16 }, 3 | { name: "Bengrir", id: 21 }, 4 | { name: "Tetouan", id: 55 }, 5 | { name: "Rabat", id: 75 }, 6 | { name: "Paris", id: 1 }, 7 | { name: "Lyon", id: 9 }, 8 | { name: "Barcelona", id: 46 }, 9 | { name: "Mulhouse", id: 48 }, 10 | { name: "Lausanne", id: 47 }, 11 | { name: "Istanbul", id: 49 }, 12 | { name: "Berlin", id: 51 }, 13 | { name: "Florence", id: 52 }, 14 | { name: "Vienna", id: 53 }, 15 | { name: "Prague", id: 56 }, 16 | { name: "London", id: 57 }, 17 | { name: "Porto", id: 58 }, 18 | { name: "Luxembourg", id: 59 }, 19 | { name: "Perpignan", id: 60 }, 20 | { name: "Tokyo", id: 26 }, 21 | { name: "Moscow", id: 17 }, 22 | { name: "Madrid", id: 22 }, 23 | { name: "Seoul", id: 29 }, 24 | { name: "Rome", id: 30 }, 25 | { name: "Bangkok", id: 33 }, 26 | { name: "Amman", id: 35 }, 27 | { name: "Malaga", id: 37 }, 28 | { name: "Nice", id: 41 }, 29 | { name: "Abu Dhabi", id: 43 }, 30 | { name: "Wolfsburg", id: 44 }, 31 | ]; 32 | 33 | export const ProjectFilterOptions = [ 34 | { 35 | value: "all", 36 | label: "All Projects", 37 | icon: "", 38 | activeClasses: "bg-[#0070ef]/20 text-white border-l-[#0070ef]", 39 | hoverClasses: "hover:border-l-[#0070ef]/50" 40 | }, 41 | { 42 | value: "known", 43 | label: "Known Projects", 44 | icon: "", 45 | activeClasses: "bg-[#0070ef]/20 text-white border-l-[#0070ef]", 46 | hoverClasses: "hover:border-l-[#0070ef]/50" 47 | }, 48 | { 49 | value: "unknown", 50 | label: "Unknown Projects", 51 | icon: "", 52 | activeClasses: "bg-[#0070ef]/20 text-white border-l-[#0070ef]", 53 | hoverClasses: "hover:border-l-[#0070ef]/50" 54 | } 55 | ] as const; 56 | 57 | export const AnimationConfig = { 58 | cardStagger: 0.05, 59 | cardDuration: 0.3, 60 | dropdownInitial: { opacity: 0, y: -10, scale: 0.95 }, 61 | dropdownAnimate: { opacity: 1, y: 0, scale: 1 }, 62 | dropdownExit: { opacity: 0, y: -10, scale: 0.95 }, 63 | buttonInitial: { opacity: 0, scale: 0.8 }, 64 | buttonAnimate: { opacity: 1, scale: 1 }, 65 | buttonExit: { opacity: 0, scale: 0.8 }, 66 | } as const; 67 | 68 | export const GridConfig = { 69 | teams: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 md:gap-6 auto-rows-max", 70 | filters: "flex flex-col sm:flex-row flex-wrap gap-3 md:gap-4 items-stretch sm:items-center justify-center lg:justify-start", 71 | header: "flex flex-col xl:flex-row xl:items-center justify-between space-y-4 xl:space-y-0 gap-4", 72 | campusStats: "flex flex-col sm:flex-row items-stretch sm:items-center gap-3 md:gap-4 w-full sm:w-auto", 73 | } as const; 74 | -------------------------------------------------------------------------------- /src/app/api/vip-users/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | import { Pool } from "pg"; 3 | import * as jose from "jose"; 4 | 5 | export const GET = async (request: NextRequest) => { 6 | let client: Pool | null = null; 7 | 8 | try { 9 | // Verify JWT first 10 | const authCookie = request.cookies.get("auth_code")?.value; 11 | if (!authCookie) { 12 | return NextResponse.json( 13 | { error: "You are not an allowed user" }, 14 | { status: 403 } 15 | ); 16 | } 17 | 18 | // Verify the JWT token 19 | try { 20 | await jose.jwtVerify( 21 | authCookie, 22 | new TextEncoder().encode(process.env.SECRET_KEY as string) 23 | ); 24 | } catch { 25 | return NextResponse.json( 26 | { error: "You are not an allowed user" }, 27 | { status: 403 } 28 | ); 29 | } 30 | 31 | // Get current user info 32 | const whoUrl = new URL("/api/who", request.url); 33 | const fetchme = await fetch(whoUrl, { 34 | method: "GET", 35 | headers: { 36 | "Content-Type": "application/json", 37 | Cookie: `auth_code=${authCookie};`, 38 | }, 39 | credentials: "include", 40 | }); 41 | 42 | if (!fetchme.ok) { 43 | return NextResponse.json( 44 | { error: "You are not an allowed user" }, 45 | { status: 403 } 46 | ); 47 | } 48 | 49 | const currentUser = await fetchme.json(); 50 | 51 | client = new Pool({ connectionString: process.env.DATABASE_KEY }); 52 | 53 | // Check if the requesting user is in the VIP table 54 | const vipCheckQuery = `SELECT * FROM leets.vip WHERE login = $1`; 55 | const vipCheckResult = await client.query(vipCheckQuery, [currentUser.login]); 56 | 57 | // If user is not in VIP table, deny access 58 | if (vipCheckResult.rows.length === 0) { 59 | return NextResponse.json( 60 | { error: "You are not an allowed user" }, 61 | { status: 403 } 62 | ); 63 | } 64 | 65 | // User is in VIP, return the list of VIP users 66 | const query = `SELECT login FROM leets.vip`; 67 | const result = await client.query(query); 68 | 69 | const vipLogins = result.rows.map(row => row.login); 70 | 71 | return NextResponse.json( 72 | { vipUsers: vipLogins }, 73 | { status: 200 } 74 | ); 75 | 76 | } catch (error) { 77 | console.error("Error fetching VIP users:", error); 78 | return NextResponse.json( 79 | { error: "Internal Server Error: " + (error instanceof Error ? error.message : String(error)) }, 80 | { status: 500 } 81 | ); 82 | } finally { 83 | if (client) { 84 | try { 85 | await client.end(); 86 | } catch (endError) { 87 | console.error("Error closing pool:", endError); 88 | } 89 | } 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/app/teams/components/AccessDenied.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { motion } from "motion/react"; 4 | 5 | interface AccessDeniedProps { 6 | title?: string; 7 | message?: string; 8 | showRetry?: boolean; 9 | onRetry?: () => void; 10 | } 11 | 12 | export const AccessDenied: React.FC = ({ 13 | title = "Access Denied", 14 | message = "You don't have permission to view VIP teams. Please contact an administrator for access.", 15 | showRetry = true, 16 | onRetry, 17 | }) => { 18 | return ( 19 | 25 |
26 | 32 | {title} 33 | 34 | 35 | 41 | {message} 42 | 43 | 44 | {showRetry && onRetry && ( 45 | 54 | Try Again 55 | 56 | )} 57 | 58 | 64 |
65 |
66 | Need help? Contact your administrator 67 |
68 |
69 |
70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/app/api/feedback/feedback.types.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const feedbackSchema = z.object({ 4 | feedback: z 5 | .string() 6 | .min(20, "Feature ideas must be at least 20 characters") 7 | .max(1000, "Feature ideas must be less than 1000 characters") 8 | .optional() 9 | .or(z.literal("")), 10 | dislikes: z 11 | .string() 12 | .min(10, "Dislikes must be at least 10 characters") 13 | .max(1000, "Dislikes must be less than 1000 characters") 14 | .optional() 15 | .or(z.literal("")), 16 | improvements: z 17 | .string() 18 | .min(10, "Improvements must be at least 10 characters") 19 | .max(1000, "Improvements must be less than 1000 characters") 20 | .optional() 21 | .or(z.literal("")), 22 | rating: z 23 | .number() 24 | .min(1, "Please rate your experience") 25 | .max(5), 26 | skills: z 27 | .string() 28 | .min(5, "Skills must be at least 5 characters") 29 | .max(500, "Skills must be less than 500 characters") 30 | .optional() 31 | .or(z.literal("")), 32 | contributionArea: z.array(z.string()).optional(), 33 | }).refine( 34 | (data) => { 35 | return ( 36 | (data.feedback && data.feedback.length >= 20) || 37 | (data.dislikes && data.dislikes.length >= 10) || 38 | (data.improvements && data.improvements.length >= 10) 39 | ); 40 | }, 41 | { 42 | message: "You must fill at least one feedback field with meaningful content", 43 | path: ["feedback"], 44 | } 45 | ); 46 | 47 | export interface FeedbackRecord { 48 | id: number; 49 | user_login: string; 50 | user_email: string; 51 | user_image: string; 52 | campus_id: number; 53 | campus_name: string; 54 | feedback: string | null; 55 | dislikes: string | null; 56 | improvements: string | null; 57 | rating: number; 58 | wants_to_contribute: boolean; 59 | skills: string | null; 60 | contribution_area: string[] | null; 61 | badge_awarded: boolean; 62 | badge_type: string | null; 63 | created_at: Date; 64 | } 65 | 66 | export const BADGE_TYPES = { 67 | CONTRIBUTOR: "Contributor", 68 | TOP_FEEDBACK: "Top Feedback", 69 | HELPFUL: "Helpful", 70 | INNOVATIVE: "Innovative", 71 | CRITICAL_THINKER: "Critical Thinker", 72 | VIP: "VIP", 73 | } as const; 74 | 75 | export type BadgeType = typeof BADGE_TYPES[keyof typeof BADGE_TYPES]; 76 | 77 | export const awardBadgeSchema = z.object({ 78 | feedbackId: z.number(), 79 | badgeType: z.enum([ 80 | BADGE_TYPES.CONTRIBUTOR, 81 | BADGE_TYPES.TOP_FEEDBACK, 82 | BADGE_TYPES.HELPFUL, 83 | BADGE_TYPES.INNOVATIVE, 84 | BADGE_TYPES.CRITICAL_THINKER, 85 | BADGE_TYPES.VIP, 86 | ]), 87 | customMessage: z.string().max(500).optional(), 88 | }); 89 | 90 | export type AwardBadgeSchemaType = z.infer; 91 | 92 | export type FeedbackSchemaType = z.infer; 93 | -------------------------------------------------------------------------------- /src/app/api/rate-limit-bans/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { Pool } from "pg"; 3 | import * as jose from "jose"; 4 | 5 | const pool = new Pool({ 6 | connectionString: process.env.DATABASE_KEY || process.env.DATABASE_URL, 7 | }); 8 | 9 | export async function GET(request: NextRequest) { 10 | try { 11 | // Check if user is admin 12 | const authCookie = request.cookies.get("auth_code")?.value; 13 | if (!authCookie) { 14 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 15 | } 16 | 17 | const secret = new TextEncoder().encode(process.env.SECRET_KEY as string); 18 | 19 | try { 20 | await jose.jwtVerify(authCookie, secret); 21 | } catch { 22 | return NextResponse.json({ error: "Invalid token" }, { status: 401 }); 23 | } 24 | 25 | const decodedToken = jose.decodeJwt(authCookie); 26 | const userLogin = decodedToken.login as string; 27 | 28 | // Check if user is admin (you can add your admin check here) 29 | // For now, checking if they're in VIP table as staff 30 | const adminCheck = await pool.query( 31 | "SELECT * FROM leets.vip WHERE login = $1 AND category = 'staff'", 32 | [userLogin] 33 | ); 34 | 35 | if (adminCheck.rows.length === 0) { 36 | return NextResponse.json({ error: "Admin access required" }, { status: 403 }); 37 | } 38 | 39 | // Get all currently banned users 40 | const now = Date.now(); 41 | const result = await pool.query( 42 | `SELECT 43 | identifier, 44 | block_count, 45 | reset_time, 46 | last_block_time, 47 | updated_at 48 | FROM rate_limits 49 | WHERE block_count > 0 AND reset_time > $1 50 | ORDER BY last_block_time DESC`, 51 | [now] 52 | ); 53 | 54 | const bannedUsers = result.rows.map(row => { 55 | const resetTime = parseInt(row.reset_time); 56 | const remainingSeconds = Math.ceil((resetTime - now) / 1000); 57 | 58 | let banDuration: string; 59 | if (row.block_count === 1) { 60 | banDuration = "2 minutes"; 61 | } else if (row.block_count === 2) { 62 | banDuration = "5 minutes"; 63 | } else { 64 | banDuration = "10 minutes"; 65 | } 66 | 67 | return { 68 | identifier: row.identifier, 69 | blockCount: row.block_count, 70 | banDuration, 71 | remainingSeconds, 72 | lastBlockTime: new Date(parseInt(row.last_block_time)).toISOString(), 73 | updatedAt: row.updated_at, 74 | }; 75 | }); 76 | 77 | return NextResponse.json({ bannedUsers }, { status: 200 }); 78 | } catch (error) { 79 | console.error("Error fetching banned users:", error); 80 | return NextResponse.json( 81 | { error: "Failed to fetch banned users" }, 82 | { status: 500 } 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /RATE_LIMITING.md: -------------------------------------------------------------------------------- 1 | # Rate Limiting System 2 | 3 | ## Overview 4 | The application uses a custom in-memory rate limiting system to prevent API abuse and protect against 42 API quota exhaustion. 5 | 6 | ## Implementation 7 | 8 | ### Backend (`/src/utils/rateLimit.ts`) 9 | - **Storage**: In-memory Map tracking requests per user/IP 10 | - **Cleanup**: Automatic cleanup every 5 minutes 11 | - **Tracking**: Uses auth cookie when available, falls back to IP address 12 | - **Response**: Returns 429 status with friendly message and retry-after time 13 | 14 | ### Rate Limit Presets 15 | - **STRICT** (20 requests/minute): `/api/who`, `/api/progress`, `/api/peerfinder`, `/api/projects` 16 | - **STANDARD** (30 requests/minute): `/api/notifications/mark-seen` 17 | - **RELAXED** (60 requests/minute): `/api/chat` (GET), `/api/notifications` (GET), `/api/feedback` (GET) 18 | - **AUTH** (5 requests/minute): `/api/auth` 19 | - **WRITE** (15 requests/minute): `/api/chat` (POST), `/api/vip` (POST) 20 | - **CUSTOM** (10 requests/minute): `/api/notifications/create` 21 | 22 | ## Frontend Integration 23 | 24 | ### Hook: `useRateLimitHandler` 25 | ```typescript 26 | const { rateLimitState, handleRateLimitResponse, closeRateLimitPopup } = useRateLimitHandler(); 27 | ``` 28 | 29 | ### Usage in API Calls 30 | ```typescript 31 | const response = await fetch('/api/endpoint'); 32 | 33 | // Check for rate limiting 34 | const isRateLimited = await handleRateLimitResponse(response); 35 | if (isRateLimited) { 36 | setLoading(false); 37 | return; 38 | } 39 | 40 | // Continue with normal flow 41 | if (!response.ok) { 42 | // Handle other errors 43 | } 44 | ``` 45 | 46 | ### Popup Component 47 | ```typescript 48 | 53 | ``` 54 | 55 | ## Protected Pages 56 | - ✅ `/progress` - Progress/leaderboard page 57 | - ✅ `/vip` - VIP admin panel 58 | - ✅ `/peerfinder` - Peer finder page 59 | 60 | ## Spam Logging 61 | The VIP endpoint includes special spam attempt logging: 62 | - Logs IP address, user agent, timestamp 63 | - Format: `⚠️ [VIP SPAM ATTEMPT]` 64 | - Includes authentication status 65 | - Uses console.warn for visibility 66 | 67 | ## User Experience 68 | When rate limited, users see: 69 | - Friendly popup message: "Take it easy bro! 😎" 70 | - Countdown timer showing seconds until they can retry 71 | - Backdrop with blur effect 72 | - Auto-closes when countdown reaches zero 73 | - Manual close button available 74 | 75 | ## Response Format 76 | ```json 77 | { 78 | "error": "Take it easy bro! 😎", 79 | "message": "You're making too many requests. Please slow down and try again in a moment.", 80 | "retryAfter": 45, 81 | "showPopup": true 82 | } 83 | ``` 84 | 85 | ## Headers 86 | Rate limit responses include: 87 | - `Retry-After`: Seconds until next allowed request 88 | - Status: `429 Too Many Requests` 89 | -------------------------------------------------------------------------------- /diagnose-socket.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "🔍 Socket.IO Connection Diagnostic" 4 | echo "==================================" 5 | echo "" 6 | 7 | # Check if socket files exist 8 | echo "1️⃣ Checking Socket.IO files..." 9 | if [ -f "pages/api/socket.ts" ]; then 10 | echo " ✅ pages/api/socket.ts exists" 11 | else 12 | echo " ❌ pages/api/socket.ts MISSING!" 13 | fi 14 | 15 | if [ -f "pages/api/socket.types.ts" ]; then 16 | echo " ✅ pages/api/socket.types.ts exists" 17 | else 18 | echo " ❌ pages/api/socket.types.ts MISSING!" 19 | fi 20 | 21 | echo "" 22 | 23 | # Check if dev server is running 24 | echo "2️⃣ Checking if dev server is running..." 25 | if lsof -Pi :3001 -sTCP:LISTEN -t >/dev/null 2>&1 ; then 26 | echo " ✅ Server running on port 3001" 27 | elif lsof -Pi :3000 -sTCP:LISTEN -t >/dev/null 2>&1 ; then 28 | echo " ✅ Server running on port 3000" 29 | else 30 | echo " ❌ No server running on port 3000 or 3001" 31 | echo " Run: npm run dev" 32 | fi 33 | 34 | echo "" 35 | 36 | # Check database connection 37 | echo "3️⃣ Checking database..." 38 | if [ -f ".env" ]; then 39 | if grep -q "DATABASE_KEY" .env; then 40 | echo " ✅ DATABASE_KEY found in .env" 41 | else 42 | echo " ❌ DATABASE_KEY missing in .env" 43 | fi 44 | else 45 | echo " ❌ .env file not found" 46 | fi 47 | 48 | echo "" 49 | 50 | # Check if chat table exists 51 | echo "4️⃣ Checking chat_messages table..." 52 | export $(grep -v '^#' .env | xargs) 53 | RESULT=$(psql "$DATABASE_KEY" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'leets' AND table_name = 'chat_messages';" 2>/dev/null) 54 | 55 | if [ "$RESULT" = " 1" ]; then 56 | echo " ✅ chat_messages table exists" 57 | MSG_COUNT=$(psql "$DATABASE_KEY" -t -c "SELECT COUNT(*) FROM leets.chat_messages;" 2>/dev/null | xargs) 58 | echo " 📊 Messages in database: $MSG_COUNT" 59 | else 60 | echo " ❌ chat_messages table MISSING!" 61 | echo " Run: ./setup-chat-db.sh" 62 | fi 63 | 64 | echo "" 65 | 66 | # Check socket.io packages 67 | echo "5️⃣ Checking npm packages..." 68 | if [ -d "node_modules/socket.io" ]; then 69 | echo " ✅ socket.io installed" 70 | else 71 | echo " ❌ socket.io NOT installed" 72 | echo " Run: npm install socket.io" 73 | fi 74 | 75 | if [ -d "node_modules/socket.io-client" ]; then 76 | echo " ✅ socket.io-client installed" 77 | else 78 | echo " ❌ socket.io-client NOT installed" 79 | echo " Run: npm install socket.io-client" 80 | fi 81 | 82 | echo "" 83 | echo "==================================" 84 | echo "🎯 Quick Fixes:" 85 | echo "" 86 | echo "If server not running:" 87 | echo " npm run dev" 88 | echo "" 89 | echo "If table missing:" 90 | echo " ./setup-chat-db.sh" 91 | echo "" 92 | echo "If packages missing:" 93 | echo " npm install socket.io socket.io-client" 94 | echo "" 95 | echo "To test Socket.IO manually:" 96 | echo " curl http://localhost:3001/api/socket" 97 | echo "" 98 | echo "==================================" 99 | -------------------------------------------------------------------------------- /src/app/progress/progress.types.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const cloneData: null[] = []; 4 | 5 | for (let i = 0; i < 100; i++) { 6 | cloneData.push(null); 7 | } 8 | 9 | const CloseIfNotClicked = ( 10 | ref: React.RefObject, 11 | ClickedPlace: HTMLElement | null, 12 | triggerRef?: React.RefObject 13 | ) => { 14 | if (ref.current && ClickedPlace) { 15 | if (getComputedStyle(ref.current).display === "none") return; 16 | const isClickedInsideDropdown = ref.current.contains(ClickedPlace); 17 | const isClickedOnTrigger = 18 | triggerRef?.current?.contains(ClickedPlace) || false; 19 | if (!isClickedInsideDropdown && !isClickedOnTrigger) { 20 | ref.current.style.display = "none"; 21 | } 22 | } 23 | }; 24 | 25 | interface CursusNameId { 26 | name: string; 27 | id: number; 28 | } 29 | 30 | interface CampusNameId { 31 | name: string; 32 | id: number; 33 | } 34 | 35 | interface SearchDeliverData { 36 | page: number; 37 | set: boolean; 38 | month: number | string; 39 | year: number | string; 40 | cursus: CursusNameId; 41 | campus: CampusNameId; 42 | } 43 | 44 | const CursusList: CampusNameId[] = [ 45 | { name: "Piscine", id: 9 }, 46 | { name: "Cursus", id: 21 }, 47 | ]; 48 | const YearList: number[] = [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]; 49 | const MonthList: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; 50 | const monthsIndex: string[] = [ 51 | "0", 52 | "january", 53 | "february", 54 | "march", 55 | "april", 56 | "may", 57 | "june", 58 | "july", 59 | "august", 60 | "september", 61 | "october", 62 | "november", 63 | "december", 64 | ]; 65 | const CampusList: CampusNameId[] = [ 66 | { name: "All", id: 0 }, 67 | { name: "Khouribga", id: 16 }, 68 | { name: "Bengrir", id: 21 }, 69 | { name: "Tetouan", id: 55 }, 70 | { name: "Rabat", id: 75 }, 71 | { name: "Paris", id: 1 }, 72 | { name: "Lyon", id: 9 }, 73 | { name: "Barcelona", id: 46 }, 74 | { name: "Mulhouse", id: 48 }, 75 | { name: "Lausanne", id: 47 }, 76 | { name: "Istanbul", id: 49 }, 77 | { name: "Berlin", id: 51 }, 78 | { name: "Florence", id: 52 }, 79 | { name: "Vienna", id: 53 }, 80 | { name: "Prague", id: 56 }, 81 | { name: "London", id: 57 }, 82 | { name: "Porto", id: 58 }, 83 | { name: "Luxembourg", id: 59 }, 84 | { name: "Perpignan", id: 60 }, 85 | { name: "Tokyo", id: 26 }, 86 | { name: "Moscow", id: 17 }, 87 | { name: "Madrid", id: 22 }, 88 | { name: "Seoul", id: 29 }, 89 | { name: "Rome", id: 30 }, 90 | { name: "Bangkok", id: 33 }, 91 | { name: "Amman", id: 35 }, 92 | { name: "Malaga", id: 37 }, 93 | { name: "Nice", id: 41 }, 94 | { name: "Abu Dhabi", id: 43 }, 95 | { name: "Wolfsburg", id: 44 }, 96 | ]; 97 | 98 | export type { SearchDeliverData }; 99 | export { 100 | cloneData, 101 | CloseIfNotClicked, 102 | MonthList, 103 | CampusList, 104 | YearList, 105 | CursusList, 106 | monthsIndex, 107 | }; 108 | -------------------------------------------------------------------------------- /public/test-auth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Auth Test 7 | 33 | 34 | 35 |

🔐 Authentication Test

36 | 37 | 38 | 39 | 40 |

Results:

41 |
Click a button to test...
42 | 43 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /diagnose-socketio.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "🔍 Socket.IO Diagnostic Test" 4 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 5 | echo "" 6 | 7 | # Check if server is running 8 | echo "1️⃣ Checking if Next.js server is running..." 9 | if pgrep -f "next dev" > /dev/null; then 10 | echo " ✅ Server is running (PID: $(pgrep -f "next dev" | head -1))" 11 | else 12 | echo " ❌ Server is NOT running!" 13 | echo " Run: npm run dev" 14 | exit 1 15 | fi 16 | echo "" 17 | 18 | # Check if Socket.IO endpoint responds 19 | echo "2️⃣ Testing Socket.IO endpoint..." 20 | HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/socket) 21 | if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "400" ]; then 22 | echo " ✅ Socket.IO endpoint responding (HTTP $HTTP_CODE)" 23 | else 24 | echo " ❌ Socket.IO endpoint not responding (HTTP $HTTP_CODE)" 25 | fi 26 | echo "" 27 | 28 | # Check if auth endpoint works 29 | echo "3️⃣ Testing auth verification endpoint..." 30 | AUTH_CODE=$(curl -s -w "%{http_code}" http://localhost:3000/api/auth/verify) 31 | if echo "$AUTH_CODE" | grep -q "200"; then 32 | echo " ✅ Auth endpoint working" 33 | elif echo "$AUTH_CODE" | grep -q "401"; then 34 | echo " ⚠️ Auth endpoint working but not logged in (expected)" 35 | else 36 | echo " ❌ Auth endpoint error" 37 | fi 38 | echo "" 39 | 40 | # Check for next.config.ts 41 | echo "4️⃣ Checking next.config.ts..." 42 | if grep -q "webpack" next.config.ts; then 43 | echo " ✅ Webpack config found in next.config.ts" 44 | else 45 | echo " ❌ Webpack config missing in next.config.ts" 46 | echo " This might cause Socket.IO issues!" 47 | fi 48 | echo "" 49 | 50 | # Check for socket.io packages 51 | echo "5️⃣ Checking Socket.IO packages..." 52 | if npm list socket.io > /dev/null 2>&1; then 53 | echo " ✅ socket.io installed" 54 | else 55 | echo " ❌ socket.io NOT installed!" 56 | fi 57 | 58 | if npm list socket.io-client > /dev/null 2>&1; then 59 | echo " ✅ socket.io-client installed" 60 | else 61 | echo " ❌ socket.io-client NOT installed!" 62 | fi 63 | echo "" 64 | 65 | # Check if .next directory exists (build) 66 | echo "6️⃣ Checking build status..." 67 | if [ -d ".next" ]; then 68 | echo " ✅ .next directory exists" 69 | BUILD_TIME=$(stat -c %y .next 2>/dev/null || stat -f "%Sm" .next 2>/dev/null) 70 | echo " Last built: $BUILD_TIME" 71 | else 72 | echo " ❌ .next directory missing (not built)" 73 | fi 74 | echo "" 75 | 76 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 77 | echo "" 78 | echo "📋 Recommendations:" 79 | echo "" 80 | 81 | if ! grep -q "webpack" next.config.ts; then 82 | echo "⚠️ CRITICAL: Webpack config missing!" 83 | echo " → This will cause Socket.IO event handling to fail" 84 | echo "" 85 | fi 86 | 87 | echo "🔄 To fix Socket.IO issues, restart the server:" 88 | echo " 1. Press Ctrl+C in the terminal running npm run dev" 89 | echo " 2. Run: npm run dev" 90 | echo " 3. Or use: ./restart-server.sh" 91 | echo "" 92 | echo "After restart:" 93 | echo " • Go to http://localhost:3000/chat" 94 | echo " • Send a message" 95 | echo " • Check server terminal for: 📩 Received sendMessage event" 96 | -------------------------------------------------------------------------------- /src/component/feedback/GlobalFeedbackButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState, useEffect, useContext } from "react"; 3 | import { usePathname } from "next/navigation"; 4 | import { IoRocketOutline } from "react-icons/io5"; 5 | import FeedbackPopup from "@/component/dashboard/FeedbackPopup"; 6 | import { ContextCreator } from "@/component/context/context"; 7 | 8 | const GlobalFeedbackButton: React.FC = () => { 9 | const pathname = usePathname(); 10 | const context = useContext(ContextCreator); 11 | const [showFeedbackPopup, setShowFeedbackPopup] = useState(false); 12 | const [showButton, setShowButton] = useState(false); 13 | 14 | useEffect(() => { 15 | // Don't show on landing page 16 | if (pathname === "/") { 17 | setShowButton(false); 18 | return; 19 | } 20 | 21 | // Show button after a short delay to avoid flash on page load 22 | const timer = setTimeout(() => { 23 | setShowButton(true); 24 | }, 1000); 25 | 26 | return () => clearTimeout(timer); 27 | }, [pathname]); 28 | 29 | const handleCloseFeedbackPopup = () => { 30 | setShowFeedbackPopup(false); 31 | localStorage.setItem("hasSeenFeedbackPopup", "true"); 32 | }; 33 | 34 | const handleOpenFeedbackPopup = () => { 35 | setShowFeedbackPopup(true); 36 | }; 37 | 38 | if (!showButton) return null; 39 | 40 | return ( 41 | <> 42 | {/* Floating Feedback Button */} 43 | {!showFeedbackPopup && ( 44 | 66 | )} 67 | 68 | {/* Feedback Popup */} 69 | {showFeedbackPopup && ( 70 | 74 | )} 75 | 76 | ); 77 | }; 78 | 79 | export default GlobalFeedbackButton; 80 | -------------------------------------------------------------------------------- /src/app/teams/utils/timeUtils.ts: -------------------------------------------------------------------------------- 1 | export const formatSubmissionDateTime = (closedAt: string | null) => { 2 | if (!closedAt) return "Not Submitted"; 3 | 4 | const date = new Date(closedAt); 5 | 6 | const timeOptions: Intl.DateTimeFormatOptions = { 7 | hour: '2-digit', 8 | minute: '2-digit', 9 | hour12: false, 10 | }; 11 | 12 | const dateOptions: Intl.DateTimeFormatOptions = { 13 | month: 'short', 14 | day: 'numeric', 15 | year: 'numeric', 16 | }; 17 | 18 | const time = date.toLocaleTimeString('en-US', timeOptions); 19 | const dateStr = date.toLocaleDateString('en-US', dateOptions); 20 | 21 | return `${time} • ${dateStr}`; 22 | }; 23 | 24 | export const formatTimeOnly = (closedAt: string | null) => { 25 | if (!closedAt) return "Not Submitted"; 26 | 27 | const date = new Date(closedAt); 28 | 29 | const timeOptions: Intl.DateTimeFormatOptions = { 30 | hour: '2-digit', 31 | minute: '2-digit', 32 | hour12: false, 33 | }; 34 | 35 | return date.toLocaleTimeString('en-US', timeOptions); 36 | }; 37 | 38 | export const formatDateOnly = (closedAt: string | null) => { 39 | if (!closedAt) return "No Date"; 40 | 41 | const date = new Date(closedAt); 42 | 43 | const dateOptions: Intl.DateTimeFormatOptions = { 44 | month: 'short', 45 | day: 'numeric', 46 | year: 'numeric', 47 | }; 48 | 49 | return date.toLocaleDateString('en-US', dateOptions); 50 | }; 51 | 52 | export const formatRelativeTime = (closedAt: string | null) => { 53 | if (!closedAt) return "No submission"; 54 | 55 | const date = new Date(closedAt); 56 | const now = new Date(); 57 | const diffInMs = now.getTime() - date.getTime(); 58 | const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)); 59 | const diffInDays = Math.floor(diffInHours / 24); 60 | const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); 61 | 62 | if (diffInMinutes < 60) { 63 | return `${diffInMinutes} minutes ago`; 64 | } else if (diffInHours < 24) { 65 | return `${diffInHours} hours ago`; 66 | } else if (diffInDays < 7) { 67 | return `${diffInDays} days ago`; 68 | } else { 69 | return formatDateOnly(closedAt); 70 | } 71 | }; 72 | 73 | export const isSubmittedToday = (closedAt: string | null): boolean => { 74 | if (!closedAt) return false; 75 | 76 | const date = new Date(closedAt); 77 | const today = new Date(); 78 | 79 | return ( 80 | date.getDate() === today.getDate() && 81 | date.getMonth() === today.getMonth() && 82 | date.getFullYear() === today.getFullYear() 83 | ); 84 | }; 85 | 86 | export const getSubmissionTimeStatus = (closedAt: string | null) => { 87 | if (!closedAt) return { status: 'not_submitted', color: 'gray' }; 88 | 89 | const submissionHour = new Date(closedAt).getHours(); 90 | 91 | if (submissionHour >= 6 && submissionHour < 12) { 92 | return { status: 'morning', color: 'blue', emoji: '🌅' }; 93 | } else if (submissionHour >= 12 && submissionHour < 18) { 94 | return { status: 'afternoon', color: 'yellow', emoji: '☀️' }; 95 | } else if (submissionHour >= 18 && submissionHour < 22) { 96 | return { status: 'evening', color: 'orange', emoji: '🌆' }; 97 | } else { 98 | return { status: 'night', color: 'purple', emoji: '🌙' }; 99 | } 100 | }; 101 | -------------------------------------------------------------------------------- /src/app/api/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { cookies } from "next/headers"; 3 | import { AuthResponse } from "./type.auth"; 4 | import * as jose from "jose"; 5 | import { rateLimit, RateLimitPresets } from "@/utils/rateLimit"; 6 | 7 | import { EncryptionFunction } from "./type.auth"; 8 | 9 | export const GET = async (request: NextRequest) => { 10 | // Rate limiting: 5 requests per minute (auth endpoint) 11 | const rateLimitResult = await rateLimit(request, RateLimitPresets.AUTH); 12 | if (rateLimitResult) return rateLimitResult; 13 | 14 | try { 15 | const code: string = request.nextUrl.searchParams.get("code") as string; 16 | if (!code) { 17 | return NextResponse.json( 18 | { error: "Authorization code is missing." }, 19 | { status: 400 } 20 | ); 21 | } 22 | 23 | const ClientQuery: URLSearchParams = new URLSearchParams({ 24 | grant_type: "authorization_code", 25 | client_id: process.env.INTRA_UID as string, 26 | client_secret: process.env.INTRA_SECRET_KEY as string, 27 | code: code, 28 | redirect_uri: process.env.INTRA_REDIRECT_URI as string, 29 | }); 30 | 31 | const tokenUrl = (process.env.INTRA_TOKEN as string) + "/oauth/token"; 32 | 33 | const fetchToken = await fetch(tokenUrl, { 34 | method: "POST", 35 | headers: { 36 | "Content-Type": "application/x-www-form-urlencoded", 37 | }, 38 | body: ClientQuery.toString(), 39 | signal: AbortSignal.timeout(10000), 40 | }); 41 | 42 | if (!fetchToken.ok) { 43 | throw new Error("Failed to fetch token"); 44 | } 45 | 46 | const Tok: AuthResponse = await fetchToken.json(); 47 | 48 | // Fetch user info to get username (only once during login) 49 | const userInfoResponse = await fetch("https://api.intra.42.fr/v2/me", { 50 | headers: { 51 | Authorization: `Bearer ${Tok.access_token}`, 52 | }, 53 | }); 54 | 55 | let username = "unknown"; 56 | let userId = null; 57 | let campusId = null; 58 | 59 | if (userInfoResponse.ok) { 60 | const userData = await userInfoResponse.json(); 61 | username = userData.login || "unknown"; 62 | userId = userData.id || null; 63 | campusId = userData.campus_users?.[0]?.campus_id || null; 64 | } 65 | 66 | const token = new TextEncoder().encode(process.env.SECRET_KEY as string); 67 | const Signature = new jose.SignJWT({ 68 | token: EncryptionFunction(Tok.access_token), 69 | login: username, 70 | userId: userId, 71 | campusId: campusId, 72 | }) 73 | .setProtectedHeader({ alg: "HS256" }) 74 | .sign(token); 75 | const signedToken = await Signature; 76 | 77 | const isProduction = process.env.NODE_ENV === "production"; 78 | 79 | (await cookies()).set("auth_code", signedToken as string, { 80 | httpOnly: true, 81 | secure: isProduction, 82 | sameSite: "lax", 83 | path: "/", 84 | maxAge: 60 * 60 * 24 * 7, // 7 days for better persistence 85 | }); 86 | 87 | const redirectUrl = new URL("/dashboard", request.url); 88 | return NextResponse.redirect(redirectUrl); 89 | } catch (error) { 90 | console.error("Authentication error:", error); 91 | return NextResponse.json( 92 | { 93 | error: "An error occurred while processing your request.", 94 | }, 95 | { status: 500 } 96 | ); 97 | } 98 | }; 99 | -------------------------------------------------------------------------------- /src/app/api/database/chat_moderation.sql: -------------------------------------------------------------------------------- 1 | -- Create table for storing flagged messages 2 | CREATE TABLE IF NOT EXISTS leets.chat_flagged_messages ( 3 | id SERIAL PRIMARY KEY, 4 | message_id TEXT NOT NULL UNIQUE, 5 | message TEXT NOT NULL, 6 | username TEXT NOT NULL, 7 | avatar TEXT, 8 | level NUMERIC, 9 | campus TEXT, 10 | blocked BOOLEAN DEFAULT false, 11 | categories TEXT[], -- Array of matched categories 12 | matched_terms TEXT[], -- Array of matched bad words/phrases 13 | severity TEXT CHECK (severity IN ('low', 'medium', 'high')), 14 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 15 | reviewed BOOLEAN DEFAULT false, 16 | reviewed_at TIMESTAMP, 17 | reviewed_by TEXT, 18 | action_taken TEXT, -- 'none', 'warning', 'ban', 'delete' 19 | FOREIGN KEY (message_id) REFERENCES leets.chat_messages(message_id) ON DELETE CASCADE 20 | ); 21 | 22 | -- Create indexes for efficient querying 23 | CREATE INDEX IF NOT EXISTS idx_flagged_messages_username ON leets.chat_flagged_messages(username); 24 | CREATE INDEX IF NOT EXISTS idx_flagged_messages_severity ON leets.chat_flagged_messages(severity); 25 | CREATE INDEX IF NOT EXISTS idx_flagged_messages_reviewed ON leets.chat_flagged_messages(reviewed); 26 | CREATE INDEX IF NOT EXISTS idx_flagged_messages_created_at ON leets.chat_flagged_messages(created_at DESC); 27 | 28 | -- Create function to get flagged message statistics 29 | CREATE OR REPLACE FUNCTION leets.get_flagged_stats() 30 | RETURNS TABLE( 31 | total_flagged BIGINT, 32 | high_severity BIGINT, 33 | medium_severity BIGINT, 34 | low_severity BIGINT, 35 | unreviewed BIGINT 36 | ) AS $$ 37 | BEGIN 38 | RETURN QUERY 39 | SELECT 40 | COUNT(*)::BIGINT as total_flagged, 41 | COUNT(*) FILTER (WHERE severity = 'high')::BIGINT as high_severity, 42 | COUNT(*) FILTER (WHERE severity = 'medium')::BIGINT as medium_severity, 43 | COUNT(*) FILTER (WHERE severity = 'low')::BIGINT as low_severity, 44 | COUNT(*) FILTER (WHERE reviewed = false)::BIGINT as unreviewed 45 | FROM leets.chat_flagged_messages; 46 | END; 47 | $$ LANGUAGE plpgsql; 48 | 49 | -- Create function to get user offense history 50 | CREATE OR REPLACE FUNCTION leets.get_user_offenses(p_username TEXT) 51 | RETURNS TABLE( 52 | total_offenses BIGINT, 53 | high_severity_count BIGINT, 54 | medium_severity_count BIGINT, 55 | low_severity_count BIGINT, 56 | latest_offense TIMESTAMP 57 | ) AS $$ 58 | BEGIN 59 | RETURN QUERY 60 | SELECT 61 | COUNT(*)::BIGINT as total_offenses, 62 | COUNT(*) FILTER (WHERE severity = 'high')::BIGINT as high_severity_count, 63 | COUNT(*) FILTER (WHERE severity = 'medium')::BIGINT as medium_severity_count, 64 | COUNT(*) FILTER (WHERE severity = 'low')::BIGINT as low_severity_count, 65 | MAX(created_at) as latest_offense 66 | FROM leets.chat_flagged_messages 67 | WHERE username = p_username; 68 | END; 69 | $$ LANGUAGE plpgsql; 70 | 71 | -- Auto-cleanup function for old flagged messages (keep for 90 days) 72 | CREATE OR REPLACE FUNCTION leets.cleanup_old_flagged_messages() 73 | RETURNS void AS $$ 74 | BEGIN 75 | DELETE FROM leets.chat_flagged_messages 76 | WHERE created_at < NOW() - INTERVAL '90 days' 77 | AND reviewed = true; 78 | END; 79 | $$ LANGUAGE plpgsql; 80 | 81 | -- Comment on table 82 | COMMENT ON TABLE leets.chat_flagged_messages IS 'Stores flagged messages for moderation and audit purposes. Messages are kept for 90 days after review.'; 83 | -------------------------------------------------------------------------------- /src/component/layout/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import Image from "next/image"; 4 | import Particles from "@/component/landing/particels"; 5 | import { Navbar } from "@/component/navbar/navbar"; 6 | import { usePathname } from "next/navigation"; 7 | 8 | const Layout = ({ children }: { children: React.ReactNode }) => { 9 | const pathname = usePathname(); 10 | return ( 11 |
12 | {pathname !== "/" && } 13 | {children} 14 | 15 |
16 |
17 |
18 | 19 |
23 | 33 |
34 | 35 | {pathname === "/" && ( 36 |
37 |
38 |
39 | Creator 48 |
49 |
50 | 51 | Created with 52 | 53 | ❤️ 54 | by 55 | 57 | window.open("https://profile.intra.42.fr/users/mmaghri") 58 | } 59 | className="cursor-pointer text-xs text-blue-300 font-Tektur underline font-medium" 60 | > 61 | mmaghri 62 | 63 |
64 |
65 | 66 |
67 |
68 |
69 | Mohammed Maghri 70 |
71 |
72 | Full Stack Developer 73 |
74 |
75 |
76 |
77 | )} 78 |
79 | ); 80 | }; 81 | 82 | export { Layout }; 83 | -------------------------------------------------------------------------------- /src/app/api/notifications/mark-seen/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import * as jose from "jose"; 3 | import pg from "pg"; 4 | import { rateLimit, RateLimitPresets } from "@/utils/rateLimit"; 5 | 6 | const { Pool } = pg; 7 | 8 | const pool = new Pool({ 9 | connectionString: process.env.DATABASE_KEY, 10 | }); 11 | 12 | export async function POST(request: NextRequest) { 13 | // Rate limiting: 30 requests per minute 14 | const rateLimitResult = await rateLimit(request, RateLimitPresets.STANDARD); 15 | if (rateLimitResult) return rateLimitResult; 16 | 17 | try { 18 | const user = request.cookies.get("auth_code"); 19 | 20 | if (!user || !user.value) { 21 | return NextResponse.json( 22 | { error: "Authentication required" }, 23 | { status: 401 } 24 | ); 25 | } 26 | 27 | const secret = new TextEncoder().encode(process.env.SECRET_KEY as string); 28 | const userToken = user.value; 29 | 30 | // Verify JWT token 31 | try { 32 | await jose.jwtVerify(userToken, secret); 33 | } catch (jwtError) { 34 | console.error("JWT verification failed:", jwtError); 35 | return NextResponse.json( 36 | { error: "Invalid or expired token" }, 37 | { status: 401 } 38 | ); 39 | } 40 | 41 | // Decode and decrypt token 42 | const decodedToken = jose.decodeJwt(userToken); 43 | if (!decodedToken.token) { 44 | return NextResponse.json( 45 | { error: "Invalid token structure" }, 46 | { status: 401 } 47 | ); 48 | } 49 | 50 | // Get user ID from JWT token (no 42 API call needed!) 51 | const userId = decodedToken.userId; 52 | 53 | if (!userId) { 54 | return NextResponse.json( 55 | { error: "Token outdated. Please log in again." }, 56 | { status: 401 } 57 | ); 58 | } 59 | 60 | // Get notification_id from request body 61 | const body = await request.json(); 62 | const { notification_id, mark_all } = body; 63 | 64 | if (mark_all) { 65 | // Mark all notifications as seen for this user 66 | const allNotificationsQuery = ` 67 | SELECT id FROM notifications 68 | WHERE (target_type = 'all' OR target_user_id = $1) 69 | AND (expires_at IS NULL OR expires_at > NOW()) 70 | `; 71 | const allNotifications = await pool.query(allNotificationsQuery, [userId]); 72 | 73 | for (const notif of allNotifications.rows) { 74 | await pool.query( 75 | `INSERT INTO user_notification_reads (notification_id, user_id) 76 | VALUES ($1, $2) 77 | ON CONFLICT (notification_id, user_id) DO NOTHING`, 78 | [notif.id, userId] 79 | ); 80 | } 81 | 82 | return NextResponse.json({ 83 | success: true, 84 | message: "All notifications marked as seen" 85 | }); 86 | } else if (notification_id) { 87 | // Mark specific notification as seen 88 | await pool.query( 89 | `INSERT INTO user_notification_reads (notification_id, user_id) 90 | VALUES ($1, $2) 91 | ON CONFLICT (notification_id, user_id) DO NOTHING`, 92 | [notification_id, userId] 93 | ); 94 | 95 | return NextResponse.json({ 96 | success: true, 97 | message: "Notification marked as seen" 98 | }); 99 | } else { 100 | return NextResponse.json( 101 | { error: "Missing notification_id or mark_all parameter" }, 102 | { status: 400 } 103 | ); 104 | } 105 | } catch (error) { 106 | console.error("Error marking notification as seen:", error); 107 | return NextResponse.json( 108 | { error: "Internal server error" }, 109 | { status: 500 } 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { 4 | RankComponent, 5 | StatusGrid, 6 | PoolInformation, 7 | CampusInformation, 8 | ContactInformation, 9 | } from "@/component/dashboard/dashboard"; 10 | import { ContextCreator } from "@/component/context/context"; 11 | import { ContextProps, UserData } from "@/component/context/context.types"; 12 | import { TbSkiJumping } from "react-icons/tb"; 13 | import { fetchVIPUsers } from "@/component/dashboard/dashboard.types"; 14 | import { useRouter } from "next/navigation"; 15 | import { BsStars } from "react-icons/bs"; 16 | 17 | const Dashboard = () => { 18 | const { userData } = React.useContext(ContextCreator) as ContextProps; 19 | const router = useRouter(); 20 | const [isCreator, setIsCreator] = React.useState(false); 21 | 22 | // Fetch VIP users on component mount 23 | React.useEffect(() => { 24 | fetchVIPUsers(); 25 | }, []); 26 | 27 | // Check if user is a creator 28 | React.useEffect(() => { 29 | const checkCreatorStatus = async () => { 30 | try { 31 | const response = await fetch("/api/check-creator", { 32 | method: "GET", 33 | credentials: "include", 34 | }); 35 | 36 | if (response.ok) { 37 | const data = await response.json(); 38 | setIsCreator(data.isCreator || false); 39 | } 40 | } catch (error) { 41 | console.error("Error checking creator status:", error); 42 | } 43 | }; 44 | 45 | if (userData) { 46 | checkCreatorStatus(); 47 | } 48 | }, [userData]); 49 | 50 | return ( 51 |
52 | {/* Creator Button */} 53 | {isCreator && ( 54 |
55 | 63 |
64 | )} 65 | 66 |
67 | 68 |
69 | 70 |
71 | {userData != null ? ( 72 | <> 73 | 79 | 84 | 88 | 89 | 90 | ) : ( 91 |
92 |
93 | 94 |
95 |
96 | )} 97 |
98 |
99 | ); 100 | }; 101 | 102 | export default Dashboard; 103 | -------------------------------------------------------------------------------- /src/component/RateLimitPopup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useState } from "react"; 3 | import { motion, AnimatePresence } from "motion/react"; 4 | 5 | interface RateLimitPopupProps { 6 | show: boolean; 7 | onClose: () => void; 8 | retryAfter?: number; 9 | } 10 | 11 | const RateLimitPopup: React.FC = ({ show, onClose, retryAfter }) => { 12 | const [countdown, setCountdown] = useState(retryAfter || 60); 13 | 14 | useEffect(() => { 15 | if (show && retryAfter) { 16 | setCountdown(retryAfter); 17 | 18 | const timer = setInterval(() => { 19 | setCountdown((prev) => { 20 | if (prev <= 1) { 21 | clearInterval(timer); 22 | onClose(); 23 | return 0; 24 | } 25 | return prev - 1; 26 | }); 27 | }, 1000); 28 | 29 | return () => clearInterval(timer); 30 | } 31 | }, [show, retryAfter, onClose]); 32 | 33 | return ( 34 | 35 | {show && ( 36 | <> 37 | {/* Backdrop */} 38 | 45 | 46 | {/* Popup */} 47 | 54 |
55 | {/* Icon */} 56 | 62 | 😎 63 | 64 | 65 | {/* Title */} 66 |

67 | Take it easy bro! 68 |

69 | 70 | {/* Message */} 71 |

72 | You're making too many requests. Please slow down and try again in a moment. 73 |

74 | 75 | {/* Countdown */} 76 |
77 |
78 |
79 | 80 | {countdown} 81 | 82 |
83 | seconds remaining 84 |
85 |
86 | 87 | {/* Close Button */} 88 | 94 |
95 |
96 | 97 | )} 98 |
99 | ); 100 | }; 101 | 102 | export default RateLimitPopup; 103 | -------------------------------------------------------------------------------- /src/utils/profileCache.ts: -------------------------------------------------------------------------------- 1 | interface CacheEntry { 2 | data: string; 3 | timestamp: number; 4 | } 5 | 6 | class ProfileCache { 7 | private cache: Map = new Map(); 8 | private readonly TTL = 60 * 60 * 1000; 9 | 10 | set(login: string, profilePicture: string): void { 11 | this.cache.set(login, { 12 | data: profilePicture, 13 | timestamp: Date.now(), 14 | }); 15 | } 16 | 17 | get(login: string): string | null { 18 | const entry = this.cache.get(login); 19 | if (!entry) return null; 20 | 21 | if (Date.now() - entry.timestamp > this.TTL) { 22 | this.cache.delete(login); 23 | return null; 24 | } 25 | 26 | return entry.data; 27 | } 28 | 29 | has(login: string): boolean { 30 | const entry = this.cache.get(login); 31 | if (!entry) return false; 32 | 33 | if (Date.now() - entry.timestamp > this.TTL) { 34 | this.cache.delete(login); 35 | return false; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | setMany(profiles: Map): void { 42 | const now = Date.now(); 43 | profiles.forEach((profilePicture, login) => { 44 | this.cache.set(login, { 45 | data: profilePicture, 46 | timestamp: now, 47 | }); 48 | }); 49 | } 50 | 51 | getMany(logins: string[]): Map { 52 | const result = new Map(); 53 | logins.forEach(login => { 54 | const value = this.get(login); 55 | if (value !== null) { 56 | result.set(login, value); 57 | } 58 | }); 59 | return result; 60 | } 61 | 62 | getMissingLogins(logins: string[]): string[] { 63 | return logins.filter(login => !this.has(login)); 64 | } 65 | 66 | clear(): void { 67 | this.cache.clear(); 68 | } 69 | 70 | size(): number { 71 | return this.cache.size; 72 | } 73 | } 74 | 75 | interface GenericCacheEntry { 76 | data: T; 77 | timestamp: number; 78 | } 79 | 80 | class GenericCache { 81 | private cache: Map> = new Map(); 82 | private readonly TTL: number; 83 | 84 | constructor(ttlMinutes: number) { 85 | this.TTL = ttlMinutes * 60 * 1000; 86 | } 87 | 88 | set(key: string, data: T): void { 89 | this.cache.set(key, { 90 | data, 91 | timestamp: Date.now(), 92 | }); 93 | } 94 | 95 | get(key: string): T | null { 96 | const entry = this.cache.get(key); 97 | if (!entry) return null; 98 | 99 | if (Date.now() - entry.timestamp > this.TTL) { 100 | this.cache.delete(key); 101 | return null; 102 | } 103 | 104 | return entry.data; 105 | } 106 | 107 | has(key: string): boolean { 108 | const entry = this.cache.get(key); 109 | if (!entry) return false; 110 | 111 | if (Date.now() - entry.timestamp > this.TTL) { 112 | this.cache.delete(key); 113 | return false; 114 | } 115 | 116 | return true; 117 | } 118 | 119 | clear(): void { 120 | this.cache.clear(); 121 | } 122 | 123 | size(): number { 124 | return this.cache.size; 125 | } 126 | 127 | getAge(key: string): number | null { 128 | const entry = this.cache.get(key); 129 | if (!entry) return null; 130 | return Math.floor((Date.now() - entry.timestamp) / (60 * 1000)); 131 | } 132 | } 133 | 134 | // Global singleton instances - shared across all requests 135 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 136 | const globalForCache = global as typeof globalThis & { 137 | profileCache?: ProfileCache; 138 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 139 | progressCache?: GenericCache; 140 | }; 141 | 142 | export const profileCache = globalForCache.profileCache ?? new ProfileCache(); 143 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 144 | export const progressCache = globalForCache.progressCache ?? new GenericCache(20); 145 | 146 | // Ensure singletons persist across hot reloads in development 147 | if (process.env.NODE_ENV !== 'production') { 148 | globalForCache.profileCache = profileCache; 149 | globalForCache.progressCache = progressCache; 150 | } 151 | 152 | // Log cache initialization 153 | console.log('🔧 Cache initialized - Profile TTL: 1hr, Progress TTL: 20min'); 154 | -------------------------------------------------------------------------------- /src/app/api/cv-maker/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | import * as jose from "jose"; 3 | import { DecryptionFunction } from "../auth/type.auth"; 4 | 5 | export const GET = async (request: NextRequest) => { 6 | try { 7 | await jose.jwtVerify( 8 | request.cookies.get("auth_code")?.value as string, 9 | new TextEncoder().encode(process.env.SECRET_KEY as string) 10 | ); 11 | 12 | const whoUrl = new URL("/api/who", request.url); 13 | const fetchme = await fetch(whoUrl, { 14 | method: "GET", 15 | headers: { 16 | "Content-Type": "application/json", 17 | Cookie: `auth_code=${request.cookies.get("auth_code")?.value};`, 18 | }, 19 | credentials: "include", 20 | }); 21 | 22 | if (!fetchme.ok) { 23 | throw new Error("Failed to fetch user data"); 24 | } 25 | 26 | const userData = await fetchme.json(); 27 | 28 | const authCookie = request.cookies.get("auth_code")?.value; 29 | if (!authCookie) { 30 | throw new Error("No auth_code cookie found"); 31 | } 32 | 33 | const decodedJwt = jose.decodeJwt(authCookie); 34 | const decryptedToken = DecryptionFunction(decodedJwt.token as string); 35 | 36 | if (!decryptedToken || decryptedToken.trim() === "") { 37 | throw new Error("Decrypted token is empty or invalid"); 38 | } 39 | 40 | const userProjects = await fetch( 41 | `${process.env.INTRA_TOKEN}/v2/users/${userData.login}/projects_users?page[size]=100`, 42 | { 43 | method: "GET", 44 | headers: { 45 | "Content-Type": "application/json", 46 | Authorization: `Bearer ${decryptedToken}`, 47 | }, 48 | } 49 | ); 50 | 51 | if (!userProjects.ok) { 52 | throw new Error("Failed to fetch projects"); 53 | } 54 | 55 | const projects = await userProjects.json(); 56 | 57 | const validatedProjects = projects 58 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 59 | .filter((p: any) => p.validated === true && p.final_mark >= 80) 60 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 61 | .map((p: any) => ({ 62 | name: p.project.name, 63 | description: p.project.slug || "", 64 | final_mark: p.final_mark, 65 | validated: p.validated, 66 | })) 67 | .slice(0, 10); 68 | 69 | const userSkills = await fetch( 70 | `${process.env.INTRA_TOKEN}/v2/users/${userData.login}/cursus_users`, 71 | { 72 | method: "GET", 73 | headers: { 74 | "Content-Type": "application/json", 75 | Authorization: `Bearer ${decryptedToken}`, 76 | }, 77 | } 78 | ); 79 | 80 | let skills: string[] = []; 81 | let level = 0; 82 | 83 | if (userSkills.ok) { 84 | const cursusData = await userSkills.json(); 85 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 86 | const cursus21 = cursusData.find((c: any) => c.cursus_id === 21); 87 | 88 | if (cursus21) { 89 | level = cursus21.level || 0; 90 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 91 | skills = cursus21.skills?.slice(0, 10).map((s: any) => s.name) || []; 92 | } 93 | } 94 | 95 | const cvData = { 96 | name: userData.usual_full_name || userData.displayname, 97 | email: userData.email, 98 | location: userData.campus_name || "42 Network", 99 | image: userData.image?.link, 100 | title: `Level ${level.toFixed(2)} Software Developer`, 101 | level: level.toFixed(2), 102 | about: `Passionate software developer with expertise in modern technologies. Currently at level ${level.toFixed(2)} in the 42 curriculum, with a strong foundation in programming and problem-solving.`, 103 | projects: validatedProjects, 104 | skills: skills, 105 | languages: [ 106 | { name: "English", level: "Professional" }, 107 | { name: "French", level: "Native" }, 108 | ], 109 | }; 110 | 111 | return NextResponse.json(cvData, { status: 200 }); 112 | } catch (error) { 113 | console.error("Error in CV maker route:", error); 114 | return NextResponse.json( 115 | { error: "Internal Server Error: " + (error instanceof Error ? error.message : String(error)) }, 116 | { status: 500 } 117 | ); 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /src/app/api/notifications/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import * as jose from "jose"; 3 | import pg from "pg"; 4 | import { rateLimit, RateLimitPresets } from "@/utils/rateLimit"; 5 | 6 | const { Pool } = pg; 7 | 8 | const pool = new Pool({ 9 | connectionString: process.env.DATABASE_KEY, 10 | }); 11 | 12 | export async function GET(request: NextRequest) { 13 | // Rate limiting: 60 requests per minute 14 | const rateLimitResult = await rateLimit(request, RateLimitPresets.RELAXED); 15 | if (rateLimitResult) return rateLimitResult; 16 | 17 | try { 18 | const user = request.cookies.get("auth_code"); 19 | 20 | if (!user || !user.value) { 21 | return NextResponse.json( 22 | { error: "Authentication required" }, 23 | { status: 401 } 24 | ); 25 | } 26 | 27 | const secret = new TextEncoder().encode(process.env.SECRET_KEY as string); 28 | const userToken = user.value; 29 | 30 | // Verify JWT token 31 | try { 32 | await jose.jwtVerify(userToken, secret); 33 | } catch (jwtError) { 34 | console.error("JWT verification failed:", jwtError); 35 | return NextResponse.json( 36 | { error: "Invalid or expired token" }, 37 | { status: 401 } 38 | ); 39 | } 40 | 41 | // Decode and decrypt token 42 | const decodedToken = jose.decodeJwt(userToken); 43 | if (!decodedToken.token) { 44 | return NextResponse.json( 45 | { error: "Invalid token structure" }, 46 | { status: 401 } 47 | ); 48 | } 49 | 50 | // Get user ID from JWT token (no 42 API call needed!) 51 | const userId = decodedToken.userId; 52 | 53 | if (!userId) { 54 | // Old JWT without userId - force re-login 55 | return NextResponse.json( 56 | { error: "Token outdated. Please log in again." }, 57 | { status: 401 } 58 | ); 59 | } 60 | 61 | // Check if notifications table exists, if not return empty 62 | const tableCheckQuery = ` 63 | SELECT EXISTS ( 64 | SELECT FROM information_schema.tables 65 | WHERE table_name = 'notifications' 66 | ); 67 | `; 68 | 69 | const tableExists = await pool.query(tableCheckQuery); 70 | 71 | if (!tableExists.rows[0].exists) { 72 | return NextResponse.json({ 73 | notifications: [], 74 | unread_count: 0, 75 | }); 76 | } 77 | 78 | // Get notifications for this user (both 'all' and specific to user) 79 | const notificationsQuery = ` 80 | SELECT 81 | n.*, 82 | CASE 83 | WHEN unr.user_id IS NOT NULL THEN true 84 | ELSE false 85 | END as is_seen 86 | FROM notifications n 87 | LEFT JOIN user_notification_reads unr 88 | ON n.id = unr.notification_id AND unr.user_id = $1 89 | WHERE 90 | (n.target_type = 'all' OR n.target_user_id = $1) 91 | AND (n.expires_at IS NULL OR n.expires_at > NOW()) 92 | ORDER BY n.created_at DESC 93 | LIMIT 50 94 | `; 95 | 96 | const notificationsResult = await pool.query(notificationsQuery, [userId]); 97 | 98 | // Count unread notifications 99 | const unreadCountQuery = ` 100 | SELECT COUNT(*) as count 101 | FROM notifications n 102 | LEFT JOIN user_notification_reads unr 103 | ON n.id = unr.notification_id AND unr.user_id = $1 104 | WHERE 105 | (n.target_type = 'all' OR n.target_user_id = $1) 106 | AND (n.expires_at IS NULL OR n.expires_at > NOW()) 107 | AND unr.user_id IS NULL 108 | `; 109 | 110 | const unreadCountResult = await pool.query(unreadCountQuery, [userId]); 111 | const unreadCount = parseInt(unreadCountResult.rows[0].count); 112 | 113 | return NextResponse.json({ 114 | notifications: notificationsResult.rows, 115 | unread_count: unreadCount, 116 | }); 117 | } catch (error) { 118 | console.error("Error fetching notifications:", error); 119 | console.error("Error details:", error instanceof Error ? error.message : error); 120 | return NextResponse.json( 121 | { 122 | error: "Internal server error", 123 | details: error instanceof Error ? error.message : "Unknown error", 124 | notifications: [], 125 | unread_count: 0 126 | }, 127 | { status: 500 } 128 | ); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/app/api/notifications/create/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { DecryptionFunction } from "../../auth/type.auth"; 3 | import * as jose from "jose"; 4 | import pg from "pg"; 5 | import { rateLimit } from "@/utils/rateLimit"; 6 | 7 | const { Pool } = pg; 8 | 9 | const pool = new Pool({ 10 | connectionString: process.env.DATABASE_KEY, 11 | }); 12 | 13 | export async function POST(request: NextRequest) { 14 | // Rate limiting: 10 requests per minute (strict - prevents notification spam) 15 | const rateLimitResult = await rateLimit(request, { maxRequests: 10, windowMs: 60000 }); 16 | if (rateLimitResult) return rateLimitResult; 17 | 18 | try { 19 | const user = request.cookies.get("auth_code"); 20 | 21 | if (!user || !user.value) { 22 | return NextResponse.json( 23 | { error: "Authentication required" }, 24 | { status: 401 } 25 | ); 26 | } 27 | 28 | const secret = new TextEncoder().encode(process.env.SECRET_KEY as string); 29 | const userToken = user.value; 30 | 31 | // Verify JWT token 32 | try { 33 | await jose.jwtVerify(userToken, secret); 34 | } catch (jwtError) { 35 | console.error("JWT verification failed:", jwtError); 36 | return NextResponse.json( 37 | { error: "Invalid or expired token" }, 38 | { status: 401 } 39 | ); 40 | } 41 | 42 | // Decode and decrypt token 43 | const decodedToken = jose.decodeJwt(userToken); 44 | if (!decodedToken.token) { 45 | return NextResponse.json( 46 | { error: "Invalid token structure" }, 47 | { status: 401 } 48 | ); 49 | } 50 | 51 | const accessToken = DecryptionFunction(decodedToken.token as string); 52 | 53 | // Fetch user data from 42 API to check if admin 54 | const userResponse = await fetch((process.env.INTRA_TOKEN as string) + "/v2/me", { 55 | method: "GET", 56 | headers: { 57 | Authorization: `Bearer ${accessToken}`, 58 | }, 59 | credentials: "include", 60 | }); 61 | 62 | if (!userResponse.ok) { 63 | return NextResponse.json( 64 | { error: "Failed to fetch user data" }, 65 | { status: 401 } 66 | ); 67 | } 68 | 69 | const userData = await userResponse.json(); 70 | const userId = userData.id; 71 | 72 | // Check if user is admin 73 | const adminCheck = await pool.query( 74 | "SELECT * FROM users WHERE id = $1 AND is_admin = true", 75 | [userId] 76 | ); 77 | 78 | if (adminCheck.rows.length === 0) { 79 | return NextResponse.json( 80 | { error: "Forbidden - Admin access required" }, 81 | { status: 403 } 82 | ); 83 | } 84 | 85 | // Get notification data from request body 86 | const body = await request.json(); 87 | const { 88 | title, 89 | message, 90 | type = 'info', 91 | target_type, 92 | target_user_id = null, 93 | expires_at = null, 94 | link = null, 95 | sender_username = 'mmaghri', 96 | sender_image = 'https://cdn.intra.42.fr/users/83b4706433bb90d165a91eafb7c9bb86/large_mmaghri.jpg' 97 | } = body; 98 | 99 | // Validate required fields 100 | if (!title || !message || !target_type) { 101 | return NextResponse.json( 102 | { error: "Missing required fields: title, message, target_type" }, 103 | { status: 400 } 104 | ); 105 | } 106 | 107 | // Validate target_type 108 | if (target_type !== 'all' && target_type !== 'specific') { 109 | return NextResponse.json( 110 | { error: "target_type must be 'all' or 'specific'" }, 111 | { status: 400 } 112 | ); 113 | } 114 | 115 | // If specific, target_user_id is required 116 | if (target_type === 'specific' && !target_user_id) { 117 | return NextResponse.json( 118 | { error: "target_user_id is required when target_type is 'specific'" }, 119 | { status: 400 } 120 | ); 121 | } 122 | 123 | // Insert notification 124 | const insertQuery = ` 125 | INSERT INTO notifications ( 126 | title, message, type, target_type, target_user_id, expires_at, link, sender_username, sender_image 127 | ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 128 | RETURNING * 129 | `; 130 | 131 | const result = await pool.query(insertQuery, [ 132 | title, 133 | message, 134 | type, 135 | target_type, 136 | target_user_id, 137 | expires_at, 138 | link, 139 | sender_username, 140 | sender_image 141 | ]); 142 | 143 | return NextResponse.json({ 144 | success: true, 145 | notification: result.rows[0], 146 | message: "Notification created successfully" 147 | }); 148 | } catch (error) { 149 | console.error("Error creating notification:", error); 150 | return NextResponse.json( 151 | { error: "Internal server error" }, 152 | { status: 500 } 153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /scripts/migrate-moderation.js: -------------------------------------------------------------------------------- 1 | const { Pool } = require('pg'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | 5 | // Load .env.local file manually 6 | const envPath = path.join(__dirname, '..', '.env.local'); 7 | if (fs.existsSync(envPath)) { 8 | const envContent = fs.readFileSync(envPath, 'utf8'); 9 | envContent.split('\n').forEach(line => { 10 | const match = line.match(/^([^=:#]+)=(.*)$/); 11 | if (match) { 12 | const key = match[1].trim(); 13 | const value = match[2].trim(); 14 | process.env[key] = value; 15 | } 16 | }); 17 | } 18 | 19 | const pool = new Pool({ 20 | connectionString: process.env.DATABASE_KEY, 21 | }); 22 | 23 | async function runMigration() { 24 | const client = await pool.connect(); 25 | 26 | try { 27 | console.log('🔄 Starting moderation system migration...'); 28 | 29 | // Create chat_flagged_messages table 30 | await client.query(` 31 | CREATE TABLE IF NOT EXISTS leets.chat_flagged_messages ( 32 | id SERIAL PRIMARY KEY, 33 | message_id TEXT NOT NULL UNIQUE, 34 | message TEXT NOT NULL, 35 | username TEXT NOT NULL, 36 | avatar TEXT, 37 | level NUMERIC, 38 | campus TEXT, 39 | blocked BOOLEAN DEFAULT false, 40 | categories TEXT[], 41 | matched_terms TEXT[], 42 | severity TEXT CHECK (severity IN ('low', 'medium', 'high')), 43 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 44 | reviewed BOOLEAN DEFAULT false, 45 | reviewed_at TIMESTAMP, 46 | reviewed_by TEXT, 47 | action_taken TEXT, 48 | FOREIGN KEY (message_id) REFERENCES leets.chat_messages(message_id) ON DELETE CASCADE 49 | ); 50 | `); 51 | console.log('✅ Table leets.chat_flagged_messages created successfully'); 52 | 53 | // Create indexes 54 | await client.query(` 55 | CREATE INDEX IF NOT EXISTS idx_flagged_messages_username ON leets.chat_flagged_messages(username); 56 | `); 57 | console.log('✅ Index on username created'); 58 | 59 | await client.query(` 60 | CREATE INDEX IF NOT EXISTS idx_flagged_messages_severity ON leets.chat_flagged_messages(severity); 61 | `); 62 | console.log('✅ Index on severity created'); 63 | 64 | await client.query(` 65 | CREATE INDEX IF NOT EXISTS idx_flagged_messages_reviewed ON leets.chat_flagged_messages(reviewed); 66 | `); 67 | console.log('✅ Index on reviewed created'); 68 | 69 | await client.query(` 70 | CREATE INDEX IF NOT EXISTS idx_flagged_messages_created_at ON leets.chat_flagged_messages(created_at DESC); 71 | `); 72 | console.log('✅ Index on created_at created'); 73 | 74 | // Create functions 75 | await client.query(` 76 | CREATE OR REPLACE FUNCTION leets.get_flagged_stats() 77 | RETURNS TABLE( 78 | total_flagged BIGINT, 79 | high_severity BIGINT, 80 | medium_severity BIGINT, 81 | low_severity BIGINT, 82 | unreviewed BIGINT 83 | ) AS $$ 84 | BEGIN 85 | RETURN QUERY 86 | SELECT 87 | COUNT(*)::BIGINT as total_flagged, 88 | COUNT(*) FILTER (WHERE severity = 'high')::BIGINT as high_severity, 89 | COUNT(*) FILTER (WHERE severity = 'medium')::BIGINT as medium_severity, 90 | COUNT(*) FILTER (WHERE severity = 'low')::BIGINT as low_severity, 91 | COUNT(*) FILTER (WHERE reviewed = false)::BIGINT as unreviewed 92 | FROM leets.chat_flagged_messages; 93 | END; 94 | $$ LANGUAGE plpgsql; 95 | `); 96 | console.log('✅ Function get_flagged_stats created'); 97 | 98 | await client.query(` 99 | CREATE OR REPLACE FUNCTION leets.get_user_offenses(p_username TEXT) 100 | RETURNS TABLE( 101 | total_offenses BIGINT, 102 | high_severity_count BIGINT, 103 | medium_severity_count BIGINT, 104 | low_severity_count BIGINT, 105 | latest_offense TIMESTAMP 106 | ) AS $$ 107 | BEGIN 108 | RETURN QUERY 109 | SELECT 110 | COUNT(*)::BIGINT as total_offenses, 111 | COUNT(*) FILTER (WHERE severity = 'high')::BIGINT as high_severity_count, 112 | COUNT(*) FILTER (WHERE severity = 'medium')::BIGINT as medium_severity_count, 113 | COUNT(*) FILTER (WHERE severity = 'low')::BIGINT as low_severity_count, 114 | MAX(created_at) as latest_offense 115 | FROM leets.chat_flagged_messages 116 | WHERE username = p_username; 117 | END; 118 | $$ LANGUAGE plpgsql; 119 | `); 120 | console.log('✅ Function get_user_offenses created'); 121 | 122 | // Verify table exists 123 | const result = await client.query(` 124 | SELECT COUNT(*) as count FROM leets.chat_flagged_messages; 125 | `); 126 | console.log(`✅ Migration completed successfully! Current flagged messages count: ${result.rows[0].count}`); 127 | 128 | } catch (error) { 129 | console.error('❌ Migration failed:', error.message); 130 | throw error; 131 | } finally { 132 | client.release(); 133 | await pool.end(); 134 | } 135 | } 136 | 137 | runMigration() 138 | .then(() => { 139 | console.log('🎉 Moderation system ready!'); 140 | process.exit(0); 141 | }) 142 | .catch((error) => { 143 | console.error('💥 Fatal error:', error); 144 | process.exit(1); 145 | }); 146 | -------------------------------------------------------------------------------- /src/component/badges/UserBadges.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState, useEffect } from "react"; 3 | import { BsStars, BsDiamond } from "react-icons/bs"; 4 | import { FaCrown } from "react-icons/fa"; 5 | 6 | interface UserBadgesProps { 7 | login?: string; 8 | vipStatus?: string | null; 9 | badges?: string[]; 10 | size?: "sm" | "md" | "lg"; 11 | showTooltip?: boolean; 12 | } 13 | 14 | interface BadgeData { 15 | vipStatus: string | null; 16 | badges: string[]; 17 | } 18 | 19 | export const UserBadges: React.FC = ({ 20 | login, 21 | vipStatus: propVipStatus, 22 | badges: propBadges, 23 | size = "md", 24 | showTooltip = true 25 | }) => { 26 | const [badgeData, setBadgeData] = useState(null); 27 | const [isLoading, setIsLoading] = useState(!propVipStatus && !propBadges); 28 | 29 | useEffect(() => { 30 | // If badges are provided as props, use them directly 31 | if (propVipStatus !== undefined || propBadges !== undefined) { 32 | setBadgeData({ 33 | vipStatus: propVipStatus || null, 34 | badges: propBadges || [] 35 | }); 36 | setIsLoading(false); 37 | } else if (login) { 38 | // Otherwise fetch from API 39 | fetchBadges(); 40 | } 41 | }, [login, propVipStatus, propBadges]); 42 | 43 | const fetchBadges = async () => { 44 | if (!login) return; 45 | 46 | try { 47 | const response = await fetch(`/api/user-badges?login=${login}`); 48 | if (response.ok) { 49 | const data = await response.json(); 50 | setBadgeData(data); 51 | } 52 | } catch (error) { 53 | console.error("Error fetching badges:", error); 54 | } finally { 55 | setIsLoading(false); 56 | } 57 | }; 58 | 59 | const getBadgeColor = (badgeType: string) => { 60 | switch (badgeType) { 61 | case "Contributor": 62 | return "bg-emerald-500 border-emerald-400"; 63 | case "Top Feedback": 64 | return "bg-yellow-500 border-yellow-400"; 65 | case "Helpful": 66 | return "bg-blue-500 border-blue-400"; 67 | case "Innovative": 68 | return "bg-purple-500 border-purple-400"; 69 | case "Critical Thinker": 70 | return "bg-orange-500 border-orange-400"; 71 | default: 72 | return "bg-gray-500 border-gray-400"; 73 | } 74 | }; 75 | 76 | const sizeClasses = { 77 | sm: "w-5 h-5", 78 | md: "w-6 h-6", 79 | lg: "w-8 h-8" 80 | }; 81 | 82 | const iconSizeClasses = { 83 | sm: "w-2.5 h-2.5", 84 | md: "w-3 h-3", 85 | lg: "w-4 h-4" 86 | }; 87 | 88 | if (isLoading || !badgeData) { 89 | return null; 90 | } 91 | 92 | return ( 93 |
94 | {/* Creator Badge - Special Crown */} 95 | {badgeData.vipStatus === "creator" && ( 96 |
97 |
98 | 99 |
100 | {showTooltip && ( 101 |
102 | Creator 103 |
104 |
105 | )} 106 |
107 | )} 108 | 109 | {/* VIP Badge - Diamond */} 110 | {badgeData.vipStatus === "vip" && ( 111 |
112 |
113 | 114 |
115 | {showTooltip && ( 116 |
117 | VIP Member 118 |
119 |
120 | )} 121 |
122 | )} 123 | 124 | {/* Feedback Badges */} 125 | {badgeData.badges.map((badge, index) => ( 126 |
127 |
128 | 129 |
130 | {showTooltip && ( 131 |
132 | {badge} 133 |
134 |
135 | )} 136 |
137 | ))} 138 |
139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /src/utils/moderationUtils.ts: -------------------------------------------------------------------------------- 1 | import badWordsData from '../../bad_words.json'; 2 | 3 | interface ModerationResult { 4 | blocked: boolean; 5 | categories?: string[]; 6 | matched_terms?: string[]; 7 | severity?: 'low' | 'medium' | 'high'; 8 | } 9 | 10 | interface BadWordsDataset { 11 | profanity: string[]; 12 | sexual: string[]; 13 | arabic: string[]; 14 | darija_franco: string[]; 15 | hate_speech: string[]; 16 | phrases: string[]; 17 | religious_insults: string[]; 18 | emoji_insults: string[]; 19 | } 20 | 21 | const badWords = badWordsData as BadWordsDataset; 22 | 23 | // Normalize a single word for detection (handle leet speak and obfuscation) 24 | function normalizeWord(word: string): string { 25 | return word 26 | .toLowerCase() 27 | .replace(/[.\-_]+/g, '') // Remove dots, dashes, underscores WITHIN a word 28 | .replace(/[0@]/g, 'o') 29 | .replace(/[1!]/g, 'i') 30 | .replace(/[3]/g, 'e') 31 | .replace(/[4]/g, 'a') 32 | .replace(/[5]/g, 's') 33 | .replace(/[7]/g, 't') 34 | .replace(/[8]/g, 'b') 35 | .replace(/\*/g, ''); // Remove asterisks 36 | } 37 | 38 | // Check if text contains a bad word/phrase with fuzzy matching 39 | function containsBadWord(text: string, badWord: string): boolean { 40 | // Check original text for exact emoji matches first 41 | if (badWord.match(/[\u{1F300}-\u{1F9FF}]/u)) { 42 | return text.includes(badWord); 43 | } 44 | 45 | // If the bad word contains spaces, it's a phrase - check the whole text 46 | if (badWord.includes(' ')) { 47 | const normalizedText = text.toLowerCase().replace(/[.\-_]+/g, ''); 48 | const normalizedBadWord = badWord.toLowerCase().replace(/[.\-_]+/g, ''); 49 | return normalizedText.includes(normalizedBadWord); 50 | } 51 | 52 | // For single words: check each word in the text individually 53 | const words = text.split(/\s+/); // Split by spaces 54 | const normalizedBadWord = normalizeWord(badWord); 55 | 56 | for (const word of words) { 57 | const normalizedWord = normalizeWord(word); 58 | 59 | // Exact match after normalization (always check this) 60 | if (normalizedWord === normalizedBadWord) { 61 | return true; 62 | } 63 | 64 | // For longer bad words (4+ chars), allow substring matching 65 | // This catches "fuckhead", "shitty", etc. but avoids "mo king" matching "mok" 66 | if (normalizedBadWord.length >= 4 && normalizedWord.includes(normalizedBadWord)) { 67 | return true; 68 | } 69 | } 70 | 71 | return false; 72 | } 73 | 74 | // Check for phrase matches (multi-word patterns) 75 | function checkPhrases(text: string, phrases: string[]): string[] { 76 | const matched: string[] = []; 77 | const normalizedText = text.toLowerCase().replace(/[.\-_]+/g, ''); 78 | 79 | for (const phrase of phrases) { 80 | const normalizedPhrase = phrase.toLowerCase().replace(/[.\-_]+/g, ''); 81 | if (normalizedText.includes(normalizedPhrase)) { 82 | matched.push(phrase); 83 | } 84 | } 85 | 86 | return matched; 87 | } 88 | 89 | // Main moderation function 90 | export function moderateMessage(message: string): ModerationResult { 91 | if (!message || message.trim().length === 0) { 92 | return { blocked: false }; 93 | } 94 | 95 | const categories: string[] = []; 96 | const matchedTerms: string[] = []; 97 | 98 | // Check each category 99 | Object.entries(badWords).forEach(([category, words]) => { 100 | const categoryMatches: string[] = []; 101 | 102 | if (category === 'phrases') { 103 | // Special handling for multi-word phrases 104 | const phraseMatches = checkPhrases(message, words); 105 | if (phraseMatches.length > 0) { 106 | categoryMatches.push(...phraseMatches); 107 | } 108 | } else { 109 | // Regular word checking 110 | for (const word of words) { 111 | if (containsBadWord(message, word)) { 112 | categoryMatches.push(word); 113 | } 114 | } 115 | } 116 | 117 | if (categoryMatches.length > 0) { 118 | categories.push(category); 119 | matchedTerms.push(...categoryMatches); 120 | } 121 | }); 122 | 123 | // Determine severity 124 | let severity: 'low' | 'medium' | 'high' = 'low'; 125 | if (categories.includes('hate_speech') || categories.includes('phrases')) { 126 | severity = 'high'; 127 | } else if (categories.includes('sexual') || categories.includes('religious_insults')) { 128 | severity = 'medium'; 129 | } 130 | 131 | if (categories.length > 0) { 132 | return { 133 | blocked: true, 134 | categories: [...new Set(categories)], // Remove duplicates 135 | matched_terms: [...new Set(matchedTerms)], // Remove duplicates 136 | severity 137 | }; 138 | } 139 | 140 | return { blocked: false }; 141 | } 142 | 143 | // Sanitize message for frontend display (replace with asterisks) 144 | export function sanitizeMessage(message: string, moderationResult: ModerationResult): string { 145 | if (!moderationResult.blocked || !moderationResult.matched_terms) { 146 | return message; 147 | } 148 | 149 | let sanitized = message; 150 | 151 | for (const term of moderationResult.matched_terms) { 152 | const regex = new RegExp(term.split('').join('[\\s\\-._*]*'), 'gi'); 153 | sanitized = sanitized.replace(regex, (match) => '*'.repeat(Math.max(3, match.length))); 154 | } 155 | 156 | return sanitized; 157 | } 158 | 159 | // Store flagged message for audit 160 | export interface FlaggedMessage { 161 | id: string; 162 | message: string; 163 | username: string; 164 | timestamp: number; 165 | blocked: boolean; 166 | categories?: string[]; 167 | matched_terms?: string[]; 168 | severity?: 'low' | 'medium' | 'high'; 169 | } 170 | -------------------------------------------------------------------------------- /src/app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | import * as jose from "jose"; 3 | import { Pool } from "pg"; 4 | import { rateLimit, RateLimitPresets } from "@/utils/rateLimit"; 5 | 6 | // Database connection 7 | const pool = new Pool({ 8 | connectionString: process.env.DATABASE_KEY as string, 9 | }); 10 | 11 | // Cleanup messages older than 24 hours (runs every hour) 12 | setInterval(async () => { 13 | const client = await pool.connect(); 14 | try { 15 | await client.query( 16 | `DELETE FROM leets.chat_messages WHERE created_at < NOW() - INTERVAL '24 hours'` 17 | ); 18 | } catch (error) { 19 | console.error("Error cleaning up old messages:", error); 20 | } finally { 21 | client.release(); 22 | } 23 | }, 60 * 60 * 1000); // Run every hour 24 | 25 | // GET: Fetch all messages 26 | export async function GET(request: NextRequest) { 27 | // Rate limiting: 60 requests per minute (read operations) 28 | const rateLimitResult = await rateLimit(request, RateLimitPresets.RELAXED); 29 | if (rateLimitResult) return rateLimitResult; 30 | 31 | const client = await pool.connect(); 32 | try { 33 | const user = request.cookies.get("auth_code"); 34 | 35 | if (!user || !user.value) { 36 | return NextResponse.json( 37 | { error: "Authentication required" }, 38 | { status: 401 } 39 | ); 40 | } 41 | 42 | const secret = new TextEncoder().encode(process.env.SECRET_KEY as string); 43 | 44 | // Verify JWT token 45 | try { 46 | await jose.jwtVerify(user.value, secret); 47 | } catch (jwtError) { 48 | console.error("JWT verification failed:", jwtError); 49 | return NextResponse.json( 50 | { error: "Invalid or expired token" }, 51 | { status: 401 } 52 | ); 53 | } 54 | 55 | // Fetch messages from database (last 100 messages) 56 | const result = await client.query( 57 | `SELECT 58 | message_id as id, 59 | message, 60 | username, 61 | avatar, 62 | level, 63 | campus, 64 | EXTRACT(EPOCH FROM created_at) * 1000 as timestamp 65 | FROM leets.chat_messages 66 | ORDER BY created_at DESC 67 | LIMIT 100` 68 | ); 69 | 70 | return NextResponse.json({ messages: result.rows }, { status: 200 }); 71 | } catch (error) { 72 | console.error("Error fetching messages:", error); 73 | return NextResponse.json( 74 | { error: "Failed to fetch messages" }, 75 | { status: 500 } 76 | ); 77 | } finally { 78 | client.release(); 79 | } 80 | } 81 | 82 | // POST: Send a new message 83 | export async function POST(request: NextRequest) { 84 | // Rate limiting: 15 requests per minute (write operations) 85 | const rateLimitResult = await rateLimit(request, RateLimitPresets.WRITE); 86 | if (rateLimitResult) return rateLimitResult; 87 | 88 | const client = await pool.connect(); 89 | try { 90 | const user = request.cookies.get("auth_code"); 91 | 92 | if (!user || !user.value) { 93 | return NextResponse.json( 94 | { error: "Authentication required" }, 95 | { status: 401 } 96 | ); 97 | } 98 | 99 | const secret = new TextEncoder().encode(process.env.SECRET_KEY as string); 100 | const userToken = user.value; 101 | 102 | // Verify JWT token 103 | let userData; 104 | try { 105 | const { payload } = await jose.jwtVerify(userToken, secret); 106 | userData = payload.userData as { 107 | login: string; 108 | avatar: string; 109 | level: number; 110 | campus: string; 111 | }; 112 | 113 | if (!userData) { 114 | throw new Error("No user data in token"); 115 | } 116 | } catch (jwtError) { 117 | console.error("JWT verification failed:", jwtError); 118 | return NextResponse.json( 119 | { error: "Invalid or expired token" }, 120 | { status: 401 } 121 | ); 122 | } 123 | 124 | // Parse request body 125 | const body = await request.json(); 126 | const { message } = body; 127 | 128 | if (!message || typeof message !== "string") { 129 | return NextResponse.json( 130 | { error: "Message is required" }, 131 | { status: 400 } 132 | ); 133 | } 134 | 135 | // Sanitize message 136 | const sanitizedMessage = message.trim().slice(0, 1000); 137 | 138 | if (sanitizedMessage.length === 0) { 139 | return NextResponse.json( 140 | { error: "Message cannot be empty" }, 141 | { status: 400 } 142 | ); 143 | } 144 | 145 | // Create new message ID 146 | const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 147 | 148 | // Insert message into database 149 | const result = await client.query( 150 | `INSERT INTO leets.chat_messages (message_id, message, username, avatar, level, campus) 151 | VALUES ($1, $2, $3, $4, $5, $6) 152 | RETURNING 153 | message_id as id, 154 | message, 155 | username, 156 | avatar, 157 | level, 158 | campus, 159 | EXTRACT(EPOCH FROM created_at) * 1000 as timestamp`, 160 | [messageId, sanitizedMessage, userData.login, userData.avatar, userData.level, userData.campus] 161 | ); 162 | 163 | const newMessage = result.rows[0]; 164 | 165 | 166 | return NextResponse.json({ message: newMessage }, { status: 201 }); 167 | } catch (error) { 168 | console.error("Error posting message:", error); 169 | return NextResponse.json( 170 | { error: "Failed to post message" }, 171 | { status: 500 } 172 | ); 173 | } finally { 174 | client.release(); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /public/socket-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Socket.IO Connection Test 5 | 45 | 46 | 47 |

🔌 Socket.IO Connection Test

48 | 49 |
Status: Not Started
50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /src/component/landing/landing.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { FC } from "react"; 3 | 4 | const LandingComponent: FC = () => { 5 | const handleLogin = () => { 6 | window.location.href = process.env.NEXT_PUBLIC_REDIRECT_URL as string; 7 | }; 8 | 9 | return ( 10 | <> 11 |
15 |
16 |

17 | 1337leets 18 |

19 |
20 |

21 | Elite School Ranking 22 |

23 |
24 | 25 |
26 |

Welcome Back

27 |

28 | Access your academic dashboard 29 |

30 |
31 | 43 | 60 | 61 |
62 | 77 | 95 |
96 |
97 | 98 |
99 |

100 | Not feeling good with the update? 101 |

102 |

103 | Classic interface available 104 |

105 |
106 | 107 | ); 108 | }; 109 | 110 | export { LandingComponent }; 111 | -------------------------------------------------------------------------------- /src/app/api/who/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, NextRequest } from "next/server"; 2 | import { DecryptionFunction } from "../auth/type.auth"; 3 | import * as jose from "jose"; 4 | import { Pool } from "pg"; 5 | import { rateLimit, RateLimitPresets } from "@/utils/rateLimit"; 6 | 7 | export async function GET(request: NextRequest) { 8 | // Rate limiting: 20 requests per minute 9 | const rateLimitResult = await rateLimit(request, RateLimitPresets.STRICT); 10 | if (rateLimitResult) return rateLimitResult; 11 | 12 | try { 13 | const user = request.cookies.get("auth_code"); 14 | 15 | if (!user || !user.value) { 16 | return NextResponse.json( 17 | { error: "Authentication required" }, 18 | { status: 401 } 19 | ); 20 | } 21 | 22 | const secret = new TextEncoder().encode(process.env.SECRET_KEY as string); 23 | const userToken = user.value; 24 | 25 | try { 26 | await jose.jwtVerify(userToken, secret); 27 | } catch { 28 | return NextResponse.json( 29 | { error: "Invalid or expired token" }, 30 | { status: 401 } 31 | ); 32 | } 33 | 34 | const decodedToken = jose.decodeJwt(userToken); 35 | if (!decodedToken.token) { 36 | return NextResponse.json( 37 | { error: "Invalid token structure" }, 38 | { status: 401 } 39 | ); 40 | } 41 | 42 | // Force re-login for old JWT tokens without userId 43 | if (!decodedToken.userId || !decodedToken.campusId) { 44 | return NextResponse.json( 45 | { error: "Token outdated. Please log in again." }, 46 | { status: 401 } 47 | ); 48 | } 49 | 50 | const accessToken = DecryptionFunction(decodedToken.token as string); 51 | 52 | // Retry logic for 42 API fetch 53 | let data; 54 | let lastError; 55 | const maxRetries = 3; 56 | 57 | for (let attempt = 0; attempt < maxRetries; attempt++) { 58 | try { 59 | data = await fetch((process.env.INTRA_TOKEN as string) + "/v2/me", { 60 | method: "GET", 61 | headers: { 62 | Authorization: `Bearer ${accessToken}`, 63 | }, 64 | credentials: "include", 65 | signal: AbortSignal.timeout(10000), // 10 second timeout 66 | }); 67 | 68 | if (data.ok) { 69 | break; // Success, exit retry loop 70 | } 71 | 72 | lastError = `API returned status ${data.status}`; 73 | 74 | // If it's a 401, don't retry 75 | if (data.status === 401) { 76 | return NextResponse.json( 77 | { error: "Invalid or expired access token" }, 78 | { status: 401 } 79 | ); 80 | } 81 | 82 | // Wait before retrying (exponential backoff) 83 | if (attempt < maxRetries - 1) { 84 | await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1))); 85 | } 86 | } catch (fetchError) { 87 | lastError = fetchError instanceof Error ? fetchError.message : String(fetchError); 88 | console.error(`Attempt ${attempt + 1} failed:`, lastError); 89 | 90 | // Wait before retrying 91 | if (attempt < maxRetries - 1) { 92 | await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1))); 93 | } 94 | } 95 | } 96 | 97 | if (!data || !data.ok) { 98 | return NextResponse.json( 99 | { error: "Failed to fetch user data from 42 API", details: lastError }, 100 | { status: 503 } 101 | ); 102 | } 103 | 104 | const userResponse = await data.json(); 105 | 106 | if (!userResponse.email || !userResponse.login) { 107 | return NextResponse.json( 108 | { error: "Invalid user data received" }, 109 | { status: 500 } 110 | ); 111 | } 112 | 113 | let badge: { type: 'creator' | 'vip' | 'feedback'; name: string } | null = null; 114 | try { 115 | const pool = new Pool({ connectionString: process.env.DATABASE_KEY }); 116 | 117 | const feedbackQuery = ` 118 | SELECT badge_type 119 | FROM leets.feedback 120 | WHERE user_login = $1 121 | AND badge_awarded = TRUE 122 | ORDER BY created_at DESC 123 | LIMIT 1 124 | `; 125 | const feedbackResult = await pool.query(feedbackQuery, [userResponse.login]); 126 | 127 | if (feedbackResult.rows.length > 0 && feedbackResult.rows[0].badge_type) { 128 | badge = { type: 'feedback', name: feedbackResult.rows[0].badge_type }; 129 | } else { 130 | const vipQuery = `SELECT token FROM leets.vip WHERE login = $1`; 131 | const vipResult = await pool.query(vipQuery, [userResponse.login]); 132 | 133 | if (vipResult.rows.length > 0) { 134 | const token = vipResult.rows[0].token; 135 | if (token === 'creator') { 136 | badge = { type: 'creator', name: 'creator' }; 137 | } else if (token === 'vip' || token === 'owner') { 138 | badge = { type: 'vip', name: token }; 139 | } 140 | } 141 | } 142 | 143 | pool.end().catch((err: unknown) => { 144 | console.error("Error closing pool:", err); 145 | }); 146 | } catch (err) { 147 | console.error("Error fetching badge:", err); 148 | } 149 | 150 | return NextResponse.json( 151 | { 152 | email: userResponse.email, 153 | login: userResponse.login, 154 | kind: userResponse.cursus_users?.length > 1 ? "student" : "pooler", 155 | image: userResponse.image?.versions?.large || "/nopic.jpg", 156 | staff: userResponse.staff !== undefined && userResponse.staff !== false, 157 | correction_point: userResponse.correction_point || 0, 158 | pool_month: userResponse.pool_month || null, 159 | pool_year: userResponse.pool_year || null, 160 | location: userResponse.location || null, 161 | wallet: userResponse.wallet || 0, 162 | campus_id: userResponse.campus?.[0]?.id || 0, 163 | campus_name: userResponse.campus?.[0]?.name || "Unknown", 164 | level: userResponse?.cursus_users?.[1]?.level || 0, 165 | fullname: userResponse.usual_full_name || userResponse.displayname || userResponse.login, 166 | badge: badge, 167 | }, 168 | { status: 200 } 169 | ); 170 | } catch (error) { 171 | return NextResponse.json( 172 | { 173 | error: "Internal server error", 174 | message: error instanceof Error ? error.message : String(error) 175 | }, 176 | { status: 500 } 177 | ); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /secure-chat-server.js: -------------------------------------------------------------------------------- 1 | // secure-chat-server.js 2 | require('dotenv').config(); 3 | const express = require('express'); 4 | const http = require('http'); 5 | const { Server } = require('socket.io'); 6 | const cors = require('cors'); 7 | const jwt = require('jsonwebtoken'); 8 | const CryptoJS = require('crypto-js'); 9 | 10 | const app = express(); 11 | app.use(cors()); 12 | 13 | const server = http.createServer(app); 14 | const io = new Server(server, { 15 | cors: { 16 | origin: ["http://localhost:3000", "https://www.1337leets.com"], 17 | methods: ["GET", "POST"], 18 | credentials: true 19 | } 20 | }); 21 | 22 | // Store messages (in production, use a database) 23 | const messages = []; 24 | const MESSAGE_RETENTION = 24 * 60 * 60 * 1000; // 24 hours 25 | 26 | // Clean up old messages 27 | setInterval(() => { 28 | const now = Date.now(); 29 | const before = messages.length; 30 | messages.splice(0, messages.length, ...messages.filter(msg => { 31 | const messageTime = new Date(msg.timestamp).getTime(); 32 | return (now - messageTime) < MESSAGE_RETENTION; 33 | })); 34 | const after = messages.length; 35 | if (before !== after) { 36 | console.log(`Cleaned ${before - after} old messages. ${after} messages remaining.`); 37 | } 38 | }, 60 * 60 * 1000); // Run every hour 39 | 40 | // Decrypt function (must match your auth system) 41 | function decryptToken(encryptedToken) { 42 | try { 43 | return CryptoJS.AES.decrypt(encryptedToken, process.env.SECRET_KEY).toString(CryptoJS.enc.Utf8); 44 | } catch (error) { 45 | console.error('Decryption error:', error); 46 | return null; 47 | } 48 | } 49 | 50 | // Verify and fetch user data from token 51 | async function verifyToken(token) { 52 | try { 53 | // Decode JWT (without verification since we're verifying the 42 API token) 54 | const decoded = jwt.decode(token); 55 | if (!decoded || !decoded.token) { 56 | console.log('Invalid JWT structure'); 57 | return null; 58 | } 59 | 60 | // Decrypt the 42 API access token 61 | const accessToken = decryptToken(decoded.token); 62 | if (!accessToken) { 63 | console.log('Failed to decrypt access token'); 64 | return null; 65 | } 66 | 67 | // Fetch user data from 42 API 68 | const response = await fetch('https://api.intra.42.fr/v2/me', { 69 | headers: { 70 | 'Authorization': `Bearer ${accessToken}` 71 | } 72 | }); 73 | 74 | if (!response.ok) { 75 | console.log('42 API request failed:', response.status); 76 | return null; 77 | } 78 | 79 | const userData = await response.json(); 80 | 81 | return { 82 | login: userData.login, 83 | avatar: userData.image?.versions?.large || '/nopic.jpg', 84 | level: userData.cursus_users?.[1]?.level || 0, 85 | campus: userData.campus?.[0]?.name || 'Unknown', 86 | email: userData.email 87 | }; 88 | } catch (error) { 89 | console.error('Token verification failed:', error.message); 90 | return null; 91 | } 92 | } 93 | 94 | // Middleware to authenticate socket connections 95 | io.use(async (socket, next) => { 96 | const token = socket.handshake.auth.token; 97 | 98 | if (!token) { 99 | console.log('Connection rejected: No token provided'); 100 | return next(new Error('Authentication required')); 101 | } 102 | 103 | const userData = await verifyToken(token); 104 | 105 | if (!userData) { 106 | console.log('Connection rejected: Invalid token'); 107 | return next(new Error('Invalid or expired token')); 108 | } 109 | 110 | // Attach verified user data to socket 111 | socket.userData = userData; 112 | next(); 113 | }); 114 | 115 | // Handle socket connections 116 | io.on('connection', (socket) => { 117 | console.log(`✅ User connected: ${socket.userData.login} (${socket.id})`); 118 | 119 | // Send message history to new user 120 | socket.emit('messageHistory', messages); 121 | 122 | // Handle incoming messages 123 | socket.on('sendMessage', (messageData) => { 124 | try { 125 | // Validate message data 126 | if (!messageData || !messageData.text || typeof messageData.text !== 'string') { 127 | console.log('Invalid message data from', socket.userData.login); 128 | return; 129 | } 130 | 131 | const text = messageData.text.trim(); 132 | 133 | // Validate message length 134 | if (text.length === 0) { 135 | console.log('Empty message from', socket.userData.login); 136 | return; 137 | } 138 | 139 | if (text.length > 1000) { 140 | console.log('Message too long from', socket.userData.login); 141 | socket.emit('error', { message: 'Message too long (max 1000 characters)' }); 142 | return; 143 | } 144 | 145 | // Create message with verified user data from token (not from client!) 146 | const message = { 147 | id: Date.now() + Math.random(), // Use UUID in production 148 | text: text, 149 | sender: socket.userData.login, // From verified token 150 | avatar: socket.userData.avatar, // From verified token 151 | level: socket.userData.level, // From verified token 152 | campus: socket.userData.campus, // From verified token 153 | timestamp: new Date().toISOString(), 154 | }; 155 | 156 | // Store message 157 | messages.push(message); 158 | 159 | // Broadcast to all connected clients 160 | io.emit('message', message); 161 | 162 | console.log(`📨 Message from ${socket.userData.login}: ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}`); 163 | } catch (error) { 164 | console.error('Error handling message:', error); 165 | socket.emit('error', { message: 'Failed to send message' }); 166 | } 167 | }); 168 | 169 | socket.on('disconnect', () => { 170 | console.log(`❌ User disconnected: ${socket.userData.login} (${socket.id})`); 171 | }); 172 | 173 | socket.on('error', (error) => { 174 | console.error(`Socket error for ${socket.userData.login}:`, error); 175 | }); 176 | }); 177 | 178 | // Health check endpoint 179 | app.get('/health', (req, res) => { 180 | res.json({ 181 | status: 'ok', 182 | connections: io.engine.clientsCount, 183 | messages: messages.length, 184 | uptime: process.uptime() 185 | }); 186 | }); 187 | 188 | // Start server 189 | const PORT = process.env.PORT || 3001; 190 | server.listen(PORT, () => { 191 | console.log(` 192 | ╔════════════════════════════════════════╗ 193 | ║ 🚀 Socket.IO Chat Server Running ║ 194 | ║ 📡 Port: ${PORT} ║ 195 | ║ 🔒 Secure: JWT Authentication ║ 196 | ║ ⏰ Message Retention: 24 hours ║ 197 | ╚════════════════════════════════════════╝ 198 | `); 199 | }); 200 | 201 | // Graceful shutdown 202 | process.on('SIGTERM', () => { 203 | console.log('SIGTERM received, closing server...'); 204 | server.close(() => { 205 | console.log('Server closed'); 206 | process.exit(0); 207 | }); 208 | }); 209 | 210 | process.on('SIGINT', () => { 211 | console.log('SIGINT received, closing server...'); 212 | server.close(() => { 213 | console.log('Server closed'); 214 | process.exit(0); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /src/component/landing/particels.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { useEffect, useRef } from "react"; 3 | import { Renderer, Camera, Geometry, Program, Mesh } from "ogl"; 4 | 5 | interface ParticlesProps { 6 | particleCount?: number; 7 | particleSpread?: number; 8 | speed?: number; 9 | particleColors?: string[]; 10 | moveParticlesOnHover?: boolean; 11 | particleHoverFactor?: number; 12 | alphaParticles?: boolean; 13 | particleBaseSize?: number; 14 | sizeRandomness?: number; 15 | cameraDistance?: number; 16 | disableRotation?: boolean; 17 | className?: string; 18 | } 19 | 20 | const defaultColors: string[] = ["#ffffff", "#ffffff", "#ffffff"]; 21 | 22 | const hexToRgb = (hex: string): [number, number, number] => { 23 | hex = hex.replace(/^#/, ""); 24 | if (hex.length === 3) { 25 | hex = hex.split("").map((c) => c + c).join(""); 26 | } 27 | const int = parseInt(hex, 16); 28 | const r = ((int >> 16) & 255) / 255; 29 | const g = ((int >> 8) & 255) / 255; 30 | const b = (int & 255) / 255; 31 | return [r, g, b]; 32 | }; 33 | 34 | const vertex = /* glsl */ ` 35 | attribute vec3 position; 36 | attribute vec4 random; 37 | attribute vec3 color; 38 | 39 | uniform mat4 modelMatrix; 40 | uniform mat4 viewMatrix; 41 | uniform mat4 projectionMatrix; 42 | uniform float uTime; 43 | uniform float uSpread; 44 | uniform float uBaseSize; 45 | uniform float uSizeRandomness; 46 | 47 | varying vec4 vRandom; 48 | varying vec3 vColor; 49 | 50 | void main() { 51 | vRandom = random; 52 | vColor = color; 53 | 54 | vec3 pos = position * uSpread; 55 | pos.z *= 10.0; 56 | 57 | vec4 mPos = modelMatrix * vec4(pos, 1.0); 58 | float t = uTime; 59 | mPos.x += sin(t * random.z + 6.28 * random.w) * mix(0.1, 1.5, random.x); 60 | mPos.y += sin(t * random.y + 6.28 * random.x) * mix(0.1, 1.5, random.w); 61 | mPos.z += sin(t * random.w + 6.28 * random.y) * mix(0.1, 1.5, random.z); 62 | 63 | vec4 mvPos = viewMatrix * mPos; 64 | gl_PointSize = (uBaseSize * (1.0 + uSizeRandomness * (random.x - 0.5))) / length(mvPos.xyz); 65 | gl_Position = projectionMatrix * mvPos; 66 | } 67 | `; 68 | 69 | const fragment = /* glsl */ ` 70 | precision highp float; 71 | 72 | uniform float uTime; 73 | uniform float uAlphaParticles; 74 | varying vec4 vRandom; 75 | varying vec3 vColor; 76 | 77 | void main() { 78 | vec2 uv = gl_PointCoord.xy; 79 | float d = length(uv - vec2(0.5)); 80 | 81 | if(uAlphaParticles < 0.5) { 82 | if(d > 0.5) { 83 | discard; 84 | } 85 | gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), 1.0); 86 | } else { 87 | float circle = smoothstep(0.5, 0.4, d) * 0.8; 88 | gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), circle); 89 | } 90 | } 91 | `; 92 | 93 | const Particles: React.FC = ({ 94 | particleCount = 200, 95 | particleSpread = 10, 96 | speed = 0.1, 97 | particleColors, 98 | moveParticlesOnHover = false, 99 | particleHoverFactor = 1, 100 | alphaParticles = false, 101 | particleBaseSize = 100, 102 | sizeRandomness = 1, 103 | cameraDistance = 20, 104 | disableRotation = false, 105 | className, 106 | }) => { 107 | const containerRef = useRef(null); 108 | const mouseRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); 109 | 110 | useEffect(() => { 111 | const container = containerRef.current; 112 | if (!container) return; 113 | 114 | const renderer = new Renderer({ depth: false, alpha: true }); 115 | const gl = renderer.gl; 116 | container.appendChild(gl.canvas); 117 | gl.clearColor(0, 0, 0, 0); 118 | 119 | const camera = new Camera(gl, { fov: 15 }); 120 | camera.position.set(0, 0, cameraDistance); 121 | 122 | const resize = () => { 123 | const width = container.clientWidth; 124 | const height = container.clientHeight; 125 | renderer.setSize(width, height); 126 | camera.perspective({ aspect: gl.canvas.width / gl.canvas.height }); 127 | }; 128 | window.addEventListener("resize", resize, false); 129 | resize(); 130 | 131 | const handleMouseMove = (e: MouseEvent) => { 132 | const rect = container.getBoundingClientRect(); 133 | const x = ((e.clientX - rect.left) / rect.width) * 2 - 1; 134 | const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1); 135 | mouseRef.current = { x, y }; 136 | }; 137 | 138 | if (moveParticlesOnHover) { 139 | container.addEventListener("mousemove", handleMouseMove); 140 | } 141 | 142 | const count = particleCount; 143 | const positions = new Float32Array(count * 3); 144 | const randoms = new Float32Array(count * 4); 145 | const colors = new Float32Array(count * 3); 146 | const palette = particleColors && particleColors.length > 0 ? particleColors : defaultColors; 147 | 148 | for (let i = 0; i < count; i++) { 149 | let x: number, y: number, z: number, len: number; 150 | do { 151 | x = Math.random() * 2 - 1; 152 | y = Math.random() * 2 - 1; 153 | z = Math.random() * 2 - 1; 154 | len = x * x + y * y + z * z; 155 | } while (len > 1 || len === 0); 156 | const r = Math.cbrt(Math.random()); 157 | positions.set([x * r, y * r, z * r], i * 3); 158 | randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4); 159 | const col = hexToRgb(palette[Math.floor(Math.random() * palette.length)]); 160 | colors.set(col, i * 3); 161 | } 162 | 163 | const geometry = new Geometry(gl, { 164 | position: { size: 3, data: positions }, 165 | random: { size: 4, data: randoms }, 166 | color: { size: 3, data: colors }, 167 | }); 168 | 169 | const program = new Program(gl, { 170 | vertex, 171 | fragment, 172 | uniforms: { 173 | uTime: { value: 0 }, 174 | uSpread: { value: particleSpread }, 175 | uBaseSize: { value: particleBaseSize }, 176 | uSizeRandomness: { value: sizeRandomness }, 177 | uAlphaParticles: { value: alphaParticles ? 1 : 0 }, 178 | }, 179 | transparent: true, 180 | depthTest: false, 181 | }); 182 | 183 | const particles = new Mesh(gl, { mode: gl.POINTS, geometry, program }); 184 | 185 | let animationFrameId: number; 186 | let lastTime = performance.now(); 187 | let elapsed = 0; 188 | 189 | const update = (t: number) => { 190 | animationFrameId = requestAnimationFrame(update); 191 | const delta = t - lastTime; 192 | lastTime = t; 193 | elapsed += delta * speed; 194 | 195 | program.uniforms.uTime.value = elapsed * 0.001; 196 | 197 | if (moveParticlesOnHover) { 198 | particles.position.x = -mouseRef.current.x * particleHoverFactor; 199 | particles.position.y = -mouseRef.current.y * particleHoverFactor; 200 | } else { 201 | particles.position.x = 0; 202 | particles.position.y = 0; 203 | } 204 | 205 | if (!disableRotation) { 206 | particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1; 207 | particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15; 208 | particles.rotation.z += 0.01 * speed; 209 | } 210 | 211 | renderer.render({ scene: particles, camera }); 212 | }; 213 | 214 | animationFrameId = requestAnimationFrame(update); 215 | 216 | return () => { 217 | window.removeEventListener("resize", resize); 218 | if (moveParticlesOnHover) { 219 | container.removeEventListener("mousemove", handleMouseMove); 220 | } 221 | cancelAnimationFrame(animationFrameId); 222 | if (container.contains(gl.canvas)) { 223 | container.removeChild(gl.canvas); 224 | } 225 | }; 226 | }, [ 227 | particleCount, 228 | particleSpread, 229 | speed, 230 | moveParticlesOnHover, 231 | particleHoverFactor, 232 | alphaParticles, 233 | particleBaseSize, 234 | sizeRandomness, 235 | cameraDistance, 236 | disableRotation, 237 | ]); 238 | 239 | return ( 240 |
244 | ); 245 | }; 246 | 247 | export default Particles; 248 | -------------------------------------------------------------------------------- /src/component/BannedUsersPopup.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useState, useEffect } from "react"; 3 | import { motion, AnimatePresence } from "motion/react"; 4 | 5 | interface BannedUser { 6 | identifier: string; 7 | blockCount: number; 8 | banDuration: string; 9 | remainingSeconds: number; 10 | lastBlockTime: string; 11 | updatedAt: string; 12 | } 13 | 14 | interface BannedUsersPopupProps { 15 | isVisible: boolean; 16 | onClose: () => void; 17 | } 18 | 19 | export default function BannedUsersPopup({ isVisible, onClose }: BannedUsersPopupProps) { 20 | const [bannedUsers, setBannedUsers] = useState([]); 21 | const [loading, setLoading] = useState(false); 22 | const [error, setError] = useState(null); 23 | 24 | useEffect(() => { 25 | if (isVisible) { 26 | fetchBannedUsers(); 27 | // Auto-refresh every 10 seconds 28 | const interval = setInterval(fetchBannedUsers, 10000); 29 | return () => clearInterval(interval); 30 | } 31 | }, [isVisible]); 32 | 33 | const fetchBannedUsers = async () => { 34 | setLoading(true); 35 | setError(null); 36 | try { 37 | const response = await fetch("/api/rate-limit-bans"); 38 | const data = await response.json(); 39 | 40 | if (response.ok) { 41 | setBannedUsers(data.bannedUsers); 42 | } else { 43 | setError(data.error || "Failed to fetch banned users"); 44 | } 45 | } catch { 46 | setError("Failed to fetch banned users"); 47 | } finally { 48 | setLoading(false); 49 | } 50 | }; 51 | 52 | const formatTime = (seconds: number) => { 53 | const minutes = Math.floor(seconds / 60); 54 | const secs = seconds % 60; 55 | return `${minutes}m ${secs}s`; 56 | }; 57 | 58 | if (!isVisible) return null; 59 | 60 | return ( 61 | 62 | 69 | e.stopPropagation()} 74 | className="bg-blue-950/95 backdrop-blur-xl border-2 border-blue-700/50 rounded-2xl p-6 max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col" 75 | > 76 | {/* Header */} 77 |
78 |
79 |
80 | 🚫 81 |
82 |
83 |

84 | Rate Limited Users 85 |

86 |

87 | {bannedUsers.length} user{bannedUsers.length !== 1 ? "s" : ""} currently banned 88 |

89 |
90 |
91 | 97 |
98 | 99 | {/* Content */} 100 |
101 | {loading && bannedUsers.length === 0 ? ( 102 |
103 |
104 |
105 | ) : error ? ( 106 |
107 |

{error}

108 |
109 | ) : bannedUsers.length === 0 ? ( 110 |
111 |
🎉
112 |

No users are currently banned

113 |

All users are behaving well!

114 |
115 | ) : ( 116 |
117 | {bannedUsers.map((user, index) => ( 118 | 125 |
126 | {/* User Info */} 127 |
128 |
129 | 130 | {user.identifier.startsWith("ip:") 131 | ? user.identifier 132 | : `@${user.identifier}`} 133 | 134 | 141 | Strike {user.blockCount}/3 142 | 143 |
144 |

145 | Banned {new Date(user.lastBlockTime).toLocaleString()} 146 |

147 |
148 | 149 | {/* Ban Duration */} 150 |
151 |
152 |

153 | {formatTime(user.remainingSeconds)} 154 |

155 |

156 | remaining 157 |

158 |
159 |

160 | {user.banDuration} ban 161 |

162 |
163 |
164 |
165 | ))} 166 |
167 | )} 168 |
169 | 170 | {/* Footer */} 171 |
172 |
173 | Auto-refreshes every 10 seconds 174 | 181 |
182 |
183 |
184 |
185 |
186 | ); 187 | } 188 | --------------------------------------------------------------------------------