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
2 changes: 1 addition & 1 deletion apps/sanity/scripts/sync-guest-socials.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1800,7 +1800,7 @@ async function main() {
const patchMutations = ENRICHED_GUESTS.map((g) => ({
patch: {
id: g.id,
set: {
setIfMissing: {
socials: g.socials,
},
unset: ["socials.email", "portalToken", "portalTokenExpiresAt"],
Expand Down
302 changes: 302 additions & 0 deletions apps/site/src/lib/guest-portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,305 @@ export function normalizeSocialInput(
return `https://${clean}`;
}
}

export interface PortableTextSpan {
_type: "span";
_key: string;
text: string;
marks: string[];
}

export interface PortableTextMarkDef {
_type: "link";
_key: string;
href: string;
blank?: boolean;
}

export interface PortableTextBlock {
_type: "block";
_key: string;
style: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote";
listItem?: "bullet" | "number";
level?: number;
markDefs: PortableTextMarkDef[];
children: PortableTextSpan[];
}

function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

function generateKey(): string {
const bytes = new Uint8Array(6);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

/**
* Converts Sanity Portable Text blocks into semantic HTML for the rich text editor.
*/
export function portableTextToHtml(
rawBlocks: unknown[] | undefined | null,
): string {
if (!Array.isArray(rawBlocks) || rawBlocks.length === 0) {
return "<h2>About</h2><p></p>";
}

const htmlParts: string[] = [];
let currentListType: "bullet" | "number" | null = null;

const closeListIfOpen = () => {
if (currentListType === "bullet") {
htmlParts.push("</ul>");
currentListType = null;
} else if (currentListType === "number") {
htmlParts.push("</ol>");
currentListType = null;
}
};

for (const raw of rawBlocks) {
if (!raw || typeof raw !== "object") {
continue;
}
const block = raw as Record<string, unknown>;
if (block._type !== "block") {
continue;
}

const markDefs = Array.isArray(block.markDefs)
? (block.markDefs as Array<Record<string, unknown>>)
: [];
const markDefByKey = new Map<string, Record<string, unknown>>();
for (const md of markDefs) {
if (typeof md._key === "string") {
markDefByKey.set(md._key, md);
}
}

const children = Array.isArray(block.children)
? (block.children as Array<Record<string, unknown>>)
: [];

const inlineHtml = children
.map((child) => {
const rawText = typeof child.text === "string" ? child.text : "";
let rendered = escapeHtml(rawText).replace(/\n/g, "<br>");
const marks = Array.isArray(child.marks)
? (child.marks as string[])
: [];

for (const mark of marks) {
if (mark === "strong") {
rendered = `<strong>${rendered}</strong>`;
} else if (mark === "em") {
rendered = `<em>${rendered}</em>`;
} else if (mark === "code") {
rendered = `<code>${rendered}</code>`;
} else if (mark === "underline") {
rendered = `<u>${rendered}</u>`;
} else if (mark === "strike-through") {
rendered = `<s>${rendered}</s>`;
} else {
const md = markDefByKey.get(mark);
if (md && typeof md.href === "string") {
const safeHref = escapeHtml(md.href);
rendered = `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${rendered}</a>`;
}
}
}
return rendered;
})
.join("");

const listItem =
block.listItem === "bullet" || block.listItem === "number"
? block.listItem
: null;

if (listItem) {
if (currentListType !== listItem) {
closeListIfOpen();
htmlParts.push(listItem === "bullet" ? "<ul>" : "<ol>");
currentListType = listItem;
}
htmlParts.push(`<li>${inlineHtml || "<br>"}</li>`);
continue;
}

closeListIfOpen();

const style = typeof block.style === "string" ? block.style : "normal";
const tag =
style === "h1" ||
style === "h2" ||
style === "h3" ||
style === "h4" ||
style === "blockquote"
? style
: "p";
htmlParts.push(`<${tag}>${inlineHtml || "<br>"}</${tag}>`);
}

closeListIfOpen();
return htmlParts.join("");
}

const ALLOWED_STYLES = new Set([
"normal",
"h1",
"h2",
"h3",
"h4",
"blockquote",
]);
const ALLOWED_DECORATORS = new Set([
"strong",
"em",
"code",
"underline",
"strike-through",
]);

/**
* Validates and normalizes incoming Portable Text blocks before persisting to Sanity.
*/
export function sanitizePortableTextBlocks(
rawBlocks: unknown,
): PortableTextBlock[] {
if (!Array.isArray(rawBlocks)) {
return [];
}

const sanitized: PortableTextBlock[] = [];

for (const item of rawBlocks) {
if (!item || typeof item !== "object") {
continue;
}
const b = item as Record<string, unknown>;
if (b._type !== "block") {
continue;
}

const styleRaw = typeof b.style === "string" ? b.style : "normal";
const style = (
ALLOWED_STYLES.has(styleRaw) ? styleRaw : "normal"
) as PortableTextBlock["style"];

const listItem =
b.listItem === "bullet" || b.listItem === "number"
? b.listItem
: undefined;

const rawMarkDefs = Array.isArray(b.markDefs)
? (b.markDefs as Array<Record<string, unknown>>)
: [];
const validMarkDefs: PortableTextMarkDef[] = [];
const validMarkKeys = new Set<string>();

for (const md of rawMarkDefs) {
if (!md || typeof md !== "object") {
continue;
}
const rawHref = typeof md.href === "string" ? md.href.trim() : "";
if (!rawHref) {
continue;
}
// Only allow http(s)://, mailto:, or relative / paths
const safeHref = /^(https?:\/\/|mailto:|\/)/i.test(rawHref)
? rawHref
: `https://${rawHref}`;
const key =
typeof md._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(md._key)
? md._key
: generateKey();
validMarkDefs.push({
_type: "link",
_key: key,
href: safeHref,
blank: true,
});
validMarkKeys.add(key);
}

const rawChildren = Array.isArray(b.children)
? (b.children as Array<Record<string, unknown>>)
: [];
const validChildren: PortableTextSpan[] = [];

for (const ch of rawChildren) {
if (!ch || typeof ch !== "object") {
continue;
}
const text = typeof ch.text === "string" ? ch.text : "";
const rawMarks = Array.isArray(ch.marks) ? (ch.marks as unknown[]) : [];
const marks = rawMarks.filter(
(m): m is string =>
typeof m === "string" &&
(ALLOWED_DECORATORS.has(m) || validMarkKeys.has(m)),
);
const spanKey =
typeof ch._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(ch._key)
? ch._key
: generateKey();
validChildren.push({
_type: "span",
_key: spanKey,
text,
marks,
});
}

if (validChildren.length === 0) {
validChildren.push({
_type: "span",
_key: generateKey(),
text: "",
marks: [],
});
}

// Skip completely empty trailing normal blocks unless it's the only block
const hasNonEmptyText = validChildren.some((s) => s.text.trim().length > 0);
if (
!hasNonEmptyText &&
sanitized.length > 0 &&
style === "normal" &&
!listItem
) {
continue;
}

const blockKey =
typeof b._key === "string" && /^[a-zA-Z0-9_-]{4,32}$/.test(b._key)
? b._key
: generateKey();

const cleanBlock: PortableTextBlock = {
_type: "block",
_key: blockKey,
style,
markDefs: validMarkDefs,
children: validChildren,
};

if (listItem) {
cleanBlock.listItem = listItem;
cleanBlock.level =
typeof b.level === "number" && b.level >= 1 ? b.level : 1;
}

sanitized.push(cleanBlock);
}

return sanitized;
}
17 changes: 17 additions & 0 deletions apps/site/src/pages/api/guest-portal/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
import {
ALLOWED_SOCIAL_KEYS,
normalizeSocialInput,
sanitizePortableTextBlocks,
verifyGuestPortalToken,
} from "@/lib/guest-portal";
import { getSanityServerClient } from "@/lib/sanity/write-client";
Expand All @@ -12,6 +13,8 @@ export const POST: APIRoute = async ({ request }) => {
token?: string;
title?: string;
websiteUrl?: string;
excerpt?: string;
content?: unknown[];
socials?: Record<string, string>;
};

Expand Down Expand Up @@ -112,6 +115,20 @@ export const POST: APIRoute = async ({ request }) => {
}
}

if (typeof body.excerpt === "string") {
const cleanExcerpt = body.excerpt.trim();
if (cleanExcerpt) {
patchSet.excerpt = cleanExcerpt;
}
}

if (Array.isArray(body.content)) {
const sanitizedBlocks = sanitizePortableTextBlocks(body.content);
if (sanitizedBlocks.length > 0) {
patchSet.content = sanitizedBlocks;
}
}

if (canWrite) {
await client.patch(existingGuest._id).set(patchSet).commit();
}
Expand Down
Loading
Loading