Files
Linus OlofssonandClaude Opus 5.5 aeb1c76428 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>
2026-09-25 00:23:33 +02:00

111 lines
6.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}