From e8d59281a811f538b5366c088319a0c5a4d884cb Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 9 Jul 2026 00:49:39 +0300 Subject: [PATCH 1/6] chore: move lib/pages.config.ts to content/pages.ts --- app/pages/[slug]/PageClient.tsx | 2 +- app/pages/[slug]/[subslug]/page.tsx | 4 ++-- app/pages/[slug]/page.tsx | 4 ++-- app/pages/layout.tsx | 2 +- components/home/ShowcaseSection.tsx | 4 ++-- lib/pages.config.ts => content/pages.ts | 0 6 files changed, 8 insertions(+), 8 deletions(-) rename lib/pages.config.ts => content/pages.ts (100%) diff --git a/app/pages/[slug]/PageClient.tsx b/app/pages/[slug]/PageClient.tsx index 13a9649..545a775 100644 --- a/app/pages/[slug]/PageClient.tsx +++ b/app/pages/[slug]/PageClient.tsx @@ -1,7 +1,7 @@ "use client"; +import { Page, PageContent } from "@/content/pages"; import { fadeInUp, staggerContainer } from "@/lib/animations"; -import { Page, PageContent } from "@/lib/pages.config"; import { motion } from "framer-motion"; import { OctagonAlert as AlertOctagon, diff --git a/app/pages/[slug]/[subslug]/page.tsx b/app/pages/[slug]/[subslug]/page.tsx index 177e055..2e2a2c5 100644 --- a/app/pages/[slug]/[subslug]/page.tsx +++ b/app/pages/[slug]/[subslug]/page.tsx @@ -1,5 +1,5 @@ +import { getAdjacentPages, getPage } from "@/content/pages"; import { notFound } from "next/navigation"; -import { getPage, getAdjacentPages } from "@/lib/pages.config"; import PageClient from "../PageClient"; interface Props { @@ -16,7 +16,7 @@ export default function SubPageRoute({ params }: Props) { } export async function generateStaticParams() { - const { pages } = await import("@/lib/pages.config"); + const { pages } = await import("@/content/pages"); return pages .filter((p) => p.slug.includes("/")) .map((p) => { diff --git a/app/pages/[slug]/page.tsx b/app/pages/[slug]/page.tsx index 3efc9d2..d67f327 100644 --- a/app/pages/[slug]/page.tsx +++ b/app/pages/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { getAdjacentPages, getPage } from "@/lib/pages.config"; +import { getAdjacentPages, getPage } from "@/content/pages"; import { notFound } from "next/navigation"; import PageClient from "./PageClient"; @@ -17,6 +17,6 @@ export default async function PageRoute({ params }: Props) { } export async function generateStaticParams() { - const { pages } = await import("@/lib/pages.config"); + const { pages } = await import("@/content/pages"); return pages.map((page) => ({ slug: page.slug })); } diff --git a/app/pages/layout.tsx b/app/pages/layout.tsx index a293d9c..cfbaedc 100644 --- a/app/pages/layout.tsx +++ b/app/pages/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { pageSections } from "@/lib/pages.config"; +import { pageSections } from "@/content/pages"; import { AnimatePresence, motion } from "framer-motion"; import { ChevronDown, Menu, X } from "lucide-react"; import Link from "next/link"; diff --git a/components/home/ShowcaseSection.tsx b/components/home/ShowcaseSection.tsx index 3b91cc3..52ebdf2 100644 --- a/components/home/ShowcaseSection.tsx +++ b/components/home/ShowcaseSection.tsx @@ -45,8 +45,8 @@ const projects = [ { name: "devhub-bot", desc: "GitHub Bot designed to automate the boring stuff within the DevHub Organization. Built on the powerful Probot framework.", - lang: "Discord.js", - langColor: "#f59e0b", + lang: "TypeScript", + langColor: "#6366f1", }, ]; diff --git a/lib/pages.config.ts b/content/pages.ts similarity index 100% rename from lib/pages.config.ts rename to content/pages.ts From 832006d42bf241aebb34387974dfee0918b611d4 Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 9 Jul 2026 01:00:23 +0300 Subject: [PATCH 2/6] docs: update readme --- README.md | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index de1477a..e0ab3da 100644 --- a/README.md +++ b/README.md @@ -56,36 +56,38 @@ Open [http://localhost:3000](http://localhost:3000) and you're in. ## Project Structure ``` -├── 📄 .eslintrc.json -├── 📄 .gitignore +├── 📁 .github +│ └── 📁 workflows +│ └── 📄 linter.yaml ├── 📁 .vscode │ └── 📄 settings.json -├── 📄 CONTRIBUTING.md -├── 📄 LICENSE -├── 📄 README.md ├── 📁 app │ ├── 📄 globals.css │ ├── 📄 layout.tsx +│ ├── 📄 not-found.tsx │ ├── 📄 page.tsx │ ├── 📁 pages -│ │ ├── 📄 [slug] +│ │ ├── 📁 [slug] +│ │ │ ├── 📄 PageClient.tsx +│ │ │ ├── 📁 [subslug] +│ │ │ │ └── 📄 page.tsx +│ │ │ └── 📄 page.tsx │ │ ├── 📄 layout.tsx │ │ └── 📄 page.tsx +│ ├── 📁 partners +│ │ └── 📄 page.tsx │ ├── 📁 resources │ │ └── 📄 page.tsx │ └── 📁 rules │ └── 📄 page.tsx -├── 📄 components.json ├── 📁 components │ ├── 📄 AnimatedText.tsx │ ├── 📄 Badge.tsx +│ ├── 📄 BorderGlow.tsx │ ├── 📄 BorderGlowButton.tsx │ ├── 📄 Footer.tsx -│ ├── 📄 GlowButton.tsx -│ ├── 📄 Navbar.tsx -│ ├── 📄 Section.tsx -│ ├── 📄 ShinyText.tsx -│ ├── 📄 TargetCursor.tsx +│ ├── 📄 FuzzyText.tsx +│ ... │ ├── 📁 home │ │ ├── 📄 BelongSection.tsx │ │ ├── 📄 CTASection.tsx @@ -100,9 +102,9 @@ Open [http://localhost:3000](http://localhost:3000) and you're in. │ ├── 📄 aspect-ratio.tsx │ ├── 📄 avatar.tsx │ ├── 📄 badge.tsx -│ ├── 📄 breadcrumb.tsx -│ ├── 📄 button.tsx -│ ├── ... + ... +├── 📁 content +│ └── 📄 pages.ts ├── 📁 hooks │ └── 📄 use-toast.ts ├── 📁 lib @@ -111,10 +113,16 @@ Open [http://localhost:3000](http://localhost:3000) and you're in. │ ├── 📄 redirects.config.ts │ ├── 📄 staticdata.config.ts │ └── 📄 utils.ts +├── 📄 .eslintrc.json +├── 📄 .gitignore +├── 📄 components.json +├── 📄 CONTRIBUTING.md +├── 📄 LICENSE ├── 📄 next.config.js ├── 📄 package-lock.json ├── 📄 package.json ├── 📄 postcss.config.js +├── 📄 README.md ├── 📄 tailwind.config.ts └── 📄 tsconfig.json ``` @@ -145,7 +153,7 @@ npm run format # Run Prettier ## Community -- **Discord** - [devhub.vercel.app/invite](https://devhub.vercel.app/invite) +- **Discord** - [devhub.vercel.app/join](https://devhub.vercel.app/join) - **GitHub Org** - [github.com/open-devhub](https://github.com/open-devhub) - **Email** - open-devhub@outlook.com From 766ba426633052f8bda2875401f2af2e355ea2b4 Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 9 Jul 2026 01:21:58 +0300 Subject: [PATCH 3/6] feat: apply special classes on regex match in pages --- app/pages/[slug]/PageClient.tsx | 103 ++++++++++++++++++++++---------- content/pages.ts | 6 +- 2 files changed, 76 insertions(+), 33 deletions(-) diff --git a/app/pages/[slug]/PageClient.tsx b/app/pages/[slug]/PageClient.tsx index 545a775..842d36f 100644 --- a/app/pages/[slug]/PageClient.tsx +++ b/app/pages/[slug]/PageClient.tsx @@ -21,30 +21,73 @@ interface Props { next?: Page; } -function Highlight({ text }: { text: string }) { - return ( - <> - {text.split(/(\s+)/).map((part, i) => { - if (part.startsWith("#")) { - const match = part.match(/^(#[\w-]+)(.*)?$/); - if (match) { - return ( - - - {match[1]} - - {match[2]} - - ); - } - } - return part; - })} - - ); +function ApplySpecialClass({ text }: { text: string }) { + const regex = /(`[^`]+`)|(\[[^\]]+\]\([^)]+\))|(#[\w-]+)/g; + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let key = 0; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + const [full, code, link, channel] = match; + + if (code) { + parts.push( + + {code.slice(1, -1)} + , + ); + } else if (link) { + const linkMatch = link.match(/^\[([^\]]+)\]\(([^)]+)\)$/); + if (linkMatch) { + parts.push( + + {linkMatch[1]} + , + ); + } else { + parts.push(full); + } + } else if (channel) { + parts.push( + + {channel} + , + ); + } + + lastIndex = regex.lastIndex; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return <>{parts}; } function ContentBlock({ block }: { block: PageContent }) { @@ -59,7 +102,7 @@ function ContentBlock({ block }: { block: PageContent }) { color: "#e2e2f0", }} > - + ); case "h3": @@ -71,7 +114,7 @@ function ContentBlock({ block }: { block: PageContent }) { color: "#e2e2f0", }} > - + ); case "p": @@ -80,7 +123,7 @@ function ContentBlock({ block }: { block: PageContent }) { className="leading-relaxed mb-4" style={{ fontFamily: "var(--font-geist-mono)", color: "#71717a" }} > - +

); case "ul": @@ -97,7 +140,7 @@ function ContentBlock({ block }: { block: PageContent }) { style={{ background: "rgba(99,102,241,0.5)" }} /> - + ))} @@ -119,7 +162,7 @@ function ContentBlock({ block }: { block: PageContent }) { {String(i + 1).padStart(2, "0")} - + ))} @@ -206,7 +249,7 @@ function ContentBlock({ block }: { block: PageContent }) { className="text-sm" style={{ fontFamily: "var(--font-geist-mono)", color: "#71717a" }} > - +

); diff --git a/content/pages.ts b/content/pages.ts index cf96290..a51fc87 100644 --- a/content/pages.ts +++ b/content/pages.ts @@ -522,7 +522,7 @@ export const pages: Page[] = [ "Contact the moderator who handled the action, clearly and calmly. Most things can be resolved here.", "If you don't get a satisfactory response, escalate to an Admin via DM.", "If you believe there's bias or a serious process failure, contact the Owner.", - "For bans, go to https://appeal.gg/s/1429026875946172459 and submit an appeal form. This goes to the admin team for review.", + "For bans, go to [appeal.gg](https://appeal.gg/s/1429026875946172459) and submit an appeal form. This goes to the admin team for review.", ], }, { @@ -651,7 +651,7 @@ export const pages: Page[] = [ { type: "h2", text: "I got banned. Can I appeal?" }, { type: "p", - text: "Yes. Use https://appeal.gg/s/1429026875946172459. See the Moderation Guide for the full process. Bans aren't always permanent, context and how you handle the appeal matters.", + text: "Yes. Use [appeal.gg](https://appeal.gg/s/1429026875946172459). See the Moderation Guide for the full process. Bans aren't always permanent, context and how you handle the appeal matters.", }, { type: "h2", text: "Who runs DevHub?" }, { @@ -1091,7 +1091,7 @@ export const pages: Page[] = [ { type: "h2", text: "Commit Messages" }, { type: "p", - text: 'We use Conventional Commits: "type(scope): description". Common types are feat, fix, docs, chore, refactor, test (full documentation at https://www.conventionalcommits.org/en/v1.0.0/). The commit history is documentation, write it like someone will read it.', + text: 'We use Conventional Commits: "type(scope): description". Common types are feat, fix, docs, chore, refactor, test (full documentation at [conventionalcommits.org](https://www.conventionalcommits.org/en/v1.0.0/)). The commit history is documentation, write it like someone will read it.', }, { type: "code", From 27de58e56939182de7c91955ae254aa9eb6ff738 Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 9 Jul 2026 01:49:44 +0300 Subject: [PATCH 4/6] feat: add link preview card component --- app/api/link-preview/route.ts | 99 +++++++++++++++ app/globals.css | 4 + app/pages/[slug]/PageClient.tsx | 14 +- components/LinkPreviewCard.tsx | 218 ++++++++++++++++++++++++++++++++ 4 files changed, 325 insertions(+), 10 deletions(-) create mode 100644 app/api/link-preview/route.ts create mode 100644 components/LinkPreviewCard.tsx diff --git a/app/api/link-preview/route.ts b/app/api/link-preview/route.ts new file mode 100644 index 0000000..1db0e87 --- /dev/null +++ b/app/api/link-preview/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +function extractMeta(html: string, property: string): string | null { + const patterns = [ + new RegExp( + `]+(?:property|name)=["']${property}["'][^>]+content=["']([^"']*)["']`, + "i", + ), + new RegExp( + `]+content=["']([^"']*)["'][^>]+(?:property|name)=["']${property}["']`, + "i", + ), + ]; + for (const pattern of patterns) { + const match = html.match(pattern); + if (match) return match[1]; + } + return null; +} + +function extractTitleTag(html: string): string | null { + const match = html.match(/]*>([^<]+)<\/title>/i); + return match ? match[1] : null; +} + +function decodeHtmlEntities(str: string): string { + return str + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const url = searchParams.get("url"); + + if (!url) { + return NextResponse.json({ error: "missing url" }, { status: 400 }); + } + + try { + new URL(url); + } catch { + return NextResponse.json({ error: "invalid url" }, { status: 400 }); + } + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; LinkPreviewBot/1.0; +https://example.com/bot)", + }, + }); + + clearTimeout(timeout); + + if (!res.ok) { + return NextResponse.json({ error: "failed to fetch" }, { status: 502 }); + } + + const html = await res.text(); + + const rawTitle = extractMeta(html, "og:title") || extractTitleTag(html); + const rawDescription = + extractMeta(html, "og:description") || extractMeta(html, "description"); + const rawImage = extractMeta(html, "og:image"); + + let image: string | null = null; + if (rawImage) { + try { + image = new URL(rawImage, url).toString(); + } catch { + image = null; + } + } + + return NextResponse.json( + { + title: rawTitle ? decodeHtmlEntities(rawTitle).trim() : null, + description: rawDescription + ? decodeHtmlEntities(rawDescription).trim() + : null, + image, + url, + }, + { headers: { "Cache-Control": "public, max-age=3600" } }, + ); + } catch { + return NextResponse.json({ error: "failed to fetch" }, { status: 502 }); + } +} diff --git a/app/globals.css b/app/globals.css index 696083f..d91ceef 100644 --- a/app/globals.css +++ b/app/globals.css @@ -108,6 +108,10 @@ body { font-family: var(--font-pixelify), "Pixelify Sans", monospace; } +.font-mono { + font-family: monospace; +} + /* Mono font */ .font-mono-custom { font-family: var(--font-geist-mono), "Geist Mono", monospace; diff --git a/app/pages/[slug]/PageClient.tsx b/app/pages/[slug]/PageClient.tsx index 842d36f..efad2e5 100644 --- a/app/pages/[slug]/PageClient.tsx +++ b/app/pages/[slug]/PageClient.tsx @@ -1,5 +1,6 @@ "use client"; +import { LinkPreviewCard } from "@/components/LinkPreviewCard"; import { Page, PageContent } from "@/content/pages"; import { fadeInUp, staggerContainer } from "@/lib/animations"; import { motion } from "framer-motion"; @@ -39,7 +40,7 @@ function ApplySpecialClass({ text }: { text: string }) { parts.push( + {linkMatch[1]} - , + , ); } else { parts.push(full); diff --git a/components/LinkPreviewCard.tsx b/components/LinkPreviewCard.tsx new file mode 100644 index 0000000..917cf98 --- /dev/null +++ b/components/LinkPreviewCard.tsx @@ -0,0 +1,218 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface PreviewData { + title: string | null; + description: string | null; + image: string | null; + url: string; +} + +const previewCache = new Map(); + +export function LinkPreviewCard({ + href, + children, +}: { + href: string; + children: React.ReactNode; +}) { + const [hovering, setHovering] = useState(false); + const [data, setData] = useState(null); + const [imgError, setImgError] = useState(false); + const [pos, setPos] = useState({ top: 0, left: 0 }); + const [mounted, setMounted] = useState(false); + const anchorRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); + const fetchTimeoutRef = useRef | null>(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + return () => { + if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); + if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current); + }; + }, []); + + useEffect(() => { + if (!hovering) return; + + const handleScroll = () => { + cancelHide(); + setHovering(false); + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => window.removeEventListener("scroll", handleScroll); + }, [hovering]); + + const fetchPreview = () => { + if (previewCache.has(href)) { + setData(previewCache.get(href) || null); + return; + } + fetch(`/api/link-preview?url=${encodeURIComponent(href)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { + previewCache.set(href, json); + setData(json); + }) + .catch(() => { + previewCache.set(href, null); + setData(null); + }); + }; + + const cancelHide = () => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }; + + const scheduleHide = () => { + cancelHide(); + hideTimeoutRef.current = setTimeout(() => setHovering(false), 200); + }; + + const handleLinkEnter = () => { + cancelHide(); + const rect = anchorRef.current?.getBoundingClientRect(); + if (rect) { + setPos({ top: rect.bottom + 8, left: rect.left }); + } + setHovering(true); + setImgError(false); + fetchTimeoutRef.current = setTimeout(fetchPreview, 150); + }; + + const handleLinkLeave = () => { + if (fetchTimeoutRef.current) clearTimeout(fetchTimeoutRef.current); + scheduleHide(); + }; + + const handleCardEnter = () => { + cancelHide(); + }; + + const handleCardLeave = () => { + scheduleHide(); + }; + + const hasContent = Boolean(data?.description); + + return ( + <> + + {children} + + {mounted && + createPortal( + + {hovering && hasContent && ( + +
+
+
+
+ +
+ {data?.image && !imgError && ( + setImgError(true)} + className="absolute top-0 right-0 w-14 h-14 object-cover" + style={{ border: "1px solid rgba(99,102,241,0.12)" }} + /> + )} +
+ + {data?.title || href} + + {data?.description && ( +

+ {data.description} +

+ )} +
+
+ + )} + , + document.body, + )} + + ); +} From b7eeb2b363064be8afbf137efc4dd0f145f27610 Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 9 Jul 2026 01:56:14 +0300 Subject: [PATCH 5/6] chore: organize component files --- app/partners/page.tsx | 2 +- app/resources/page.tsx | 2 +- app/rules/page.tsx | 2 +- components/{ => bits}/BorderGlow.tsx | 0 components/{ => bits}/FuzzyText.tsx | 0 components/{ => bits}/ShinyText.tsx | 0 components/{ => bits}/SoftAurora.tsx | 0 components/{ => bits}/TargetCursor.tsx | 0 components/home/BelongSection.tsx | 2 +- components/home/FeaturesSection.tsx | 2 +- components/home/HeroSection.tsx | 4 ++-- components/home/StatsSection.tsx | 2 +- components/{ => site}/Footer.tsx | 0 components/{ => site}/Navbar.tsx | 0 components/{ => site}/Section.tsx | 0 15 files changed, 8 insertions(+), 8 deletions(-) rename components/{ => bits}/BorderGlow.tsx (100%) rename components/{ => bits}/FuzzyText.tsx (100%) rename components/{ => bits}/ShinyText.tsx (100%) rename components/{ => bits}/SoftAurora.tsx (100%) rename components/{ => bits}/TargetCursor.tsx (100%) rename components/{ => site}/Footer.tsx (100%) rename components/{ => site}/Navbar.tsx (100%) rename components/{ => site}/Section.tsx (100%) diff --git a/app/partners/page.tsx b/app/partners/page.tsx index 3515b84..25c0f30 100644 --- a/app/partners/page.tsx +++ b/app/partners/page.tsx @@ -1,6 +1,6 @@ "use client"; -import ShinyText from "@/components/ShinyText"; +import ShinyText from "@/components/bits/ShinyText"; import { fadeInUp, staggerContainer } from "@/lib/animations"; import { motion } from "framer-motion"; import { ArrowRight, ExternalLink } from "lucide-react"; diff --git a/app/resources/page.tsx b/app/resources/page.tsx index 401dedc..e406705 100644 --- a/app/resources/page.tsx +++ b/app/resources/page.tsx @@ -1,7 +1,7 @@ "use client"; import Badge from "@/components/Badge"; -import ShinyText from "@/components/ShinyText"; +import ShinyText from "@/components/bits/ShinyText"; import { Drawer, DrawerClose, diff --git a/app/rules/page.tsx b/app/rules/page.tsx index a64066a..ab21073 100644 --- a/app/rules/page.tsx +++ b/app/rules/page.tsx @@ -1,6 +1,6 @@ "use client"; -import ShinyText from "@/components/ShinyText"; +import ShinyText from "@/components/bits/ShinyText"; import { fadeInUp, staggerContainer } from "@/lib/animations"; import { motion } from "framer-motion"; diff --git a/components/BorderGlow.tsx b/components/bits/BorderGlow.tsx similarity index 100% rename from components/BorderGlow.tsx rename to components/bits/BorderGlow.tsx diff --git a/components/FuzzyText.tsx b/components/bits/FuzzyText.tsx similarity index 100% rename from components/FuzzyText.tsx rename to components/bits/FuzzyText.tsx diff --git a/components/ShinyText.tsx b/components/bits/ShinyText.tsx similarity index 100% rename from components/ShinyText.tsx rename to components/bits/ShinyText.tsx diff --git a/components/SoftAurora.tsx b/components/bits/SoftAurora.tsx similarity index 100% rename from components/SoftAurora.tsx rename to components/bits/SoftAurora.tsx diff --git a/components/TargetCursor.tsx b/components/bits/TargetCursor.tsx similarity index 100% rename from components/TargetCursor.tsx rename to components/bits/TargetCursor.tsx diff --git a/components/home/BelongSection.tsx b/components/home/BelongSection.tsx index ba01ac0..1bc4b30 100644 --- a/components/home/BelongSection.tsx +++ b/components/home/BelongSection.tsx @@ -2,7 +2,7 @@ import { fadeInUp, staggerContainer } from "@/lib/animations"; import { motion } from "framer-motion"; -import SoftAurora from "../SoftAurora"; +import SoftAurora from "../bits/SoftAurora"; const memberTypes = [ "Solo founders figuring it out as they go", diff --git a/components/home/FeaturesSection.tsx b/components/home/FeaturesSection.tsx index e13ec1c..9084ef6 100644 --- a/components/home/FeaturesSection.tsx +++ b/components/home/FeaturesSection.tsx @@ -12,7 +12,7 @@ import { Users, Wrench, } from "lucide-react"; -import BorderGlow from "../BorderGlow"; +import BorderGlow from "../bits/BorderGlow"; const features = [ { diff --git a/components/home/HeroSection.tsx b/components/home/HeroSection.tsx index 1996dcc..d272f60 100644 --- a/components/home/HeroSection.tsx +++ b/components/home/HeroSection.tsx @@ -5,8 +5,8 @@ import data from "@/lib/staticdata.config"; import { motion } from "framer-motion"; import { ArrowDown, ArrowRight } from "lucide-react"; import { useEffect, useRef } from "react"; -import ShinyText from "../ShinyText"; -import SoftAurora from "../SoftAurora"; +import ShinyText from "../bits/ShinyText"; +import SoftAurora from "../bits/SoftAurora"; export default function HeroSection() { const canvasRef = useRef(null); diff --git a/components/home/StatsSection.tsx b/components/home/StatsSection.tsx index 7070eae..96231e8 100644 --- a/components/home/StatsSection.tsx +++ b/components/home/StatsSection.tsx @@ -5,7 +5,7 @@ import { hexToHslString } from "@/lib/utils"; import { motion, useInView } from "framer-motion"; import { Code as Code2, MessageCircle, Users } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import BorderGlow from "../BorderGlow"; +import BorderGlow from "../bits/BorderGlow"; const stats = [ { icon: Users, value: 500, label: "Members", suffix: "+", color: "#6366f1" }, diff --git a/components/Footer.tsx b/components/site/Footer.tsx similarity index 100% rename from components/Footer.tsx rename to components/site/Footer.tsx diff --git a/components/Navbar.tsx b/components/site/Navbar.tsx similarity index 100% rename from components/Navbar.tsx rename to components/site/Navbar.tsx diff --git a/components/Section.tsx b/components/site/Section.tsx similarity index 100% rename from components/Section.tsx rename to components/site/Section.tsx From 33894d9e9b84acd2b7d87310d1e93c8278f8495e Mon Sep 17 00:00:00 2001 From: Caleb Date: Thu, 9 Jul 2026 02:09:23 +0300 Subject: [PATCH 6/6] feat: add safe guards to link preview card component --- app/api/link-preview/route.ts | 54 ++++++++++++++++++++++++++++++--- app/layout.tsx | 4 +-- app/not-found.tsx | 2 +- app/page.tsx | 2 +- app/pages/[slug]/PageClient.tsx | 2 +- components/LinkPreviewCard.tsx | 2 ++ vercel.json | 25 +++++++++++++++ 7 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 vercel.json diff --git a/app/api/link-preview/route.ts b/app/api/link-preview/route.ts index 1db0e87..1b5c037 100644 --- a/app/api/link-preview/route.ts +++ b/app/api/link-preview/route.ts @@ -34,6 +34,33 @@ function decodeHtmlEntities(str: string): string { .replace(/>/g, ">"); } +const PRIVATE_HOSTNAME_PATTERNS = [ + /^localhost$/i, + /^0\.0\.0\.0$/, + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, + /^::1$/, + /^fc00:/i, + /^fe80:/i, +]; + +function isSafeUrl(candidate: string): boolean { + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return false; + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return false; + } + const hostname = parsed.hostname.toLowerCase(); + return !PRIVATE_HOSTNAME_PATTERNS.some((pattern) => pattern.test(hostname)); +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const url = searchParams.get("url"); @@ -42,10 +69,11 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "missing url" }, { status: 400 }); } - try { - new URL(url); - } catch { - return NextResponse.json({ error: "invalid url" }, { status: 400 }); + if (!isSafeUrl(url)) { + return NextResponse.json( + { error: "invalid or disallowed url" }, + { status: 400 }, + ); } try { @@ -54,6 +82,7 @@ export async function GET(request: NextRequest) { const res = await fetch(url, { signal: controller.signal, + redirect: "error", headers: { "User-Agent": "Mozilla/5.0 (compatible; LinkPreviewBot/1.0; +https://example.com/bot)", @@ -66,7 +95,21 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "failed to fetch" }, { status: 502 }); } + const contentLength = res.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) { + return NextResponse.json( + { error: "response too large" }, + { status: 502 }, + ); + } + const html = await res.text(); + if (html.length > 1024 * 1024) { + return NextResponse.json( + { error: "response too large" }, + { status: 502 }, + ); + } const rawTitle = extractMeta(html, "og:title") || extractTitleTag(html); const rawDescription = @@ -76,7 +119,8 @@ export async function GET(request: NextRequest) { let image: string | null = null; if (rawImage) { try { - image = new URL(rawImage, url).toString(); + const resolvedImage = new URL(rawImage, url).toString(); + image = isSafeUrl(resolvedImage) ? resolvedImage : null; } catch { image = null; } diff --git a/app/layout.tsx b/app/layout.tsx index 8a11ee8..6c614e7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,5 @@ -import Footer from "@/components/Footer"; -import Navbar from "@/components/Navbar"; +import Footer from "@/components/site/Footer"; +import Navbar from "@/components/site/Navbar"; import type { Metadata } from "next"; import { Geist, Geist_Mono, Pixelify_Sans } from "next/font/google"; import "./globals.css"; diff --git a/app/not-found.tsx b/app/not-found.tsx index 30f930a..2c52910 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,6 +1,6 @@ "use client"; -import FuzzyText from "@/components/FuzzyText"; +import FuzzyText from "@/components/bits/FuzzyText"; import { fadeInUp } from "@/lib/animations"; import { motion } from "framer-motion"; import { ArrowLeft, ArrowRight } from "lucide-react"; diff --git a/app/page.tsx b/app/page.tsx index 8fbbcc0..4270baa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,10 @@ +import TargetCursor from "@/components/bits/TargetCursor"; import BelongSection from "@/components/home/BelongSection"; import CTASection from "@/components/home/CTASection"; import FeaturesSection from "@/components/home/FeaturesSection"; import HeroSection from "@/components/home/HeroSection"; import ShowcaseSection from "@/components/home/ShowcaseSection"; import StatsSection from "@/components/home/StatsSection"; -import TargetCursor from "@/components/TargetCursor"; export default function Home() { return ( diff --git a/app/pages/[slug]/PageClient.tsx b/app/pages/[slug]/PageClient.tsx index efad2e5..0d8b210 100644 --- a/app/pages/[slug]/PageClient.tsx +++ b/app/pages/[slug]/PageClient.tsx @@ -40,7 +40,7 @@ function ApplySpecialClass({ text }: { text: string }) { parts.push( setImgError(true)} className="absolute top-0 right-0 w-14 h-14 object-cover" style={{ border: "1px solid rgba(99,102,241,0.12)" }} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..0c4276c --- /dev/null +++ b/vercel.json @@ -0,0 +1,25 @@ +{ + "headers": [ + { + "source": "/api/link-preview", + "headers": [ + { + "key": "Access-Control-Allow-Origin", + "value": "https://devhub.vercel.app" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "GET, OPTIONS" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "Content-Type" + }, + { + "key": "Vary", + "value": "Origin" + } + ] + } + ] +}