Jordglob, spara plats och delade pins på stadsnivå
- Platsrutan: MapLibre GL 6.11 (självhostad, verifierad mot npm) med globprojektion, dag-/nattskugga i realtid med mjuk skymning, Natural Earth-konturer, röd nål, scroll- och knappzoom - "Spara som min plats" (bara i webbläsaren) i stället för automatisk sparning - "Dela min pin": öppet anonymt index i Cloudflare D1. Servern avrundar till 0,1°, slår själv upp stad/land (ingen fritext), en pin per ägare, borttagbar, spärr via saltad IP-hash som går ut - vendor/ markerad som -text i .gitattributes (filerna hålls byte-exakta) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
074d96566e
commit
aeb1c76428
+87
-1
@@ -1,7 +1,13 @@
|
||||
// Static assets + HTTP Range for /video/* (Safari/iOS will not play video without 206 responses)
|
||||
// + /api/pins: an open, anonymous index of shared pins at CITY level (no free text, name looked up by the server).
|
||||
export default {
|
||||
async fetch(req, env) {
|
||||
async fetch(req, env, ctx) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === '/api/pins') {
|
||||
try { return await pins(req, env, ctx, url); }
|
||||
catch (e) { return json({ error: 'Något gick fel', detail: String(e && e.message || e).slice(0, 120) }, 500); }
|
||||
}
|
||||
|
||||
const range = req.headers.get('Range');
|
||||
if (!url.pathname.startsWith('/video/') || !range) return env.ASSETS.fetch(req);
|
||||
|
||||
@@ -22,3 +28,83 @@ export default {
|
||||
return new Response(req.method === 'HEAD' ? null : buf.slice(start, end + 1), { status: 206, headers: h });
|
||||
},
|
||||
};
|
||||
|
||||
const json = (data, status = 200, extra = {}) =>
|
||||
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...extra } });
|
||||
|
||||
async function sha256(s) {
|
||||
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
||||
return [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
const randHex = n => [...crypto.getRandomValues(new Uint8Array(n))].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
const LIST_KEY = new Request('https://publiccloud.se/__pins-v1');
|
||||
|
||||
// City and country for a rounded position, from OpenStreetMap Nominatim (server side, cached in D1).
|
||||
// The visitor never sends a name, so nobody can put arbitrary text on the globe.
|
||||
async function cityAt(env, lat, lng) {
|
||||
const k = `${lat.toFixed(1)},${lng.toFixed(1)}`;
|
||||
const hit = await env.DB.prepare('SELECT city, country, cc FROM geocache WHERE k = ?').bind(k).first();
|
||||
if (hit) return hit.city && hit.country ? hit : null;
|
||||
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&zoom=10&accept-language=sv&lat=${lat}&lon=${lng}`,
|
||||
{ headers: { 'User-Agent': 'publiccloud.se shared-pins (https://publiccloud.se)', 'Referer': 'https://publiccloud.se/' } });
|
||||
if (!r.ok) throw new Error('geocode ' + r.status);
|
||||
const a = (await r.json()).address || {};
|
||||
const city = a.city || a.town || a.village || a.municipality || a.county || null;
|
||||
const out = { city: city ? String(city).slice(0, 80) : null, country: a.country ? String(a.country).slice(0, 80) : null,
|
||||
cc: a.country_code ? String(a.country_code).slice(0, 2).toUpperCase() : null };
|
||||
await env.DB.prepare('INSERT OR REPLACE INTO geocache (k, city, country, cc, created) VALUES (?, ?, ?, ?, ?)')
|
||||
.bind(k, out.city, out.country, out.cc, Date.now()).run();
|
||||
return out.city && out.country ? out : null;
|
||||
}
|
||||
|
||||
async function pins(req, env, ctx, url) {
|
||||
// ---- list: one entry per city with a count (last 365 days), cached briefly at the edge ----
|
||||
if (req.method === 'GET') {
|
||||
const hit = await caches.default.match(LIST_KEY); if (hit) return hit;
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT city, country, cc, ROUND(AVG(lat), 2) AS lat, ROUND(AVG(lng), 2) AS lng, COUNT(*) AS n
|
||||
FROM pins WHERE created > ? GROUP BY cc, city ORDER BY n DESC LIMIT 3000`).bind(Date.now() - 365 * 864e5).all();
|
||||
const res = json({ cities: results, total: results.reduce((s, r) => s + r.n, 0) }, 200, { 'Cache-Control': 'public, max-age=60' });
|
||||
ctx.waitUntil(caches.default.put(LIST_KEY, res.clone()));
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---- share: body { lat, lng, token? } -> rounded to 0.1°, city looked up here, one pin per owner ----
|
||||
if (req.method === 'POST') {
|
||||
const b = await req.json().catch(() => ({}));
|
||||
let lat = Number(b.lat), lng = Number(b.lng);
|
||||
if (!isFinite(lat) || !isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return json({ error: 'Ogiltig position' }, 400);
|
||||
lat = Math.round(lat * 10) / 10; lng = Math.round(lng * 10) / 10;
|
||||
|
||||
// rate limit: one share per network per 5 minutes (salted hash of the IP, expires; the IP itself is never stored)
|
||||
const ip = req.headers.get('CF-Connecting-IP') || 'x';
|
||||
const rk = await sha256((env.PIN_SALT || '') + '|' + ip), now = Date.now();
|
||||
const rl = await env.DB.prepare('SELECT until FROM ratelimit WHERE k = ?').bind(rk).first();
|
||||
if (rl && rl.until > now) return json({ error: 'Vänta några minuter innan du delar igen' }, 429);
|
||||
ctx.waitUntil(env.DB.prepare('DELETE FROM ratelimit WHERE until < ?').bind(now).run());
|
||||
|
||||
const place = await cityAt(env, lat, lng);
|
||||
if (!place) return json({ error: 'Hittar ingen stad här – välj en plats på land' }, 422);
|
||||
|
||||
const token = typeof b.token === 'string' && /^[0-9a-f]{64}$/.test(b.token) ? b.token : randHex(32);
|
||||
const th = await sha256(token);
|
||||
await env.DB.batch([
|
||||
env.DB.prepare('DELETE FROM pins WHERE token_hash = ?').bind(th), // one pin per owner: sharing again moves it
|
||||
env.DB.prepare('INSERT INTO pins (id, token_hash, lat, lng, city, country, cc, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.bind(randHex(8), th, lat, lng, place.city, place.country, place.cc, now),
|
||||
env.DB.prepare('INSERT OR REPLACE INTO ratelimit (k, until) VALUES (?, ?)').bind(rk, now + 5 * 6e4),
|
||||
]);
|
||||
ctx.waitUntil(caches.default.delete(LIST_KEY));
|
||||
return json({ token, city: place.city, country: place.country, cc: place.cc });
|
||||
}
|
||||
|
||||
// ---- remove: only with the owner's secret ----
|
||||
if (req.method === 'DELETE') {
|
||||
const token = req.headers.get('X-Pin-Token') || '';
|
||||
if (!/^[0-9a-f]{64}$/.test(token)) return json({ error: 'Saknar nyckel' }, 400);
|
||||
const r = await env.DB.prepare('DELETE FROM pins WHERE token_hash = ?').bind(await sha256(token)).run();
|
||||
ctx.waitUntil(caches.default.delete(LIST_KEY));
|
||||
return json({ removed: r.meta.changes });
|
||||
}
|
||||
return json({ error: 'Okänd förfrågan' }, 405);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user