Skip to content

Commit ff96422

Browse files
committed
feat: add structured OSM tag display, image proxy, and review link enrichment
Add PlaceTagDetails component to the overview tab displaying decoded OSM tags with proper icons, multilingual support, and social media links. Add image proxy for privacy-preserving photo loading and extend review/photo pipelines with new data sources. Place details (overview tab): - Add PlaceTagDetails component rendering 30+ OSM tag types with dedicated icons - Support operator, brand, email, wheelchair, access (13 values), fee/charge, cuisine, religion, denomination, heritage, stars, capacity, elevation, surface, height, building:levels, start_date, min_age - Support facilities: indoor/outdoor, level, locked, smoking (5 values), dog (3 values), drive-through, delivery, takeaway, outdoor seating, internet access, drinking water, toilets with wheelchair status, changing table, shower, manufacturer/model - Handle multilingual tags (description, note, *:location and language variants) with emoji country flags - Detect and linkify URLs in description/note text - Make operator and brand clickable via operator:wikipedia / operator:wikidata / brand:wikipedia / brand:wikidata - Add social media icon row (16 platforms) using MUI icons + react-icons/si (Simple Icons, CC0) - Exclude all overview-consumed tags from the Info tab (both named groups and catch-all) - Add 54 i18n keys (en + de) for all decoded tag values Review links: - Use contact:tripadvisor OSM tag for direct TripAdvisor links instead of search fallback - Add contact:yelp and contact:foursquare as alternative tag keys - Change osmTagKey to osmTagKeys array for multi-key lookup per platform - Consume review-platform contact tags so they don't appear in Info tab Photos: - Extract photos from OSM image tags (image, image:0, image:1, …) - Support direct URLs and Wikimedia Commons File: references - Resolve Google Photos share links (photos.app.goo.gl) to og:image preview with link to full album - Follow short-URL redirect manually to bypass DurableDeepLink wrapper page - Add image proxy endpoint (GET /api/image-proxy) for privacy-preserving photo loading - Domain allowlist (Wikimedia, Mapillary/fbcdn, Flickr, Panoramax, Google), referrer guard, 15MB limit - Route all frontend photo loading through proxy (PlacePhotoHero, PlacePhotoGallery) - Add proxyImageUrl() utility in packages/core - Add Cross-Origin-Resource-Policy header to all proxy/tile routes (image-proxy, mapillary, tiles, traffic, satellite, hiking, weather, winter-sports) Nominatim enrichment: - Fall back to contact:website when website tag is absent - Fall back to contact:phone when phone tag is absent
1 parent 3a91287 commit ff96422

25 files changed

Lines changed: 1748 additions & 40 deletions

File tree

apps/api/src/routes/image-proxy.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { FastifyPluginAsync } from "fastify";
2+
import { resolveGooglePhotosLink } from "../services/photos/index.js";
3+
4+
/**
5+
* Allowed upstream hostname patterns for the image proxy.
6+
* Prevents abuse by only allowing known photo-source domains.
7+
*/
8+
const ALLOWED_HOSTS = [
9+
// Wikimedia Commons
10+
"upload.wikimedia.org",
11+
"commons.wikimedia.org",
12+
// Mapillary (CDN uses regional subdomains like scontent-fra5-2.xx.fbcdn.net)
13+
"images.mapillary.com",
14+
"fbcdn.net",
15+
// Flickr
16+
"live.staticflickr.com",
17+
// Panoramax
18+
"api.panoramax.xyz",
19+
"panoramax.openstreetmap.fr",
20+
// Google (resolved Google Photos)
21+
"lh3.googleusercontent.com",
22+
"lh4.googleusercontent.com",
23+
"lh5.googleusercontent.com",
24+
"lh6.googleusercontent.com",
25+
// Google Photos (share links resolved on the fly)
26+
"photos.app.goo.gl",
27+
"photos.google.com",
28+
// OpenStreetMap / other
29+
"tile.openstreetmap.org",
30+
];
31+
32+
function isAllowedHost(hostname: string): boolean {
33+
return ALLOWED_HOSTS.some((h) => hostname === h || hostname.endsWith(`.${h}`));
34+
}
35+
36+
/** Allowed frontend origins that may use the proxy. */
37+
function getAllowedOrigins(): string[] {
38+
return (process.env.CORS_ORIGIN ?? "http://localhost:3000").split(",").map((o) => o.trim());
39+
}
40+
41+
const MAX_SIZE = 15 * 1024 * 1024; // 15 MB
42+
43+
export const imageProxyRoute: FastifyPluginAsync = async (fastify) => {
44+
fastify.get<{ Querystring: { url: string } }>("/image-proxy", {
45+
schema: {
46+
querystring: {
47+
type: "object",
48+
required: ["url"],
49+
properties: {
50+
url: { type: "string", minLength: 10 },
51+
},
52+
},
53+
},
54+
handler: async (req, reply) => {
55+
// Referrer guard: only allow requests originating from our frontend.
56+
// Browsers send Referer on <img> loads; third-party sites get blocked.
57+
const referer = req.headers.referer ?? req.headers.origin;
58+
if (referer) {
59+
const origins = getAllowedOrigins();
60+
const allowed = origins.some((o) => referer.startsWith(o));
61+
if (!allowed) {
62+
return reply.status(403).send({ message: "Forbidden" });
63+
}
64+
}
65+
66+
const { url } = req.query;
67+
68+
let parsed: URL;
69+
try {
70+
parsed = new URL(url);
71+
} catch {
72+
return reply.status(400).send({ message: "Invalid URL" });
73+
}
74+
75+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
76+
return reply.status(400).send({ message: "Only HTTP(S) URLs allowed" });
77+
}
78+
79+
if (!isAllowedHost(parsed.hostname)) {
80+
return reply.status(403).send({ message: "Domain not allowed" });
81+
}
82+
83+
// Google Photos share links are not direct images — resolve to actual image URL
84+
let imageUrl = url;
85+
if (parsed.hostname === "photos.app.goo.gl" || parsed.hostname === "photos.google.com") {
86+
const resolved = await resolveGooglePhotosLink(url);
87+
if (resolved.length === 0) {
88+
return reply.status(404).send({ message: "Could not resolve Google Photos link" });
89+
}
90+
imageUrl = resolved[0];
91+
}
92+
93+
try {
94+
const upstream = await fetch(imageUrl, {
95+
signal: AbortSignal.timeout(10_000),
96+
headers: { "User-Agent": "OpenMapX/1.0" },
97+
redirect: "follow",
98+
});
99+
100+
if (!upstream.ok) {
101+
return reply.status(upstream.status).send({ message: "Upstream error" });
102+
}
103+
104+
const contentType = upstream.headers.get("content-type") ?? "application/octet-stream";
105+
if (!contentType.startsWith("image/")) {
106+
return reply.status(415).send({ message: "Not an image" });
107+
}
108+
109+
const contentLength = upstream.headers.get("content-length");
110+
if (contentLength && parseInt(contentLength, 10) > MAX_SIZE) {
111+
return reply.status(413).send({ message: "Image too large" });
112+
}
113+
114+
const bytes = await upstream.arrayBuffer();
115+
const origins = getAllowedOrigins();
116+
reply.header("Cache-Control", "public, max-age=86400, s-maxage=86400");
117+
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
118+
reply.header("Access-Control-Allow-Origin", origins[0]);
119+
reply.type(contentType);
120+
return reply.send(Buffer.from(bytes));
121+
} catch (err) {
122+
req.log.warn({ url, err }, "Image proxy fetch failed");
123+
return reply.status(502).send({ message: "Failed to fetch image" });
124+
}
125+
},
126+
});
127+
};

apps/api/src/routes/mapillary.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const mapillaryRoute: FastifyPluginAsync = async (fastify) => {
3535
const contentType =
3636
upstream.headers.get("content-type") ?? "application/vnd.mapbox-vector-tile";
3737
reply.header("Cache-Control", "public, max-age=86400, s-maxage=86400");
38+
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
3839
reply.type(contentType);
3940
return reply.send(Buffer.from(bytes));
4041
},

apps/api/src/routes/tiles.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export const tilesRoute: FastifyPluginAsync = async (fastify) => {
6767

6868
const tfBuffer = Buffer.from(await tfResponse.arrayBuffer());
6969
reply.header("Cache-Control", "public, max-age=604800, s-maxage=604800");
70+
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
7071
reply.header("X-Tile-Source", "thunderforest");
7172
reply.type("image/png");
7273
return reply.send(tfBuffer);
@@ -98,6 +99,7 @@ export const tilesRoute: FastifyPluginAsync = async (fastify) => {
9899

99100
const buffer = Buffer.from(await response.arrayBuffer());
100101
reply.header("Cache-Control", "public, max-age=604800, s-maxage=604800");
102+
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
101103
reply.header("X-Tile-Source", "cyclosm");
102104
reply.type("image/png");
103105
return reply.send(buffer);
@@ -143,6 +145,7 @@ export const tilesRoute: FastifyPluginAsync = async (fastify) => {
143145

144146
const buffer = Buffer.from(await response.arrayBuffer());
145147
reply.header("Cache-Control", "public, max-age=604800, s-maxage=604800");
148+
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
146149
reply.type("image/png");
147150
return reply.send(buffer);
148151
} catch (error) {

apps/api/src/routes/traffic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const trafficRoute: FastifyPluginAsync = async (fastify) => {
3030
const tile = await getTrafficProvider().getFlowTile(z, x, y);
3131

3232
reply.header("Cache-Control", tile.cacheControl ?? "public, max-age=30, s-maxage=30");
33+
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
3334
reply.type(tile.contentType);
3435
return reply.send(Buffer.from(tile.bytes));
3536
} catch (error) {

apps/api/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { directionsRoute } from "./routes/directions";
2626
import { elevationRoute } from "./routes/elevation";
2727
import { geocodeRoute } from "./routes/geocode";
2828
import { gtfsRoute } from "./routes/gtfs";
29+
import { imageProxyRoute } from "./routes/image-proxy";
2930
import { isochroneRoute } from "./routes/isochrone";
3031
import { mapillaryRoute } from "./routes/mapillary";
3132
import { motisRoute } from "./routes/motis";
@@ -135,6 +136,7 @@ await server.register(isochroneRoute, { prefix: "/api" });
135136
await server.register(motisRoute, { prefix: "/api" });
136137
await server.register(dataSourcesRoute, { prefix: "/api" });
137138
await server.register(photosRoute, { prefix: "/api" });
139+
await server.register(imageProxyRoute, { prefix: "/api" });
138140
await server.register(weatherRoute, { prefix: "/api" });
139141
await server.register(winterSportsRoute, { prefix: "/api" });
140142
await server.register(risMapsRoute, { prefix: "/api" });

apps/api/src/services/nominatim-lookup.service.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@ function buildAddress(addr: NominatimAddress): string {
5252
}
5353

5454
function toPlace(r: NominatimDetailResult, id: string): Place {
55-
const { opening_hours, phone, website, ...rest } = r.extratags ?? {};
55+
const {
56+
opening_hours,
57+
phone,
58+
website,
59+
"contact:phone": contactPhone,
60+
"contact:website": contactWebsite,
61+
...rest
62+
} = r.extratags ?? {};
5663

5764
const osmTags: Record<string, string> = {};
5865
for (const [k, v] of Object.entries(rest)) {
@@ -71,8 +78,8 @@ function toPlace(r: NominatimDetailResult, id: string): Place {
7178
countryCode: r.address.country_code ?? undefined,
7279
coordinates: [Number.parseFloat(r.lon), Number.parseFloat(r.lat)],
7380
category: resolveOsmLabel(r.class, r.type),
74-
phone,
75-
website,
81+
phone: phone ?? contactPhone,
82+
website: website ?? contactWebsite,
7683
openingHours: opening_hours,
7784
osmTags: Object.keys(osmTags).length > 0 ? osmTags : undefined,
7885
};

0 commit comments

Comments
 (0)