Skip to content

Commit 21138ee

Browse files
committed
feat: Implement admin CRUD pages for articles and team members, alongside new utility functions for formatting and sanitization.
1 parent 7134dd4 commit 21138ee

5 files changed

Lines changed: 20 additions & 50 deletions

File tree

app/admin/articles/[id]/page.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { Checkbox } from "@/components/ui/checkbox";
2222
import { useToast } from "@/hooks/use-toast";
2323
import { Breadcrumb } from "@/components/breadcrumb";
2424
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
25-
import { formatDate, sanitizeText } from "@/lib/utils";
25+
import { formatDate, sanitizeUrl, sanitizeText } from "@/lib/utils";
2626
import {
2727
Loader2,
2828
AlertTriangle,
@@ -629,14 +629,7 @@ export default function EditArticlePage() {
629629
{img ? (
630630
<div className="relative">
631631
<img
632-
src={
633-
img &&
634-
(img.startsWith("http://") ||
635-
img.startsWith("https://") ||
636-
img.startsWith("/"))
637-
? img
638-
: "/placeholder.svg"
639-
}
632+
src={sanitizeUrl(img) || "/placeholder.svg"}
640633
alt={sanitizeText(imgAlt) || "Thumbnail Preview"}
641634
className="max-h-64 object-contain border border-white/20 rounded-lg"
642635
onError={(e) => {

app/admin/articles/new/page.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2424
import { Loader2, ImageIcon, Save } from "lucide-react";
2525
import { marked } from "marked";
2626
import DOMPurify from "dompurify";
27-
import { generateSlugFromTitle, sanitizeText } from "@/lib/utils";
27+
import { generateSlugFromTitle, sanitizeUrl, sanitizeText } from "@/lib/utils";
2828
import { MarkdownToolbar } from "@/components/markdown-toolbar";
2929
import { AssetManager } from "@/components/admin/assets/asset-manager";
3030
import { convertImageToWebP } from "@/lib/image-utils";
@@ -583,14 +583,7 @@ export default function NewArticlePage() {
583583
{img ? (
584584
<div className="relative">
585585
<img
586-
src={
587-
img &&
588-
(img.startsWith("http://") ||
589-
img.startsWith("https://") ||
590-
img.startsWith("/"))
591-
? img
592-
: "/placeholder.svg"
593-
}
586+
src={sanitizeUrl(img) || "/placeholder.svg"}
594587
alt={sanitizeText(imgAlt) || "Thumbnail Preview"}
595588
className="max-h-64 object-contain border border-white/20 rounded-lg"
596589
onError={(e) => {

app/admin/team/[id]/page.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
X,
2222
Camera,
2323
} from "lucide-react";
24+
import { sanitizeUrl } from "@/lib/utils";
2425
import { AvatarCropper } from "@/components/profile/avatar-cropper";
2526

2627
interface Member {
@@ -381,14 +382,7 @@ export default function EditTeamMemberPage() {
381382
<div className="w-40 h-40 rounded-none overflow-hidden flex items-center justify-center border border-white/20 relative">
382383
{avatarPreview ? (
383384
<img
384-
src={
385-
avatarPreview &&
386-
(avatarPreview.startsWith("http://") ||
387-
avatarPreview.startsWith("https://") ||
388-
avatarPreview.startsWith("/"))
389-
? avatarPreview
390-
: "/placeholder.svg"
391-
}
385+
src={sanitizeUrl(avatarPreview) || "/placeholder.svg"}
392386
alt={member.imgAlt || member.name}
393387
className="w-full h-full object-cover"
394388
onError={(e) => {

app/admin/team/new/page.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
X,
2121
UserCircle,
2222
} from "lucide-react";
23-
import { generateSlugFromTitle } from "@/lib/utils";
23+
import { generateSlugFromTitle, sanitizeUrl } from "@/lib/utils";
2424

2525
export default function NewTeamMemberPage() {
2626
const [role, setRole] = useState("");
@@ -292,14 +292,7 @@ export default function NewTeamMemberPage() {
292292
<div className="w-40 h-40 rounded-none overflow-hidden flex items-center justify-center border border-white/20">
293293
{avatar ? (
294294
<img
295-
src={
296-
avatar &&
297-
(avatar.startsWith("http://") ||
298-
avatar.startsWith("https://") ||
299-
avatar.startsWith("/"))
300-
? avatar
301-
: "/placeholder.svg"
302-
}
295+
src={sanitizeUrl(avatar) || "/placeholder.svg"}
303296
alt={imgAlt || "Profile preview"}
304297
className="w-full h-full object-cover"
305298
onError={(e) => {

lib/utils.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,27 +41,24 @@ export function generateSlugFromTitle(title: string): string {
4141

4242
export function sanitizeUrl(url: string | null | undefined): string {
4343
if (!url) return "";
44-
const trimmed = url.trim();
4544

4645
try {
47-
// Try parsing as absolute URL
48-
const parsed = new URL(trimmed);
49-
// Only allow http and https
46+
// Parse using a dummy origin to handle relative URLs natively
47+
const parsed = new URL(url.trim(), "http://dummy.local");
48+
49+
// Only allow safe protocols
5050
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
51+
// If the origin is our dummy local origin, it was a relative URL!
52+
if (parsed.hostname === "dummy.local") {
53+
// Return only the path and query string (reconstructed, so taint is dropped!)
54+
return parsed.pathname + parsed.search + parsed.hash;
55+
}
56+
57+
// Otherwise, it was an absolute URL with http/https
5158
return parsed.href;
5259
}
53-
return "";
5460
} catch (e) {
55-
// If it fails to parse, it might be a relative path
56-
// Allow clean relative paths
57-
if (/^(\/|\.\/|\.\.\/)/.test(trimmed)) {
58-
// Ensure it doesn't contain a hidden javascript: protocol (e.g. decoded)
59-
const decoded = decodeURIComponent(trimmed);
60-
if (/^(javascript|data|vbscript|file|blob):/i.test(decoded)) {
61-
return "";
62-
}
63-
return trimmed;
64-
}
61+
// URL was completely unparsable
6562
}
6663

6764
return "";

0 commit comments

Comments
 (0)