- 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>
111 lines
6.4 KiB
JavaScript
111 lines
6.4 KiB
JavaScript
// 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, 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);
|
||
|
||
const full = await env.ASSETS.fetch(new Request(url, { method: 'GET' }));
|
||
if (!full.ok) return full;
|
||
const buf = await full.arrayBuffer(), size = buf.byteLength;
|
||
const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
||
let start, end;
|
||
if (m && m[1] !== '') { start = +m[1]; end = m[2] !== '' ? Math.min(+m[2], size - 1) : size - 1; }
|
||
else if (m && m[2] !== '') { start = Math.max(0, size - +m[2]); end = size - 1; }
|
||
if (!m || start > end || start >= size)
|
||
return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${size}` } });
|
||
|
||
const h = new Headers(full.headers);
|
||
h.set('Content-Range', `bytes ${start}-${end}/${size}`);
|
||
h.set('Content-Length', String(end - start + 1));
|
||
h.set('Accept-Ranges', 'bytes');
|
||
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);
|
||
}
|