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

238 lines
9.9 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";
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"),
};
function defaultApiBase() {
// 1) URL query override: …/index.html?api=http://host:9080
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="http://host:9080">
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; // no port
const portStr = u.port; // "" if default (80/443)
const bracketHost = host.includes(":") ? `[${host}]` : host; // IPv6-safe
const port = portStr ? parseInt(portStr, 10) : null;
let apiPort = port;
// Known frontend→API mappings
if (port === 8082) apiPort = 8080;
else if (port === 9082) apiPort = 9080;
else if (port) apiPort = Math.max(1, port - 2); // generic “minus two” fallback
return apiPort ? `${proto}//${bracketHost}:${apiPort}` : `${proto}//${bracketHost}`;
} catch {
return window.location.origin.replace(/\/+$/, "");
}
}
const LS_KEY = "gc_client_config_v1";
const POSTS_KEY = "gc_posts_index_v1";
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 no URL saved yet, detect a sensible default and persist it
if (!cfg.url) {
const detected = defaultApiBase(); // uses ?api=…, <meta gc-api-base>, or port heuristic
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); }
}
let sseCtrl;
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);
}
}