Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions app/admin/community/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { useEffect, useState } from "react";
import { AdminGuard } from "@/app/admin/events/AdminGuard";
import type { SharedLinkView } from "@/app/feed/types";
import { sanitizeExternalUrl } from "@/lib/url-safety";
import { sanitizeExternalUrl, sanitizeMediaUrl } from "@/lib/url-safety";
import { approveLink, listPendingLinks, rejectLink } from "./lib";

export default function AdminCommunityPage() {
Expand Down Expand Up @@ -134,19 +134,23 @@ function AdminCommunityInner() {
图床防盗链会检查 Referer,非本站来源返回"未经允许"裂图。
next/image 的 remotePatterns 限制外站域名也一并规避。 */}
<div className="w-full md:w-40 aspect-[16/9] flex-shrink-0 bg-neutral-100 dark:bg-neutral-900 relative overflow-hidden">
{link.ogCover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={link.ogCover}
alt={link.ogTitle ?? link.url}
referrerPolicy="no-referrer"
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-3xl font-bold text-neutral-400">
{link.host[0]?.toUpperCase() ?? "?"}
</span>
)}
{(() => {
// defense-in-depth:过 sanitizeMediaUrl 拦 javascript:/data: 协议
const safeCover = sanitizeMediaUrl(link.ogCover);
return safeCover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={safeCover}
alt={link.ogTitle ?? link.url}
referrerPolicy="no-referrer"
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<span className="absolute inset-0 flex items-center justify-center text-3xl font-bold text-neutral-400">
{link.host[0]?.toUpperCase() ?? "?"}
</span>
);
})()}
</div>

{/* 中:元信息 */}
Expand Down
15 changes: 6 additions & 9 deletions app/components/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,29 +94,26 @@ export async function Hero() {
<p className="font-body text-sm mb-6 opacity-80">
{t("join.body")}
</p>
{/* 双阅读入口:严肃文档 + 社区随手分享,视觉同构;投稿动作已在 Hero 左侧 Contribute/ShareLink */}
{/* 双阅读入口:严肃文档 + 社区随手分享,视觉同构;投稿动作已在 Hero 左侧 Contribute/ShareLink。
直接把 <Link> 渲染成按钮样式,避免 <a><button> 嵌套交互元素(HTML 无效 + 键盘/a11y 问题)。 */}
<div className="flex flex-col gap-3">
<Link
href="/docs/learn/ai"
className="block w-full"
className="block w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest text-center hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Access Articles"
>
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
{t("cta.access")}
</button>
{t("cta.access")}
</Link>
<Link
href="/feed"
className="block w-full"
className="block w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest text-center hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer"
data-umami-event="navigation_click"
data-umami-event-region="hero_cta"
data-umami-event-label="Community Feed"
>
<button className="w-full py-3 border border-[var(--background)] font-sans text-xs uppercase tracking-widest hover:bg-[var(--background)] hover:text-[var(--foreground)] transition-all cursor-pointer">
{t("cta.feed")}
</button>
{t("cta.feed")}
</Link>
</div>
</div>
Expand Down
7 changes: 5 additions & 2 deletions app/feed/components/LinkCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useTranslations } from "next-intl";
import type { SharedLinkView } from "@/app/feed/types";
import { ReportButton } from "@/app/feed/components/ReportButton";
import { Badge } from "@/components/ui/badge";
import { sanitizeMediaUrl } from "@/lib/url-safety";

interface LinkCardProps {
link: SharedLinkView;
Expand All @@ -27,6 +28,8 @@ function getHostInitial(host: string): string {

export function LinkCard({ link, categoryLabel, isLoggedIn }: LinkCardProps) {
const t = useTranslations("feed.card");
// defense-in-depth:过白名单协议拦 javascript:/data:,后端 UrlNormalizer 是第一道,这里是第二道
const safeOgCover = sanitizeMediaUrl(link.ogCover);

Comment on lines 29 to 33
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LinkCard now sanitizes link.ogCover, but the card’s main <a href={link.url}> still uses a backend-provided URL without going through sanitizeExternalUrl. Since url-safety.ts documents that any backend/user-provided URLs must be sanitized before rendering, this remains an XSS vector (e.g., javascript:). Consider sanitizing link.url and rendering the card as non-clickable (or fallback to text) when the URL is unsafe.

Copilot uses AI. Check for mistakes.
return (
<li className="group border border-[var(--foreground)] hover:border-[#CC0000] transition-colors duration-150 flex flex-col">
Expand All @@ -39,14 +42,14 @@ export function LinkCard({ link, categoryLabel, isLoggedIn }: LinkCardProps) {
aria-label={link.ogTitle ?? link.url}
>
{/* OG 封面 / 占位块 */}
{link.ogCover && !link.ogFetchFailed ? (
{safeOgCover && !link.ogFetchFailed ? (
// next/image 全站 unoptimized:true,用 img 即可(与 events 页一致)。
// referrerPolicy="no-referrer":微信 mmbiz.qpic.cn 防盗链会检查 Referer,
// 非 mp.weixin.qq.com 来源直接返回"未经允许使用"裂图;不发 Referer 时
// 反而放行(微信客户端内打开文章浏览器也不发 Referer)。
// eslint-disable-next-line @next/next/no-img-element
<img
src={link.ogCover}
src={safeOgCover}
alt={link.ogTitle ?? link.host}
referrerPolicy="no-referrer"
className="w-full aspect-[16/9] object-cover border-b border-[var(--foreground)]"
Expand Down
Loading