diff --git a/Dockerfile b/Dockerfile index 5f29ff3..d0e3b9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ FROM gcr.io/distroless/base-debian12:nonroot WORKDIR /app COPY --from=build /out/greencoast-shard /app/greencoast-shard COPY configs/shard.sample.yaml /app/shard.yaml -COPY client /app/client +COPY client/ /opt/greencoast/client/ VOLUME ["/var/lib/greencoast"] EXPOSE 8080 8081 8443 9443 USER nonroot:nonroot diff --git a/client/app.js b/client/app.js index 1d60f68..6b8274e 100644 --- a/client/app.js +++ b/client/app.js @@ -2,18 +2,15 @@ import { encryptString, decryptToString, toBlob } from "./crypto.js"; // ---- Helpers ---- function defaultApiBase() { - // 1) URL query override: …/index.html?api=https://api.domain try { const qs = new URLSearchParams(window.location.search); const qApi = qs.get("api"); if (qApi) return qApi.replace(/\/+$/, ""); } catch {} - // 2) Meta override in index.html: const m = document.querySelector('meta[name="gc-api-base"]'); if (m && m.content) return m.content.replace(/\/+$/, ""); - // 3) Heuristic from frontend origin try { const u = new URL(window.location.href); const proto = u.protocol; @@ -33,6 +30,8 @@ function defaultApiBase() { } } +const LOCAL_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + // ---- DOM refs ---- const els = { shardUrl: document.getElementById("shardUrl"), @@ -47,13 +46,12 @@ const els = { publishStatus: document.getElementById("publishStatus"), posts: document.getElementById("posts"), discordStart: document.getElementById("discordStart"), + shareTZ: document.getElementById("shareTZ"), }; // ---- Config + state ---- const LS_KEY = "gc_client_config_v1"; const POSTS_KEY = "gc_posts_index_v1"; - -// IMPORTANT: define before sse() is ever called let sseCtrl = null; // ---- Boot ---- @@ -63,20 +61,18 @@ checkHealth(); syncIndex(); sse(); - -els.saveConn.onclick = async () => { - const c = { url: norm(els.shardUrl.value), bearer: els.bearer.value.trim(), passphrase: els.passphrase.value }; - saveConfig(c); await checkHealth(); await syncIndex(); sse(true); -}; - -els.publish.onclick = publish; -els.discordStart.onclick = discordStart; - +// ---- Storage helpers ---- function loadConfig(){ try { return JSON.parse(localStorage.getItem(LS_KEY)) ?? {}; } catch { return {}; } } function saveConfig(c){ localStorage.setItem(LS_KEY, JSON.stringify(c)); Object.assign(cfg, c); } function getPosts(){ try { return JSON.parse(localStorage.getItem(POSTS_KEY)) ?? []; } catch { return []; } } function setPosts(v){ localStorage.setItem(POSTS_KEY, JSON.stringify(v)); renderPosts(); } function norm(u){ return (u||"").replace(/\/+$/,""); } +function fmtWhen(ts, tz) { + try { + return new Intl.DateTimeFormat(undefined, { dateStyle:"medium", timeStyle:"short", timeZone: tz }).format(new Date(ts)); + } catch { return ts; } +} + function applyConfig() { if (!cfg.url) { const detected = defaultApiBase(); @@ -88,11 +84,23 @@ function applyConfig() { els.passphrase.value = cfg.passphrase ?? ""; } +els.saveConn.onclick = async () => { + const c = { url: norm(els.shardUrl.value), bearer: els.bearer.value.trim(), passphrase: els.passphrase.value }; + saveConfig(c); await checkHealth(); await syncIndex(); sse(true); +}; + +els.publish.onclick = publish; +els.discordStart.onclick = discordStart; async function checkHealth() { - if (!cfg.url) return; els.health.textContent = "Checking…"; - try { const r = await fetch(cfg.url + "/healthz"); els.health.textContent = r.ok ? "Connected ✔" : `Error: ${r.status}`; } - catch { els.health.textContent = "Not reachable"; } + if (!cfg.url) { els.health.textContent = "No API base set"; return; } + els.health.textContent = "Checking…"; + try { + const r = await fetch(cfg.url + "/healthz", { mode:"cors" }); + els.health.textContent = r.ok ? "Connected ✔" : `Error: ${r.status}`; + } catch (e) { + els.health.textContent = "Not reachable"; + } } async function publish() { @@ -104,15 +112,19 @@ async function publish() { if (!cfg.passphrase) return msg("Set a passphrase for private posts.", true); const payload = await encryptString(JSON.stringify({ title, body }), cfg.passphrase); blob = toBlob(payload); enc=true; - } else { blob = toBlob(JSON.stringify({ title, body })); } + } else { + blob = toBlob(JSON.stringify({ title, body })); + } const headers = { "Content-Type":"application/octet-stream" }; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer; if (enc) headers["X-GC-Private"] = "1"; + if (els.shareTZ && els.shareTZ.checked && LOCAL_TZ) headers["X-GC-TZ"] = LOCAL_TZ; // NEW + const r = await fetch(cfg.url + "/v1/object", { method:"PUT", headers, body: blob }); if (!r.ok) throw new Error(await r.text()); const j = await r.json(); const posts = getPosts(); - posts.unshift({ hash:j.hash, title: title || "(untitled)", bytes:j.bytes, ts:j.stored_at, enc }); + posts.unshift({ hash:j.hash, title: title || "(untitled)", bytes:j.bytes, ts:j.stored_at, enc, creator_tz: j.creator_tz || "" }); setPosts(posts); els.body.value = ""; msg(`Published ${enc?"private":"public"} post. Hash: ${j.hash}`); } catch(e){ msg("Publish failed: " + (e?.message||e), true); } @@ -127,13 +139,13 @@ async function syncIndex() { const r = await fetch(cfg.url + "/v1/index", { headers }); if (!r.ok) throw new Error("index fetch failed"); const entries = await r.json(); - setPosts(entries.map(e => ({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private }))); + setPosts(entries.map(e => ({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, creator_tz: e.creator_tz || "" }))); } catch(e){ console.warn("index sync failed", e); } } -function sse(){ +function sse(forceRestart=false){ if (!cfg.url) return; - if (sseCtrl) { sseCtrl.abort(); sseCtrl = undefined; } + if (sseCtrl) { sseCtrl.abort(); sseCtrl = null; } sseCtrl = new AbortController(); const url = cfg.url + "/v1/index/stream"; const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer; @@ -154,7 +166,7 @@ function sse(){ const e = ev.data; const posts = getPosts(); if (!posts.find(p => p.hash === e.hash)) { - posts.unshift({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private }); + posts.unshift({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private, creator_tz: e.creator_tz || "" }); setPosts(posts); } } else if (ev.event === "delete") { @@ -204,18 +216,15 @@ async function delServer(p) { } async function discordStart() { - // Last-resort auto-fill if user didn’t hit Save if (!cfg.url) { const derived = defaultApiBase(); if (derived) { - cfg.url = derived; - try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch {} + cfg.url = derived; try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch {} els.shardUrl.value = derived; } } if (!cfg.url) { alert("Set shard URL first."); return; } - - const r = await fetch(cfg.url + "/v1/auth/discord/start", { headers: { "X-GC-3P-Assent": "1" }}); + const r = await fetch(cfg.url + "/v1/auth/discord/start", { headers: { "X-GC-3P-Assent":"1" }}); if (!r.ok) { alert("Discord SSO not available"); return; } const j = await r.json(); location.href = j.url; @@ -224,10 +233,17 @@ async function discordStart() { function renderPosts() { const posts = getPosts(); els.posts.innerHTML = ""; for (const p of posts) { + const localStr = fmtWhen(p.ts, LOCAL_TZ) + ` (${LOCAL_TZ})`; + let creatorStr = ""; + if (p.creator_tz && p.creator_tz !== LOCAL_TZ) { + creatorStr = ` · creator: ${fmtWhen(p.ts, p.creator_tz)} (${p.creator_tz})`; + } const div = document.createElement("div"); div.className = "post"; const badge = p.enc ? `private` : `public`; div.innerHTML = ` -
+