Skip to content
Draft
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
5 changes: 5 additions & 0 deletions src/components/layout/Header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ $: mapsDropdownLinks = [
url: "/communities/map",
icon: "communities" as MobileNavIconName,
},
{
title: $_("nav.eventMap"),
url: "/events/map",
icon: "map" as MobileNavIconName,
},
] satisfies DropdownLink[];

$: statsDropdownLinks = [
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"menu": "Menu",
"merchantMap": "Merchant Map",
"communityMap": "Community Map",
"eventMap": "Event Map",
"dashboard": "Dashboard",
"taggerLeaderboard": "Tagger Leaderboard",
"communityLeaderboard": "Community Leaderboard",
Expand Down
10 changes: 10 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,16 @@ export type Place = {
"osm:note"?: string;
};

export type EventMapEvent = {
id: number;
lat: number;
lon: number;
name: string;
website: string;
starts_at: string;
ends_at: string | null;
};

// Worker progress tracking
export interface ProgressUpdate {
percent: number;
Expand Down
2 changes: 1 addition & 1 deletion src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export let data;
{#if $isLoading}
<LoadingIndicator visible={$isLoading} status="Loading..." />
{:else}
{#if !['/', '/map', '/communities/map', '/communities', '/countries'].includes(data.pathname)}
{#if !['/', '/map', '/communities/map', '/events/map', '/communities', '/countries'].includes(data.pathname)}
<div class="bg-teal dark:bg-dark">
<Header />
<main class="mx-auto w-10/12 xl:w-[1200px]">
Expand Down
189 changes: 189 additions & 0 deletions src/routes/events/map/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<script lang="ts">
import axios from "axios";
import type { Map, Marker } from "leaflet";
import { onDestroy, onMount } from "svelte";

import MapLoadingMain from "$components/MapLoadingMain.svelte";
import { loadMapDependencies } from "$lib/map/imports";
import {
attribution,
changeDefaultIcons,
generateIcon,
geolocate,
homeMarkerButtons,
layers,
scaleBars,
support,
updateMapHash,
} from "$lib/map/setup";
import { theme } from "$lib/theme";
import type { EventMapEvent, Leaflet, Theme } from "$lib/types";
import { errToast } from "$lib/utils";

import { browser } from "$app/environment";

let mapLoading = 0;

let leaflet: Leaflet;
let DomEvent: typeof import("leaflet/src/dom/DomEvent");
let currentMapTheme: Theme;

let mapElement: HTMLDivElement;
let map: Map;
let mapLoaded = false;
let eventsLoaded = false;
let markers: Marker[] = [];
let events: EventMapEvent[] = [];

const initializeEvents = () => {
if (eventsLoaded) return;

events.forEach((event: EventMapEvent) => {
const popupContainer = leaflet.DomUtil.create("div");

popupContainer.innerHTML = `
<div class='text-center space-y-2'>
<span class='text-primary dark:text-white font-semibold text-xl' title='Event name'>${event.name}</span>

${
event.website
? `<a href="${event.website}" target="_blank" rel="noopener noreferrer" class='block mt-4 bg-link hover:bg-hover !text-white text-center font-semibold py-3 rounded-xl transition-colors' title='Event website'>Visit Website</a>`
: ""
}
</div>

${
currentMapTheme === "dark"
? `
<style>
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
background-color: #06171C;
border: 1px solid #e5e7eb
}

.leaflet-popup-close-button {
font-size: 24px !important;
top: 4px !important;
right: 4px !important;
}
</style>`
: ""
}`;
Comment on lines +44 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Prevent DOM XSS in popup rendering.

Untrusted API fields (event.name, event.website) are interpolated into innerHTML. That enables script/attribute injection (including unsafe javascript: URLs).

🔐 Suggested fix
+const safeExternalUrl = (value: string): string | null => {
+	try {
+		const parsed = new URL(value);
+		return parsed.protocol === "https:" || parsed.protocol === "http:"
+			? parsed.toString()
+			: null;
+	} catch {
+		return null;
+	}
+};

 events.forEach((event: EventMapEvent) => {
-	const popupContainer = leaflet.DomUtil.create("div");
-	popupContainer.innerHTML = `
-		<div class='text-center space-y-2'>
-			<span class='text-primary dark:text-white font-semibold text-xl' title='Event name'>${event.name}</span>
-			${event.website ? `<a href="${event.website}" ...>Visit Website</a>` : ""}
-		</div>
-		...`;
+	const popupContainer = leaflet.DomUtil.create("div", "text-center space-y-2");
+	const title = leaflet.DomUtil.create(
+		"span",
+		"text-primary dark:text-white font-semibold text-xl",
+		popupContainer,
+	);
+	title.title = "Event name";
+	title.textContent = event.name;
+
+	const website = event.website ? safeExternalUrl(event.website) : null;
+	if (website) {
+		const link = leaflet.DomUtil.create(
+			"a",
+			"block mt-4 bg-link hover:bg-hover !text-white text-center font-semibold py-3 rounded-xl transition-colors",
+			popupContainer,
+		) as HTMLAnchorElement;
+		link.href = website;
+		link.target = "_blank";
+		link.rel = "noopener noreferrer";
+		link.title = "Event website";
+		link.textContent = "Visit Website";
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/events/map/`+page.svelte around lines 44 - 71, The popup uses
innerHTML with unescaped interpolations (popupContainer.innerHTML) of untrusted
fields event.name and event.website, enabling DOM XSS; to fix, stop injecting
raw HTML: build the popup DOM using safe methods (createElement, textContent,
setAttribute) or sanitize/validate inputs before inserting, ensure links use
safe hrefs (reject or strip javascript: schemes) when creating the anchor for
event.website, and keep theme-related style insertion controlled (use a CSS
class toggle using currentMapTheme instead of injecting <style> with string
interpolation). Reference popupContainer, event.name, event.website and
currentMapTheme to locate and replace the innerHTML assignment with DOM-safe
construction.


try {
const calendarIcon = generateIcon(leaflet, "event", false, 0);

const marker = leaflet
.marker([event.lat, event.lon], { icon: calendarIcon })
.bindPopup(popupContainer, { minWidth: 250 });

marker.addTo(map);
markers.push(marker);
} catch (error) {
console.error(error, event);
}
});

mapLoading = 100;

eventsLoaded = true;
};

$: events?.length && mapLoaded && !eventsLoaded && initializeEvents();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Loading can get stuck at 40% when no events are returned.

The reactive guard requires events.length, so initializeEvents() never runs for empty/error results, and mapLoading never reaches completion.

✅ Suggested fix
-$: events?.length && mapLoaded && !eventsLoaded && initializeEvents();
+$: if (mapLoaded && !eventsLoaded) {
+	initializeEvents();
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$: events?.length && mapLoaded && !eventsLoaded && initializeEvents();
$: if (mapLoaded && !eventsLoaded) {
initializeEvents();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/events/map/`+page.svelte at line 92, The reactive guard currently
checks events?.length which prevents initializeEvents() from running when events
is an empty array or error result; update the reactive statement so it triggers
when events is defined (e.g., events != null or typeof events !== "undefined")
along with mapLoaded && !eventsLoaded, so initializeEvents() always runs even
for empty results and allows mapLoading to finish; update the reactive line
referencing events, mapLoaded, eventsLoaded, and initializeEvents accordingly.


const loadEvents = async () => {
try {
const response = await axios.get<EventMapEvent[]>(
"https://api.btcmap.org/v4/events?include_past=true",
);
events = response.data;
} catch (error) {
errToast("Could not load events, please try again or contact BTC Map.");
console.error(error);
}
};

onMount(async () => {
await loadEvents();

if (browser) {
currentMapTheme = theme.current;

const deps = await loadMapDependencies();
leaflet = deps.leaflet;
DomEvent = deps.DomEvent;
const LocateControl = deps.LocateControl;

map = leaflet.map(mapElement);

if (location.hash) {
try {
const coords = location.hash.split("/");
map.setView(
[Number(coords[1]), Number(coords[2])],
Number(coords[0].slice(1)),
);
} catch (error) {
map.setView([0, 0], 3);
errToast(
"Could not set map view to provided coordinates, please try again or contact BTC Map.",
);
console.error(error);
}
} else {
map.setView([20, 0], 2);
}

const { baseMaps } = layers(leaflet, map);

map.on("moveend", () => {
const zoom = map.getZoom();
const mapCenter = map.getCenter();
updateMapHash(zoom, mapCenter);
});

support();

attribution(leaflet, map);

scaleBars(leaflet, map);

geolocate(leaflet, map, LocateControl);

homeMarkerButtons(leaflet, map, DomEvent);

leaflet.control.layers(baseMaps).addTo(map);

changeDefaultIcons(true, leaflet, mapElement, DomEvent);

mapLoading = 40;

mapLoaded = true;
}
});

onDestroy(async () => {
markers.forEach((marker) => marker.remove());
markers = [];

if (map) {
console.info("Unloading Leaflet map.");
map.remove();
}
});
</script>

<svelte:head>
<title>BTC Map - Event Map</title>
<meta property="og:image" content="https://btcmap.org/images/og/communities.png" />
<meta property="twitter:title" content="BTC Map - Event Map" />
<meta property="twitter:image" content="https://btcmap.org/images/og/communities.png" />
</svelte:head>

<div>
<h1 class="hidden">Event Map</h1>

<MapLoadingMain progress={mapLoading} />

<div bind:this={mapElement} class="absolute h-screen w-full !bg-teal dark:!bg-dark" />
</div>