diff --git a/src/components/ProjectsNew.tsx b/src/components/ProjectsNew.tsx
index 6aef0726..f6dc24df 100644
--- a/src/components/ProjectsNew.tsx
+++ b/src/components/ProjectsNew.tsx
@@ -30,6 +30,7 @@ import EmailIcon from "@mui/icons-material/Email";
import LanguageIcon from "@mui/icons-material/Language";
import VolunteerActivismIcon from "@mui/icons-material/VolunteerActivism";
import GroupsIcon from "@mui/icons-material/Groups";
+import { sanitizeUrl, sanitizeEmail } from "./projects/projectData";
interface Tag {
id: number;
@@ -78,25 +79,6 @@ interface ProjectsNewProps {
availableTags?: Tag[];
}
-/** Only allow http: and https: URLs to prevent javascript: / data: XSS vectors. Returns empty string for unsafe/invalid URLs. */
-const sanitizeUrl = (url: string | undefined): string => {
- if (!url) return "";
- try {
- const parsed = new URL(url, "https://placeholder.invalid");
- if (parsed.protocol === "http:" || parsed.protocol === "https:") {
- return url;
- }
- } catch {
- // malformed URL
- }
- return "";
-};
-
-const sanitizeEmail = (email: string | undefined): string => {
- if (!email) return "";
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : "";
-};
-
const getInitials = (name: string): string => {
const words = name.trim().split(/\s+/);
if (words.length >= 2) {
@@ -205,7 +187,11 @@ const SOCIAL_FIELDS: SocialField[] = [
{ key: "blueskyUrl", label: "Bluesky", icon: },
{ key: "tiktokUrl", label: "TikTok", icon: },
{ key: "signalUrl", label: "Signal", icon: },
- { key: "upscrolledUrl", label: "Upscrolled", icon: },
+ {
+ key: "upscrolledUrl",
+ label: "Upscrolled",
+ icon: ,
+ },
{ key: "discordUsername", label: "Discord", icon: , isDiscord: true },
{ key: "publicEmail", label: "Email", icon: , isEmail: true },
];
@@ -269,7 +255,9 @@ export default function ProjectsNew({
const data = await response.json();
const projects = data.projects ?? data;
const tags = data.tags ?? [];
- console.log(`[ProjectsNew] Successfully fetched ${projects.length} projects, ${tags.length} tags`);
+ console.log(
+ `[ProjectsNew] Successfully fetched ${projects.length} projects, ${tags.length} tags`
+ );
setProjects(projects);
setAvailableTags(tags);
} else {
@@ -326,8 +314,7 @@ export default function ProjectsNew({
getProjectText(project).toLowerCase().includes(searchQuery.toLowerCase());
const matchesTags =
- activeTagIds.size === 0 ||
- project.tags?.some((t) => activeTagIds.has(t.id));
+ activeTagIds.size === 0 || project.tags?.some((t) => activeTagIds.has(t.id));
return matchesSearch && matchesTags;
});
@@ -362,7 +349,9 @@ export default function ProjectsNew({
isOptionEqualToValue={(option, value) => option.id === value.id}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
- const { bgcolor, color } = getTagColor(availableTags.findIndex((t) => t.id === option.id));
+ const { bgcolor, color } = getTagColor(
+ availableTags.findIndex((t) => t.id === option.id)
+ );
const { key, ...tagProps } = getTagProps({ index });
return (
{
- const { bgcolor, color } = getTagColor(availableTags.findIndex((t) => t.id === option.id));
- const { key, ...optionProps } = props as { key: React.Key } & React.HTMLAttributes;
+ const { bgcolor, color } = getTagColor(
+ availableTags.findIndex((t) => t.id === option.id)
+ );
+ const { key, ...optionProps } = props as {
+ key: React.Key;
+ } & React.HTMLAttributes;
return (
setFailedImages((prev) => new Set(prev).add(project.id))}
/>
)}
-
+
{project.name}
{project.leadName && (
-
+
Led by {project.leadName}
)}
-
+
{getProjectText(project)}
{sanitizeUrl(project.websiteUrl) && (
@@ -597,7 +605,12 @@ export default function ProjectsNew({
{project.featured && isFiltering && (
-
+
)}
@@ -608,11 +621,18 @@ export default function ProjectsNew({
)}
{project.tags?.slice(0, 3).map((tag) => {
- const { bgcolor, color } = getTagColor(availableTags.findIndex((t) => t.id === tag.id));
+ const { bgcolor, color } = getTagColor(
+ availableTags.findIndex((t) => t.id === tag.id)
+ );
return (
{/* Footer row */}
-
+
{visibleSocials.map((field) => (
))}
{overflowCount > 0 && (
-
+
+{overflowCount}
)}
@@ -693,313 +723,342 @@ export default function ProjectsNew({
{/* Project Details Dialog */}
-
- {selectedProject.tags.map((tag) => {
- const { bgcolor, color } = getTagColor(availableTags.findIndex((t) => t.id === tag.id));
- return (
-
+ Our Impact
+
+
+ {selectedProject.impactStatement}
+
+
+ )}
+
+ {/* About the leader */}
+ {selectedProject.leaderBio && (
+
+
+ About the leader
+
+
+ {selectedProject.leaderPhoto && !dialogLeaderPhotoFailed && (
+ setDialogLeaderPhotoFailed(true)}
/>
- );
- })}
+ )}
+
+ {selectedProject.leaderBio}
+
+
-
- )}
-
+ )}
-
-
- Last updated {updatedDate}
-
-
-
- >
- );
- })()}
+
+ Last updated {updatedDate}
+
+
+ Close
+
+
+ >
+ );
+ })()}
);
diff --git a/src/pages/api/donation-complete.ts b/src/pages/api/donation-complete.ts
index 7f5b54a2..6768695f 100644
--- a/src/pages/api/donation-complete.ts
+++ b/src/pages/api/donation-complete.ts
@@ -2,41 +2,34 @@ import type { APIRoute } from "astro";
import * as Sentry from "@sentry/astro";
import { reportError } from "../../lib/report-error";
import { getEnv } from "../../utils/getEnv";
+import {
+ isAllowedOrigin,
+ corsHeaders,
+ ALLOWED_ORIGIN,
+ type OriginPolicy,
+} from "../../utils/origin";
export const prerender = false;
-const ALLOWED_ORIGINS = [
- "https://techforpalestine.org",
- ...(import.meta.env.PROD ? [] : ["http://localhost:4321"]),
-];
-
-function isAllowedOrigin(origin: string | null): origin is string {
- if (!origin) return false;
- if (ALLOWED_ORIGINS.includes(origin)) return true;
- return /\.website-aun\.pages\.dev$/.test(new URL(origin).hostname);
-}
+const ORIGIN_POLICY: OriginPolicy = {
+ allowedOrigins: [ALLOWED_ORIGIN, ...(import.meta.env.PROD ? [] : ["http://localhost:4321"])],
+ allowedSuffixes: [".website-aun.pages.dev"],
+};
const EO_MEMBERS_LIST_URL =
"https://emailoctopus.com/api/1.6/lists/8adc2ed4-f798-11ef-b60f-115427c25a1c/contacts";
const MAX_NAME_LENGTH = 200;
-function corsHeaders(origin: string) {
- return {
- "Access-Control-Allow-Origin": origin,
- "Access-Control-Allow-Methods": "POST, OPTIONS",
- "Access-Control-Allow-Headers": "Content-Type",
- } as const;
-}
-
export const POST: APIRoute = async ({ request, locals }) => {
const origin = request.headers.get("Origin");
- if (!isAllowedOrigin(origin)) {
+ if (!isAllowedOrigin(origin, ORIGIN_POLICY)) {
return new Response(JSON.stringify({ message: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
- const ctx = (locals as { runtime?: { ctx?: { waitUntil: (p: Promise) => void } } }).runtime?.ctx;
+ const ctx = (locals as { runtime?: { ctx?: { waitUntil: (p: Promise) => void } } })
+ .runtime?.ctx;
let body: Record;
try {
@@ -105,7 +98,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
export const OPTIONS: APIRoute = async ({ request }) => {
const origin = request.headers.get("Origin");
- if (!isAllowedOrigin(origin)) {
+ if (!isAllowedOrigin(origin, ORIGIN_POLICY)) {
return new Response(null, { status: 403 });
}
return new Response(null, { status: 200, headers: corsHeaders(origin) });
diff --git a/src/pages/api/e4p-pledge-sign.ts b/src/pages/api/e4p-pledge-sign.ts
index d8244495..268ce6c6 100644
--- a/src/pages/api/e4p-pledge-sign.ts
+++ b/src/pages/api/e4p-pledge-sign.ts
@@ -3,13 +3,14 @@ import * as Sentry from "@sentry/astro";
import { Client } from "@notionhq/client";
import { getEnv } from "../../utils/getEnv.js";
import { reportError } from "../../lib/report-error";
+import { isAllowedOrigin, corsHeaders } from "../../utils/origin";
export const prerender = false;
export const POST: APIRoute = async ({ request, locals }) => {
const ctx = locals.runtime?.ctx;
const origin = request.headers.get("Origin");
- if (origin !== "https://techforpalestine.org") {
+ if (!isAllowedOrigin(origin)) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
@@ -43,7 +44,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
status: 400,
headers: {
"Content-Type": "application/json",
- "Access-Control-Allow-Origin": "https://techforpalestine.org",
+ "Access-Control-Allow-Origin": origin,
},
}
);
@@ -54,14 +55,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (!emailRx.test(pledgeData.email)) {
return new Response(JSON.stringify({ error: "Invalid email address" }), {
status: 400,
- headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" },
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin },
});
}
- try { new URL(pledgeData.linkedin); } catch {
+ try {
+ new URL(pledgeData.linkedin);
+ } catch {
return new Response(JSON.stringify({ error: "Invalid LinkedIn URL" }), {
status: 400,
- headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" },
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin },
});
}
@@ -73,10 +76,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
];
for (const [field, value] of textFields) {
if (value.length > MAX) {
- return new Response(JSON.stringify({ error: `Field '${field}' exceeds maximum length of ${MAX} characters` }), {
- status: 400,
- headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" },
- });
+ return new Response(
+ JSON.stringify({ error: `Field '${field}' exceeds maximum length of ${MAX} characters` }),
+ {
+ status: 400,
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin },
+ }
+ );
}
}
@@ -156,12 +162,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
}),
{
status: 201,
- headers: {
- "Content-Type": "application/json",
- "Access-Control-Allow-Origin": "https://techforpalestine.org",
- "Access-Control-Allow-Methods": "POST",
- "Access-Control-Allow-Headers": "Content-Type",
- },
+ headers: { "Content-Type": "application/json", ...corsHeaders(origin, "POST") },
}
);
} catch (error) {
@@ -172,7 +173,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
status: 500,
headers: {
"Content-Type": "application/json",
- "Access-Control-Allow-Origin": "https://techforpalestine.org",
+ "Access-Control-Allow-Origin": origin,
},
});
}
diff --git a/src/pages/api/endorsement-request.ts b/src/pages/api/endorsement-request.ts
index 7da54768..4470c53a 100644
--- a/src/pages/api/endorsement-request.ts
+++ b/src/pages/api/endorsement-request.ts
@@ -3,13 +3,14 @@ import * as Sentry from "@sentry/astro";
import { Client } from "@notionhq/client";
import { getEnv } from "../../utils/getEnv.js";
import { reportError } from "../../lib/report-error";
+import { isAllowedOrigin, corsHeaders } from "../../utils/origin";
export const prerender = false;
export const POST: APIRoute = async ({ request, locals }) => {
const ctx = locals.runtime?.ctx;
const origin = request.headers.get("Origin");
- if (origin !== "https://techforpalestine.org") {
+ if (!isAllowedOrigin(origin)) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
@@ -48,7 +49,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
status: 400,
headers: {
"Content-Type": "application/json",
- "Access-Control-Allow-Origin": "https://techforpalestine.org",
+ "Access-Control-Allow-Origin": origin,
},
});
}
@@ -58,21 +59,25 @@ export const POST: APIRoute = async ({ request, locals }) => {
if (!emailRx.test(endorsementData.contactEmail)) {
return new Response(JSON.stringify({ error: "Invalid email address" }), {
status: 400,
- headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" },
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin },
});
}
- try { new URL(endorsementData.organizationWebsite); } catch {
+ try {
+ new URL(endorsementData.organizationWebsite);
+ } catch {
return new Response(JSON.stringify({ error: "Invalid organization website URL" }), {
status: 400,
- headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" },
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin },
});
}
- try { new URL(endorsementData.campaignLink); } catch {
+ try {
+ new URL(endorsementData.campaignLink);
+ } catch {
return new Response(JSON.stringify({ error: "Invalid campaign link URL" }), {
status: 400,
- headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" },
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin },
});
}
@@ -87,10 +92,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
];
for (const [field, value] of textFields) {
if (value.length > MAX) {
- return new Response(JSON.stringify({ error: `Field '${field}' exceeds maximum length of ${MAX} characters` }), {
- status: 400,
- headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "https://techforpalestine.org" },
- });
+ return new Response(
+ JSON.stringify({ error: `Field '${field}' exceeds maximum length of ${MAX} characters` }),
+ {
+ status: 400,
+ headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": origin },
+ }
+ );
}
}
@@ -191,12 +199,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
}),
{
status: 201,
- headers: {
- "Content-Type": "application/json",
- "Access-Control-Allow-Origin": "https://techforpalestine.org",
- "Access-Control-Allow-Methods": "POST",
- "Access-Control-Allow-Headers": "Content-Type",
- },
+ headers: { "Content-Type": "application/json", ...corsHeaders(origin, "POST") },
}
);
} catch (error) {
@@ -207,7 +210,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
status: 500,
headers: {
"Content-Type": "application/json",
- "Access-Control-Allow-Origin": "https://techforpalestine.org",
+ "Access-Control-Allow-Origin": origin,
},
});
}
diff --git a/src/pages/api/membership-complete.ts b/src/pages/api/membership-complete.ts
index b55cefa1..b5b7e9c3 100644
--- a/src/pages/api/membership-complete.ts
+++ b/src/pages/api/membership-complete.ts
@@ -2,41 +2,34 @@ import type { APIRoute } from "astro";
import * as Sentry from "@sentry/astro";
import { reportError } from "../../lib/report-error";
import { getEnv } from "../../utils/getEnv";
+import {
+ isAllowedOrigin,
+ corsHeaders,
+ ALLOWED_ORIGIN,
+ type OriginPolicy,
+} from "../../utils/origin";
export const prerender = false;
-const ALLOWED_ORIGINS = [
- "https://techforpalestine.org",
- ...(import.meta.env.PROD ? [] : ["http://localhost:4321"]),
-];
-
-function isAllowedOrigin(origin: string | null): origin is string {
- if (!origin) return false;
- if (ALLOWED_ORIGINS.includes(origin)) return true;
- return /\.website-aun\.pages\.dev$/.test(new URL(origin).hostname);
-}
+const ORIGIN_POLICY: OriginPolicy = {
+ allowedOrigins: [ALLOWED_ORIGIN, ...(import.meta.env.PROD ? [] : ["http://localhost:4321"])],
+ allowedSuffixes: [".website-aun.pages.dev"],
+};
const EO_MEMBERS_LIST_URL =
"https://emailoctopus.com/api/1.6/lists/8adc2ed4-f798-11ef-b60f-115427c25a1c/contacts";
const MAX_NAME_LENGTH = 200;
-function corsHeaders(origin: string) {
- return {
- "Access-Control-Allow-Origin": origin,
- "Access-Control-Allow-Methods": "POST, OPTIONS",
- "Access-Control-Allow-Headers": "Content-Type",
- } as const;
-}
-
export const POST: APIRoute = async ({ request, locals }) => {
const origin = request.headers.get("Origin");
- if (!isAllowedOrigin(origin)) {
+ if (!isAllowedOrigin(origin, ORIGIN_POLICY)) {
return new Response(JSON.stringify({ message: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
- const ctx = (locals as { runtime?: { ctx?: { waitUntil: (p: Promise) => void } } }).runtime?.ctx;
+ const ctx = (locals as { runtime?: { ctx?: { waitUntil: (p: Promise) => void } } })
+ .runtime?.ctx;
let body: Record;
try {
@@ -84,7 +77,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
})
- .catch((err) => reportError(err, { context: "membership-complete hub", email: redacted }))
+ .catch((err) =>
+ reportError(err, { context: "membership-complete hub", email: redacted })
+ )
: Promise.resolve(),
eoApiKey
@@ -110,7 +105,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
})
- .catch((err) => reportError(err, { context: "membership-complete eo", email: redacted }))
+ .catch((err) =>
+ reportError(err, { context: "membership-complete eo", email: redacted })
+ )
: Promise.resolve(),
]);
@@ -131,7 +128,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
export const OPTIONS: APIRoute = async ({ request }) => {
const origin = request.headers.get("Origin");
- if (!isAllowedOrigin(origin)) {
+ if (!isAllowedOrigin(origin, ORIGIN_POLICY)) {
return new Response(null, { status: 403 });
}
return new Response(null, { status: 200, headers: corsHeaders(origin) });
diff --git a/src/pages/api/pipe.ts b/src/pages/api/pipe.ts
index 46a65bc3..40cab17a 100644
--- a/src/pages/api/pipe.ts
+++ b/src/pages/api/pipe.ts
@@ -1,15 +1,14 @@
import type { APIRoute } from "astro";
import * as Sentry from "@sentry/astro";
import { reportError } from "../../lib/report-error";
+import { isAllowedOrigin, type OriginPolicy } from "../../utils/origin";
-const ALLOWED_ORIGIN = "https://techforpalestine.org";
-const PREVIEW_ORIGIN_SUFFIX = ".pages.dev";
const PLAUSIBLE_API = "https://plausible.io/api/event";
const CONVERSION_EVENTS = new Set(["Monthly-donate", "One-time-donate", "Membership-complete"]);
-
-function isAllowedOrigin(origin: string): boolean {
- return origin === ALLOWED_ORIGIN || origin.endsWith(PREVIEW_ORIGIN_SUFFIX);
-}
+const ORIGIN_POLICY: OriginPolicy = {
+ allowedSuffixes: [".pages.dev"],
+ allowMissingOrigin: true,
+};
function parseEventName(body: string): string {
try {
@@ -23,7 +22,7 @@ function parseEventName(body: string): string {
export const POST: APIRoute = async ({ request, locals }) => {
const ctx = locals.runtime?.ctx;
const origin = request.headers.get("origin");
- if (origin && !isAllowedOrigin(origin)) {
+ if (!isAllowedOrigin(origin, ORIGIN_POLICY)) {
return new Response("Forbidden", { status: 403 });
}
@@ -73,7 +72,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
const key = `dropped:${date}:${time}:${id}`;
let parsed: Record = {};
- try { parsed = JSON.parse(body); } catch {}
+ try {
+ parsed = JSON.parse(body);
+ } catch {}
const value = JSON.stringify({
eventName,
diff --git a/src/pages/api/projects.ts b/src/pages/api/projects.ts
index ab9ae042..2dff969e 100644
--- a/src/pages/api/projects.ts
+++ b/src/pages/api/projects.ts
@@ -2,29 +2,35 @@ import type { APIRoute } from "astro";
import * as Sentry from "@sentry/astro";
import { getEnv } from "../../utils/getEnv.js";
import { reportError } from "../../lib/report-error";
+import { sanitizeUrl } from "../../components/projects/projectData.js";
export const prerender = false;
const URL_FIELDS = [
- "websiteUrl", "logoUrl", "twitterUrl", "linkedinUrl", "githubUrl",
- "instagramUrl", "facebookUrl", "youtubeUrl", "telegramUrl",
- "mastodonUrl", "blueskyUrl", "tiktokUrl", "signalUrl", "upscrolledUrl",
- "leaderPhoto", "donationUrl", "involvementUrl",
+ "websiteUrl",
+ "logoUrl",
+ "twitterUrl",
+ "linkedinUrl",
+ "githubUrl",
+ "instagramUrl",
+ "facebookUrl",
+ "youtubeUrl",
+ "telegramUrl",
+ "mastodonUrl",
+ "blueskyUrl",
+ "tiktokUrl",
+ "signalUrl",
+ "upscrolledUrl",
+ "leaderPhoto",
+ "donationUrl",
+ "involvementUrl",
] as const;
-/** Strip any URL field that isn't http(s) to prevent XSS via javascript: or data: URIs. */
function sanitizeProjectUrls(project: Record): Record {
for (const field of URL_FIELDS) {
const val = project[field];
if (typeof val !== "string") continue;
- try {
- const parsed = new URL(val, "https://placeholder.invalid");
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
- project[field] = undefined;
- }
- } catch {
- project[field] = undefined;
- }
+ project[field] = sanitizeUrl(val) || undefined;
}
return project;
}
diff --git a/src/pages/api/sentry-webhook.ts b/src/pages/api/sentry-webhook.ts
index aeaa84c1..1f4e1e20 100644
--- a/src/pages/api/sentry-webhook.ts
+++ b/src/pages/api/sentry-webhook.ts
@@ -1,11 +1,8 @@
import type { APIRoute } from "astro";
import { getEnv } from "../../utils/getEnv";
+import { constantTimeEqual } from "../../utils/crypto";
-async function verifySignature(
- secret: string,
- body: string,
- signature: string
-): Promise {
+async function verifySignature(secret: string, body: string, signature: string): Promise {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
@@ -17,13 +14,7 @@ async function verifySignature(
const expected = Array.from(new Uint8Array(mac))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
- // constant-time comparison
- if (expected.length !== signature.length) return false;
- let diff = 0;
- for (let i = 0; i < expected.length; i++) {
- diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i);
- }
- return diff === 0;
+ return constantTimeEqual(expected, signature);
}
function formatMessage(body: Record): string {
@@ -88,7 +79,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
const mmChannelId = getEnv("MATTERMOST_CHANNEL_ID", locals);
if (!secret || !mmUrl || !mmToken || !mmChannelId) {
- console.error("Missing SENTRY_WEBHOOK_SECRET, MATTERMOST_URL, MATTERMOST_BOT_TOKEN, or MATTERMOST_CHANNEL_ID");
+ console.error(
+ "Missing SENTRY_WEBHOOK_SECRET, MATTERMOST_URL, MATTERMOST_BOT_TOKEN, or MATTERMOST_CHANNEL_ID"
+ );
return new Response(JSON.stringify({ error: "Not configured" }), { status: 500 });
}
diff --git a/src/store/notionClient.ts b/src/store/notionClient.ts
index f4bb2a6c..e5d4f756 100644
--- a/src/store/notionClient.ts
+++ b/src/store/notionClient.ts
@@ -13,6 +13,35 @@ function createNotionAxios(secret: string) {
});
}
+interface NotionFilesProperty {
+ files?: Array<
+ { type: "external"; external: { url: string } } | { type: "file"; file: { url: string } }
+ >;
+}
+
+interface NotionTitleProperty {
+ title?: Array<{ plain_text: string }>;
+}
+
+interface NotionRichTextProperty {
+ rich_text?: Array<{ plain_text: string }>;
+}
+
+function fileUrl(prop: NotionFilesProperty | undefined, fallback: string): string {
+ const file = prop?.files?.[0];
+ if (!file) return fallback;
+ if (file.type === "external") return file.external.url;
+ return file.file.url;
+}
+
+function titleText(prop: NotionTitleProperty | undefined, fallback = ""): string {
+ return prop?.title?.[0]?.plain_text || fallback;
+}
+
+function richText(prop: NotionRichTextProperty | undefined, fallback = ""): string {
+ return prop?.rich_text?.[0]?.plain_text || fallback;
+}
+
export const fetchNotionEvents = async (showAll: boolean = false, locals?: any) => {
const secret = getEnv("NOTION_SECRET", locals);
const dbId = getEnv("NOTION_DB_ID", locals);
@@ -38,28 +67,18 @@ export const fetchNotionEvents = async (showAll: boolean = false, locals?: any)
const events = response.data.results.map((page: any) => {
const props = page.properties;
- let headerImage = "";
- if (props["Header"]?.files?.length > 0) {
- const file = props["Header"].files[0];
- if (file.type === "external") {
- headerImage = file.external.url;
- } else if (file.type === "file") {
- headerImage = file.file.url;
- }
- }
-
- const description = props["Description"]?.rich_text?.[0]?.plain_text || "";
-
+ const headerImage = fileUrl(props["Header"], "/images/default.jpg");
+ const description = richText(props["Description"]);
const registerLink = props["Link to registration"]?.url || "";
const recordingLink = props["Link to recording"]?.url || "";
return {
id: page.id,
- title: props["Title"]?.title?.[0]?.plain_text || "Untitled",
+ title: titleText(props["Title"], "Untitled"),
date: props["Date of event"]?.date?.start || "",
status: props["Stage"]?.select?.name || "",
location: props["Type of event"]?.multi_select?.[0]?.name || "",
- image: headerImage || "/images/default.jpg",
+ image: headerImage,
link: page.url,
description,
registerLink,
@@ -83,28 +102,18 @@ export const fetchNotionEventById = async (pageId: string, locals?: any) => {
const props = response.data.properties;
- // Image from "Header" files property
- let headerImage = "";
- if (props["Header"]?.files?.length > 0) {
- const file = props["Header"].files[0];
- if (file.type === "external") {
- headerImage = file.external.url;
- } else if (file.type === "file") {
- headerImage = file.file.url;
- }
- }
-
- const description = props["Description"]?.rich_text?.[0]?.plain_text || "";
+ const headerImage = fileUrl(props["Header"], "/images/default.jpg");
+ const description = richText(props["Description"]);
const registerLink = props["Link to registration"]?.url || "";
const recordingLink = props["Link to recording"]?.url || "";
return {
id: response.data.id,
- title: props["Title"]?.title?.[0]?.plain_text || "Untitled",
+ title: titleText(props["Title"], "Untitled"),
date: props["Date of event"]?.date?.start || "",
status: props["Stage"]?.select?.name || "",
location: props["Type of event"]?.multi_select?.[0]?.name || "",
- image: headerImage || "/images/default.jpg",
+ image: headerImage,
link: response.data.url,
description,
registerLink,
@@ -139,7 +148,7 @@ export const fetchNotionFAQ = async (showAll: boolean = false, locals?: any) =>
const faqs = response.data.results.map((page: any) => {
const props = page.properties;
- const question = props["Question"]?.title?.[0]?.plain_text || "";
+ const question = titleText(props["Question"]);
const answer = props["Answer"]?.rich_text || [];
const position = props["Position"]?.number ?? 999999; // Default to high number if no position
@@ -180,7 +189,7 @@ export const fetchNotionIdeas = async (locals?: any) => {
const ideas = response.data.results.map((page: any) => {
const props = page.properties;
- const name = props["Name"]?.title?.[0]?.plain_text || "";
+ const name = titleText(props["Name"]);
const category = props["Category"]?.select?.name || "";
const description = props["Description"]?.rich_text || [];
@@ -222,23 +231,14 @@ export const fetchNotionAgenda = async (locals?: any) => {
const speakerResponse = await notionAxios.get(`pages/${modId}`);
const props = speakerResponse.data.properties;
- const name = props["Name"]?.title?.[0]?.plain_text || "";
- const title = props["Title"]?.rich_text?.[0]?.plain_text || "";
+ const name = titleText(props["Name"]);
+ const title = richText(props["Title"]);
// Concatenate all rich_text blocks for bio
const bioArray = props["Speaker bio"]?.rich_text || [];
const bio = bioArray.map((block: any) => block.plain_text).join("");
- // Get photo from files property
- let photo = "";
- if (props["Photo"]?.files?.length > 0) {
- const file = props["Photo"].files[0];
- if (file.type === "external") {
- photo = file.external.url;
- } else if (file.type === "file") {
- photo = file.file.url;
- }
- }
+ const photo = fileUrl(props["Photo"], "/images/default.jpg");
return {
id: modId,
@@ -247,7 +247,7 @@ export const fetchNotionAgenda = async (locals?: any) => {
name,
title,
bio,
- photo: photo || "/images/default.jpg",
+ photo,
},
};
} catch (error) {
@@ -267,9 +267,9 @@ export const fetchNotionAgenda = async (locals?: any) => {
const agendaItems = response.data.results.map((page: any) => {
const props = page.properties;
- const title = props["Title"]?.title?.[0]?.plain_text || "";
- const description = props["Description"]?.rich_text?.[0]?.plain_text || "";
- const time = props["Time"]?.rich_text?.[0]?.plain_text || "";
+ const title = titleText(props["Title"]);
+ const description = richText(props["Description"]);
+ const time = richText(props["Time"]);
const moderatorRelations = props["Moderator"]?.relation || [];
const moderator =
diff --git a/src/utils/origin.ts b/src/utils/origin.ts
new file mode 100644
index 00000000..85a736b0
--- /dev/null
+++ b/src/utils/origin.ts
@@ -0,0 +1,33 @@
+export const ALLOWED_ORIGIN = "https://techforpalestine.org";
+
+export interface OriginPolicy {
+ readonly allowedOrigins?: readonly string[];
+ readonly allowedSuffixes?: readonly string[];
+ readonly allowMissingOrigin?: boolean;
+}
+
+export function isAllowedOrigin(
+ origin: string | null,
+ policy: OriginPolicy = {}
+): origin is string {
+ if (!origin) return policy.allowMissingOrigin ?? false;
+
+ const allowedOrigins = policy.allowedOrigins ?? [ALLOWED_ORIGIN];
+ if (allowedOrigins.includes(origin)) return true;
+
+ if (!policy.allowedSuffixes?.length) return false;
+ try {
+ const hostname = new URL(origin).hostname;
+ return policy.allowedSuffixes.some((suffix) => hostname.endsWith(suffix));
+ } catch {
+ return false;
+ }
+}
+
+export function corsHeaders(origin: string, methods = "POST, OPTIONS") {
+ return {
+ "Access-Control-Allow-Origin": origin,
+ "Access-Control-Allow-Methods": methods,
+ "Access-Control-Allow-Headers": "Content-Type",
+ } as const;
+}