Commit 7fe3590c authored by Phạm Quang Bảo's avatar Phạm Quang Bảo

[tag]0.1-vcci

parents 91aa0f69 290840a0
Pipeline #52995 passed with stages
in 7 minutes and 49 seconds
......@@ -152,9 +152,6 @@ importers:
react-resizable-panels:
specifier: ^3.0.6
version: 3.0.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
sharp:
specifier: 0.34.5
version: 0.34.5
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
......@@ -4254,7 +4251,8 @@ snapshots:
transitivePeerDependencies:
- encoding
'@img/colour@1.0.0': {}
'@img/colour@1.0.0':
optional: true
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
......@@ -7468,6 +7466,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
shebang-command@2.0.0:
dependencies:
......
......@@ -10,17 +10,15 @@ import {
import DynamicPageClient from "./DynamicPageClient";
/**
* Build an absolute og:image URL routed through the custom /api/seo-image
* endpoint which resizes the source image to 1200x630 JPEG (~a few hundred KB)
* so social media crawlers receive a reasonably-sized image instead of the
* original full-resolution upload (which can be 5+ MB and get rejected by
* Twitter/Zalo/Facebook).
* Build an absolute og:image URL. Relative paths (e.g. "/thumbnail.png") are
* resolved against the site origin so social crawlers always receive an
* absolute URL. Absolute URLs are returned as-is.
*/
function toSeoImageUrl(imageUrl: string): string {
function toAbsoluteSeoImageUrl(imageUrl: string): string {
if (!imageUrl) return "";
if (/^https?:\/\//i.test(imageUrl)) return imageUrl;
const origin = (links.siteURL || "").replace(/\/+$/, "");
const encoded = encodeURIComponent(imageUrl);
return `${origin}/api/seo-image?url=${encoded}`;
return `${origin}${imageUrl.startsWith("/") ? "" : "/"}${imageUrl}`;
}
type GenerateMetadataArgs = {
......@@ -59,11 +57,10 @@ export async function generateMetadata({
searchParams,
}: GenerateMetadataArgs): Promise<Metadata> {
const { slug } = await params;
const { id, categoryId } = await searchParams;
const { id } = await searchParams;
const path = `/${(slug ?? []).join("/")}`;
const postId = id?.trim() ?? "";
const categoryIdParam = categoryId?.trim() ?? "";
let post = null;
try {
......@@ -88,16 +85,10 @@ export async function generateMetadata({
"Tin tức từ VCCI HCM";
const rawImageUrl = getDynamicPostSeoImage(post);
const isImageValid = await isRemoteImageValid(rawImageUrl);
const imageUrl = toSeoImageUrl(
const imageUrl = toAbsoluteSeoImageUrl(
isImageValid ? rawImageUrl : "/thumbnail.png",
);
const articleUrl = `${links.siteURL.replace(/\/+$/, "")}${path}${postId || categoryIdParam
? `?${new URLSearchParams({
...(postId && { id: postId }),
...(categoryIdParam && { categoryId: categoryIdParam }),
}).toString()}`
: ""
}`;
const articleUrl = `${links.siteURL.replace(/\/+$/, "")}${path}`;
return {
title,
......
......@@ -127,26 +127,10 @@ const normalizePath = (value?: string | null) => {
export const buildDynamicPostHref = (
path?: string | null,
id?: string | null,
categoryId?: string | null,
_id?: string | null,
_categoryId?: string | null,
) => {
const normalizedPath = normalizePath(path);
const trimmedId = id?.trim() ?? "";
const trimmedCategoryId = categoryId?.trim() ?? "";
if ((!trimmedId && !trimmedCategoryId) || normalizedPath === "/") {
return normalizedPath;
}
const params = new URLSearchParams();
if (trimmedId) {
params.set("id", trimmedId);
}
if (trimmedCategoryId) {
params.set("categoryId", trimmedCategoryId);
}
return `${normalizedPath}?${params.toString()}`;
return normalizePath(path);
};
const getSlugFromPath = (value?: string | null) => {
......
import { NextRequest, NextResponse } from "next/server";
import sharp from "sharp";
import links from "@/links";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const ALLOWED_HOSTS = ["vcci-hcm.org.vn", "vccihcm.vn"];
const OUTPUT_WIDTH = 1200;
const OUTPUT_HEIGHT = 630;
const OUTPUT_QUALITY = 80;
type FetchResult = {
ok: boolean;
buffer?: Buffer;
contentType?: string;
};
/**
* Resolve a relative path (e.g. "/thumbnail.png") to an absolute URL on the
* current site so we can fetch it from the local server.
*/
function resolveAbsoluteUrl(value: string): string {
if (/^https?:\/\//i.test(value)) return value;
const origin = (links.siteURL || "").replace(/\/+$/, "");
return `${origin}${value.startsWith("/") ? "" : "/"}${value}`;
}
/**
* Validate that an absolute URL points to one of the allowed image hosts so
* the endpoint cannot be abused as an open proxy.
*/
function isAllowedHost(url: string): boolean {
try {
const parsed = new URL(url);
return ALLOWED_HOSTS.includes(parsed.hostname);
} catch {
return false;
}
}
async function fetchImage(url: string): Promise<FetchResult> {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(10000),
redirect: "follow",
});
if (!response.ok) return { ok: false };
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) return { ok: false };
const arrayBuffer = await response.arrayBuffer();
return { ok: true, buffer: Buffer.from(arrayBuffer), contentType };
} catch {
return { ok: false };
}
}
async function optimizeImage(buffer: Buffer): Promise<Buffer> {
return sharp(buffer)
.resize(OUTPUT_WIDTH, OUTPUT_HEIGHT, {
fit: "cover",
position: "attention",
withoutEnlargement: true,
})
.jpeg({ quality: OUTPUT_QUALITY, mozjpeg: true })
.toBuffer();
}
export async function GET(request: NextRequest) {
const urlParam = request.nextUrl.searchParams.get("url")?.trim() ?? "";
if (!urlParam) {
return new NextResponse("Missing url parameter", { status: 400 });
}
const absoluteUrl = resolveAbsoluteUrl(urlParam);
if (!isAllowedHost(absoluteUrl)) {
return new NextResponse("Host not allowed", { status: 403 });
}
const fetched = await fetchImage(absoluteUrl);
let imageBuffer: Buffer;
if (fetched.ok && fetched.buffer) {
const optimized = await optimizeImage(fetched.buffer).catch(() => null);
imageBuffer = optimized ?? fetched.buffer;
} else {
const fallbackUrl = resolveAbsoluteUrl("/thumbnail.png");
const fallback = await fetchImage(fallbackUrl);
if (!fallback.ok || !fallback.buffer) {
return new NextResponse("Image not found", { status: 404 });
}
const optimized = await optimizeImage(fallback.buffer).catch(() => null);
imageBuffer = optimized ?? fallback.buffer;
}
return new NextResponse(new Uint8Array(imageBuffer), {
status: 200,
headers: {
"Content-Type": "image/jpeg",
"Cache-Control":
"public, max-age=86400, s-maxage=2592000, stale-while-revalidate=604800",
"X-Content-Type-Options": "nosniff",
},
});
}
export async function HEAD(request: NextRequest) {
const urlParam = request.nextUrl.searchParams.get("url")?.trim() ?? "";
if (!urlParam) {
return new NextResponse(null, { status: 400 });
}
const absoluteUrl = resolveAbsoluteUrl(urlParam);
if (!isAllowedHost(absoluteUrl)) {
return new NextResponse(null, { status: 403 });
}
return new NextResponse(null, {
status: 200,
headers: {
"Content-Type": "image/jpeg",
"Cache-Control":
"public, max-age=86400, s-maxage=2592000, stale-while-revalidate=604800",
},
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment