Skip to content

Commit da59e2b

Browse files
Merge pull request #256 from rocky-linux/develop
Merge develop into main
2 parents 302142a + aede232 commit da59e2b

File tree

5 files changed

+165
-27
lines changed

5 files changed

+165
-27
lines changed

app/[locale]/news/[slug]/page.tsx

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import type { Metadata } from "next";
2+
13
import Date from "@/components/Date";
24
import ShareButtons from "@/components/shareButtons/ShareButtons";
35

46
import { checkIfSlugIsValid, getPostData } from "@/lib/news";
7+
import { safeJsonLdStringify } from "@/utils/jsonLd";
58
import { notFound } from "next/navigation";
69

710
export type Params = {
@@ -17,9 +20,10 @@ export type PostData = {
1720
date: string;
1821
author?: string;
1922
contentHtml: string;
23+
excerpt: string;
2024
};
2125

22-
export async function generateMetadata({ params }: Props) {
26+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
2327
const { slug } = await params;
2428

2529
if (!(await checkIfSlugIsValid(slug))) {
@@ -29,9 +33,22 @@ export async function generateMetadata({ params }: Props) {
2933
}
3034

3135
const postData: PostData = await getPostData(slug);
36+
const url = `https://rockylinux.org/news/${slug}`;
37+
const author = postData.author || "Rocky Linux Team";
3238

3339
return {
3440
title: `${postData.title} - Rocky Linux`,
41+
description: postData.excerpt,
42+
openGraph: {
43+
title: postData.title,
44+
description: postData.excerpt,
45+
url,
46+
siteName: "Rocky Linux",
47+
locale: "en_US",
48+
type: "article",
49+
publishedTime: postData.date,
50+
authors: [author],
51+
},
3552
};
3653
}
3754

@@ -43,25 +60,53 @@ export default async function Post({ params }: Props) {
4360
}
4461

4562
const postData: PostData = await getPostData(slug);
63+
const author = postData.author || "Rocky Linux Team";
64+
65+
const jsonLd = {
66+
"@context": "https://schema.org",
67+
"@type": "NewsArticle",
68+
headline: postData.title,
69+
description: postData.excerpt,
70+
datePublished: postData.date,
71+
author: {
72+
"@type": "Person",
73+
name: author,
74+
},
75+
publisher: {
76+
"@type": "Organization",
77+
name: "Rocky Linux",
78+
url: "https://rockylinux.org",
79+
},
80+
mainEntityOfPage: {
81+
"@type": "WebPage",
82+
"@id": `https://rockylinux.org/news/${slug}`,
83+
},
84+
};
4685

4786
return (
48-
<div className="py-24 sm:py-32">
49-
<div className="mx-auto max-w-3xl text-base leading-7">
50-
<p className="text-base font-semibold leading-7 text-primary text-center uppercase font-display">
51-
<Date dateString={postData.date} />
52-
</p>
53-
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl mb-2 text-center font-display">
54-
{postData.title}
55-
</h1>
56-
<p className="text-base leading-7 text-center mb-12 italic">
57-
{postData.author ? postData.author : "Rocky Linux Team"}
58-
</p>
59-
<div
60-
className="prose dark:prose-invert prose-headings:font-display prose-a:text-primary prose-pre:bg-muted prose-pre:py-3 prose-pre:px-4 prose-pre:rounded prose-pre:text-black dark:prose-pre:text-white prose-img:rounded-md max-w-none mb-12"
61-
dangerouslySetInnerHTML={{ __html: postData.contentHtml }}
62-
/>
63-
<ShareButtons url={`https://rockylinux.org/news/${slug}`} />
87+
<>
88+
<script
89+
type="application/ld+json"
90+
dangerouslySetInnerHTML={{ __html: safeJsonLdStringify(jsonLd) }}
91+
/>
92+
<div className="py-24 sm:py-32">
93+
<div className="mx-auto max-w-3xl text-base leading-7">
94+
<p className="text-base font-semibold leading-7 text-primary text-center uppercase font-display">
95+
<Date dateString={postData.date} />
96+
</p>
97+
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl mb-2 text-center font-display">
98+
{postData.title}
99+
</h1>
100+
<p className="text-base leading-7 text-center mb-12 italic">
101+
{author}
102+
</p>
103+
<div
104+
className="prose dark:prose-invert prose-headings:font-display prose-a:text-primary prose-pre:bg-muted prose-pre:py-3 prose-pre:px-4 prose-pre:rounded prose-pre:text-black dark:prose-pre:text-white prose-img:rounded-md max-w-none mb-12"
105+
dangerouslySetInnerHTML={{ __html: postData.contentHtml }}
106+
/>
107+
<ShareButtons url={`https://rockylinux.org/news/${slug}`} />
108+
</div>
64109
</div>
65-
</div>
110+
</>
66111
);
67112
}

app/[locale]/news/page.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { NextPage, Route } from "next";
1+
import type { Metadata, NextPage, Route } from "next";
22

33
import { getTranslations } from "next-intl/server";
44
import Link from "next/link";
@@ -14,12 +14,22 @@ import {
1414
CardTitle,
1515
} from "@/components/ui/card";
1616

17-
export async function generateMetadata() {
17+
export async function generateMetadata(): Promise<Metadata> {
1818
const t = await getTranslations("news");
19+
const title = t("title");
20+
const description = t("description");
1921

2022
return {
21-
title: `${t("title")} - Rocky Linux`,
22-
description: `${t("description")}`,
23+
title: `${title} - Rocky Linux`,
24+
description,
25+
openGraph: {
26+
title,
27+
description,
28+
url: "https://rockylinux.org/news",
29+
siteName: "Rocky Linux",
30+
locale: "en_US",
31+
type: "website",
32+
},
2333
};
2434
}
2535

lib/news.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ export async function checkIfSlugIsValid(slug: string) {
2222
}
2323
}
2424

25-
export async function getSortedPostsData(
26-
numPosts?: number
27-
): Promise<
25+
export async function getSortedPostsData(numPosts?: number): Promise<
2826
{
2927
slug: string;
3028
excerpt: string;
@@ -46,7 +44,7 @@ export async function getSortedPostsData(
4644

4745
const contentHtml = await processMarkdownAsHTML(matterResult.content);
4846
const plainText = contentHtml.replace(/<[^>]*>/g, "");
49-
const excerpt = plainText.substring(0, 200) + "...";
47+
const excerpt = plainText.substring(0, 150) + "...";
5048

5149
return {
5250
slug,
@@ -94,7 +92,7 @@ export async function getPostData(slug: string) {
9492
const contentHtml = await processMarkdownAsHTML(matterResult.content);
9593

9694
const plainText = contentHtml.replace(/<[^>]*>/g, "");
97-
const excerpt = plainText.substring(0, 200) + "...";
95+
const excerpt = plainText.substring(0, 150) + "...";
9896

9997
return {
10098
slug,

utils/__tests__/jsonLd.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { safeJsonLdStringify } from "../jsonLd";
2+
3+
describe("safeJsonLdStringify", () => {
4+
it("should stringify a simple object", () => {
5+
const data = { name: "Test", value: 123 };
6+
const result = safeJsonLdStringify(data);
7+
expect(result).toBe('{"name":"Test","value":123}');
8+
});
9+
10+
it("should escape < characters to prevent script injection", () => {
11+
const data = { title: "<script>alert('xss')</script>" };
12+
const result = safeJsonLdStringify(data);
13+
expect(result).not.toContain("<");
14+
expect(result).toContain("\\u003c");
15+
});
16+
17+
it("should escape > characters to prevent script injection", () => {
18+
const data = { title: "test</script><script>alert('xss')" };
19+
const result = safeJsonLdStringify(data);
20+
expect(result).not.toContain(">");
21+
expect(result).toContain("\\u003e");
22+
});
23+
24+
it("should escape & characters to prevent HTML entity issues", () => {
25+
const data = { title: "Rocky & Linux" };
26+
const result = safeJsonLdStringify(data);
27+
expect(result).not.toContain("&");
28+
expect(result).toContain("\\u0026");
29+
});
30+
31+
it("should escape all dangerous characters in complex content", () => {
32+
const data = {
33+
title: "<script>alert('xss')</script>",
34+
description: "Test & verify > safety < concerns",
35+
};
36+
const result = safeJsonLdStringify(data);
37+
expect(result).not.toContain("<");
38+
expect(result).not.toContain(">");
39+
expect(result).not.toContain("&");
40+
});
41+
42+
it("should handle nested objects", () => {
43+
const data = {
44+
"@context": "https://schema.org",
45+
author: {
46+
"@type": "Person",
47+
name: "Test <Author>",
48+
},
49+
};
50+
const result = safeJsonLdStringify(data);
51+
expect(result).not.toContain("<");
52+
expect(result).not.toContain(">");
53+
});
54+
55+
it("should handle arrays", () => {
56+
const data = {
57+
tags: ["<script>", "test & tag", "normal"],
58+
};
59+
const result = safeJsonLdStringify(data);
60+
expect(result).not.toContain("<");
61+
expect(result).not.toContain("&");
62+
});
63+
64+
it("should produce valid JSON when parsed (after unescaping)", () => {
65+
const data = {
66+
"@context": "https://schema.org",
67+
"@type": "NewsArticle",
68+
headline: "Test Article",
69+
datePublished: "2025-01-01",
70+
};
71+
const result = safeJsonLdStringify(data);
72+
// The escaped output should still be valid JSON
73+
expect(() => JSON.parse(result)).not.toThrow();
74+
});
75+
});

utils/jsonLd.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Sanitizes and stringifies an object for safe use in JSON-LD script tags.
3+
* Prevents XSS by escaping characters that could break out of the script context.
4+
*/
5+
export function safeJsonLdStringify(data: Record<string, unknown>): string {
6+
return JSON.stringify(data)
7+
.replace(/</g, "\\u003c")
8+
.replace(/>/g, "\\u003e")
9+
.replace(/&/g, "\\u0026");
10+
}

0 commit comments

Comments
 (0)