This repository has been archived on 2025-08-23. You can view files and clone it, but cannot push or open issues or pull requests.
Files
GreenCoast/client/app.js

246 lines
9.8 KiB
JavaScript
Raw 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.

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: <meta name="gc-api-base" content="https://api.domain">
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;
const host = u.hostname;
const portStr = u.port;
const bracketHost = host.includes(":") ? `[${host}]` : host;
const port = portStr ? parseInt(portStr, 10) : null;
let apiPort = port;
if (port === 8082) apiPort = 8080;
else if (port === 9082) apiPort = 9080;
else if (port) apiPort = Math.max(1, port - 2);
return apiPort ? `${proto}//${bracketHost}:${apiPort}` : `${proto}//${bracketHost}`;
} catch {
return window.location.origin.replace(/\/+$/, "");
}
}
// ---- DOM refs ----
const els = {
shardUrl: document.getElementById("shardUrl"),
bearer: document.getElementById("bearer"),
passphrase: document.getElementById("passphrase"),
saveConn: document.getElementById("saveConn"),
health: document.getElementById("health"),
visibility: document.getElementById("visibility"),
title: document.getElementById("title"),
body: document.getElementById("body"),
publish: document.getElementById("publish"),
publishStatus: document.getElementById("publishStatus"),
posts: document.getElementById("posts"),
discordStart: document.getElementById("discordStart"),
};
// ---- 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 ----
const cfg = loadConfig();
applyConfig();
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;
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 applyConfig() {
if (!cfg.url) {
const detected = defaultApiBase();
cfg.url = detected;
try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch {}
}
els.shardUrl.value = cfg.url;
els.bearer.value = cfg.bearer ?? "";
els.passphrase.value = cfg.passphrase ?? "";
}
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"; }
}
async function publish() {
if (!cfg.url) return msg("Set shard URL first.", true);
const title = els.title.value.trim(); const body = els.body.value; const vis = els.visibility.value;
try {
let blob, enc=false;
if (vis === "private") {
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 })); }
const headers = { "Content-Type":"application/octet-stream" };
if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
if (enc) headers["X-GC-Private"] = "1";
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 });
setPosts(posts);
els.body.value = ""; msg(`Published ${enc?"private":"public"} post. Hash: ${j.hash}`);
} catch(e){ msg("Publish failed: " + (e?.message||e), true); }
}
function msg(t, err=false){ els.publishStatus.textContent=t; els.publishStatus.style.color = err ? "#ff6b6b" : "#8b949e"; }
async function syncIndex() {
if (!cfg.url) return;
try {
const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
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 })));
} catch(e){ console.warn("index sync failed", e); }
}
function sse(){
if (!cfg.url) return;
if (sseCtrl) { sseCtrl.abort(); sseCtrl = undefined; }
sseCtrl = new AbortController();
const url = cfg.url + "/v1/index/stream";
const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
fetch(url, { headers, signal: sseCtrl.signal }).then(async resp => {
if (!resp.ok) return;
const reader = resp.body.getReader(); const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read(); if (done) break;
buf += decoder.decode(value, { stream:true });
let idx;
while ((idx = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, idx); buf = buf.slice(idx+2);
if (chunk.startsWith("data: ")) {
try {
const ev = JSON.parse(chunk.slice(6));
if (ev.event === "put") {
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 });
setPosts(posts);
}
} else if (ev.event === "delete") {
const h = ev.data.hash; setPosts(getPosts().filter(p => p.hash !== h));
}
} catch {}
}
}
}
}).catch(()=>{});
}
async function viewPost(p, pre) {
pre.textContent = "Loading…";
try {
const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
const r = await fetch(cfg.url + "/v1/object/" + p.hash, { headers });
if (!r.ok) throw new Error("fetch failed " + r.status);
const buf = new Uint8Array(await r.arrayBuffer());
let text;
if (p.enc) {
if (!cfg.passphrase) throw new Error("passphrase required");
text = await decryptToString(buf, cfg.passphrase);
} else { text = new TextDecoder().decode(buf); }
try {
const j = JSON.parse(text);
pre.textContent = (j.title ? `# ${j.title}\n\n` : "") + (j.body ?? text);
} catch { pre.textContent = text; }
} catch (e) { pre.textContent = "Error: " + (e?.message || e); }
}
async function saveBlob(p) {
const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
const r = await fetch(cfg.url + "/v1/object/" + p.hash, { headers });
if (!r.ok) return alert("download failed " + r.status);
const b = await r.blob();
const a = document.createElement("a"); a.href = URL.createObjectURL(b);
a.download = p.hash + (p.enc ? ".gcenc" : ".json"); a.click(); URL.revokeObjectURL(a.href);
}
async function delServer(p) {
const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
if (!confirm("Delete blob from server by hash?")) return;
const r = await fetch(cfg.url + "/v1/object/" + p.hash, { method:"DELETE", headers });
if (!r.ok) return alert("delete failed " + r.status);
setPosts(getPosts().filter(x=>x.hash!==p.hash));
}
async function discordStart() {
// Last-resort auto-fill if user didnt hit Save
if (!cfg.url) {
const derived = defaultApiBase();
if (derived) {
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" }});
if (!r.ok) { alert("Discord SSO not available"); return; }
const j = await r.json();
location.href = j.url;
}
function renderPosts() {
const posts = getPosts(); els.posts.innerHTML = "";
for (const p of posts) {
const div = document.createElement("div"); div.className = "post";
const badge = p.enc ? `<span class="badge">private</span>` : `<span class="badge">public</span>`;
div.innerHTML = `
<div class="meta"><code>${p.hash.slice(0,10)}…</code> · ${p.bytes} bytes · ${p.ts} ${badge}</div>
<div class="actions">
<button data-act="view">View</button>
<button data-act="save">Save blob</button>
<button data-act="delete">Delete (server)</button>
<button data-act="remove">Remove (local)</button>
</div>
<pre class="content" style="white-space:pre-wrap;margin-top:.5rem;"></pre>`;
const pre = div.querySelector(".content");
div.querySelector('[data-act="view"]').onclick = () => viewPost(p, pre);
div.querySelector('[data-act="save"]').onclick = () => saveBlob(p);
div.querySelector('[data-act="delete"]').onclick = () => delServer(p);
div.querySelector('[data-act="remove"]').onclick = () => { setPosts(getPosts().filter(x=>x.hash!==p.hash)); };
els.posts.appendChild(div);
}
}