// 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); }