feat: formatted links in descriptions

This commit is contained in:
trafficlunar 2026-04-09 15:50:46 +01:00
parent 913f0ef65a
commit 0e2df242d0
2 changed files with 100 additions and 63 deletions

72
src/app/out/page.tsx Normal file
View file

@ -0,0 +1,72 @@
import { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Icon } from "@iconify/react";
export const metadata: Metadata = {
title: "Leaving TomodachiShare",
description: "Warning: You are leaving TomodachiShare, proceed with caution",
};
interface Props {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function LinkOutPage({ searchParams }: Props) {
const url = (await searchParams).url;
if (!url || Array.isArray(url)) redirect("/");
let parsed: URL;
try {
parsed = new URL(url);
} catch {
redirect("/"); // redirect if URL is invalid
}
// Next.js doesn't allow attacks like these but you can never be too safe
if (!["http:", "https:"].includes(parsed.protocol)) redirect("/");
const isSafe = Array.from(SAFE_LINKS).some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`));
if (isSafe) redirect(url);
return (
<div className="grow flex items-center justify-center">
<div className="bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg py-8 px-6 max-w-md w-full text-center flex flex-col items-center">
<h2 className="text-3xl font-black flex items-center gap-2 mb-1">
<Icon icon="mingcute:alert-fill" className="text-5xl" />
Warning
</h2>
<p>You're attempting to leave TomodachiShare island! The destination website is potentially dangerous.</p>
<div className="bg-zinc-100 border border-zinc-300 rounded-md p-2 break-all w-full mt-4">
<code className="font-mono text-sm">{url}</code>
</div>
<div className="flex justify-center gap-2">
<Link href="/" className="pill button gap-2 mt-8 w-fit self-center bg-zinc-100! border-zinc-300! hover:bg-zinc-300!">
<Icon icon="ic:round-home" fontSize={24} />
Travel Back
</Link>
<Link href={url} target="_blank" rel="noopener noreferrer" className="pill button gap-2 mt-8 w-fit self-center">
<Icon icon="ic:round-open-in-new" fontSize={21} />
Continue
</Link>
</div>
</div>
</div>
);
}
const SAFE_LINKS = new Set([
"tomodachishare.com",
"trafficlunar.net",
"youtube.com",
"youtu.be",
"twitter.com",
"x.com",
"reddit.com",
"tiktok.com",
"tumblr.com",
"instagram.com",
"wikipedia.org",
]);

View file

@ -1,78 +1,43 @@
import Image from "next/image"; import { Icon } from "@iconify/react";
import Link from "next/link"; import Link from "next/link";
import { prisma } from "@/lib/prisma";
import ProfilePicture from "./profile-picture";
interface Props { interface Props {
text: string; text: string;
className?: string; className?: string;
} }
// Adds fancy formatting to links
export default function Description({ text, className }: Props) { export default function Description({ text, className }: Props) {
const urlRegex = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlRegex);
return ( return (
<p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}> <p className={`text-sm mt-2 bg-white/50 p-3 rounded-lg border border-orange-200 whitespace-break-spaces max-h-54 overflow-y-auto ${className}`}>
{/* Adds fancy formatting when linking to other pages on the site */} {parts.map(async (part, index) => {
{(() => { try {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://tomodachishare.com"; // Check if it's a URL
if (!urlRegex.test(part)) throw new Error("Not a URL");
// Match both mii and profile links const url = new URL(part);
const regex = new RegExp(`(${baseUrl.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}/(?:mii|profile)/\\d+)`, "g");
const parts = text.split(regex);
return parts.map(async (part, index) => {
const miiMatch = part.match(new RegExp(`^${baseUrl}/mii/(\\d+)$`));
const profileMatch = part.match(new RegExp(`^${baseUrl}/profile/(\\d+)$`));
if (miiMatch) {
const id = Number(miiMatch[1]);
const linkedMii = await prisma.mii.findUnique({
where: {
id,
},
});
if (!linkedMii) return;
return ( return (
<Link <Link
key={index} key={index}
href={`/mii/${id}`} href={`/out?url=${encodeURIComponent(part)}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-amber-100 border border-amber-400 rounded-lg mx-1 text-amber-800 text-xs" target="_blank"
className="text-blue-700 underline break-all ml-1 inline-flex items-center group"
title={`Go to ${url.hostname}`}
> >
<Image src={`/mii/${id}/image?type=mii`} alt="mii" width={24} height={24} className="bg-white rounded-lg border-r border-amber-400" /> {url.hostname}
{linkedMii.name} {url.pathname !== "/" ? url.pathname : ""}
{url.search}
<Icon icon="mi:arrow-right-up" fontSize={16} className="transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</Link> </Link>
); );
} } catch {
// Normal text/Invalid URL fallback
if (profileMatch) {
const id = Number(profileMatch[1]);
const linkedProfile = await prisma.user.findUnique({
where: {
id,
},
});
if (!linkedProfile) return;
return (
<Link
key={index}
href={`/profile/${id}`}
className="inline-flex items-center align-bottom gap-1.5 pr-2 bg-orange-100 border border-orange-400 rounded-lg mx-1 text-orange-800 text-xs"
>
<ProfilePicture src={linkedProfile.image || "/guest.png"} width={24} height={24} className="bg-white rounded-lg border-r border-orange-400" />
{linkedProfile.name}
</Link>
);
}
// Regular text
return <span key={index}>{part}</span>; return <span key={index}>{part}</span>;
}); }
})()} })}
</p> </p>
); );
} }