diff --git a/lith/Caddyfile b/lith/Caddyfile --- a/lith/Caddyfile +++ b/lith/Caddyfile @@ -122,9 +122,16 @@ # --- nela.aesthetic.computer (NELA Computer Club donate page) --- @nela host nela.aesthetic.computer handle @nela { - root * /opt/ac/system/public/nela.aesthetic.computer - try_files {path} {path}.html /index.html - file_server + handle /api/* { + reverse_proxy localhost:8888 + } + # Static fallback wrapped in `handle {}` — bare try_files reorders + # ahead of `handle` and eats /api/* (see the give block above). + handle { + root * /opt/ac/system/public/nela.aesthetic.computer + try_files {path} {path}.html /index.html + file_server + } } # --- give.aesthetic.computer --- diff --git a/system/netlify/functions/nela-signin.mjs b/system/netlify/functions/nela-signin.mjs new file mode 100644 --- /dev/null +++ b/system/netlify/functions/nela-signin.mjs @@ -0,0 +1,82 @@ +// nela-signin, 26.07.07 +// Signal OAuth for nela.aesthetic.computer — the donate page greets members +// by their Signal username. The identity provider is NCC's Signal-authed +// jump box (https://jump.nelacomputer.club, an OpenID Connect issuer run by +// max). The page can't hold the client secret, so it hands its authorization +// code here and we finish the dance: +// +// POST /api/nela-signin body: { code } +// → { username, uid, sub } +// +// Flow: exchange the code at /oauth/token (confidential client — id + secret +// live in the lith env, vault lith/.env), then read the claims from +// /oauth/userinfo. No scopes; tokens carry sub, phone_number, +// preferred_username, and uid. We return only what the greeting needs — +// phone_number stays server-side. +// +// The redirect_uri is pinned to the registered value (exact-match rule), so +// sign-in only completes against the production page. + +import { respond } from "../../backend/http.mjs"; + +const ISSUER = "https://jump.nelacomputer.club"; +const REDIRECT_URI = + process.env.NELA_OAUTH_REDIRECT_URI || "https://nela.aesthetic.computer"; + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(204, null); + if (event.httpMethod !== "POST") + return respond(405, { message: "Method Not Allowed." }); + + const clientId = process.env.NELA_OAUTH_CLIENT_ID; + const clientSecret = process.env.NELA_OAUTH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + console.error("🚪 nela-signin: NELA_OAUTH_* missing from the environment"); + return respond(500, { message: "Sign-in is not configured." }); + } + + let code; + try { + code = JSON.parse(event.body || "{}").code; + } catch { + return respond(400, { message: "Bad JSON." }); + } + if (!code || typeof code !== "string") + return respond(400, { message: "Missing code." }); + + // Code → token. + const tokenRes = await fetch(`${ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + client_secret: clientSecret, + }), + }); + if (!tokenRes.ok) { + const detail = await tokenRes.text().catch(() => ""); + console.error("🚪 nela-signin: token exchange failed", tokenRes.status, detail); + return respond(401, { message: "Sign-in failed." }); + } + const { access_token } = await tokenRes.json(); + + // Token → claims. + const infoRes = await fetch(`${ISSUER}/oauth/userinfo`, { + headers: { Authorization: `Bearer ${access_token}` }, + }); + if (!infoRes.ok) { + console.error("🚪 nela-signin: userinfo failed", infoRes.status); + return respond(502, { message: "Could not read profile." }); + } + const claims = await infoRes.json(); + console.log("🚪 nela-signin:", claims.preferred_username, claims.sub); + + return respond(200, { + username: claims.preferred_username, + uid: claims.uid, + sub: claims.sub, + }); +} diff --git a/system/public/nela.aesthetic.computer/index.html b/system/public/nela.aesthetic.computer/index.html --- a/system/public/nela.aesthetic.computer/index.html +++ b/system/public/nela.aesthetic.computer/index.html @@ -209,8 +209,19 @@ ▌ ▚ ▌ ▚ ▚ ▌ ▚▌ ▚▂▂▞ ▚▂▂▞ -
▒▒▒▒▒▒▒▒▒▒▒▒+ + +
Already on the club jump box? + sign in with signal
+Signed in as + @ via Signal. + sign out
+NCC is something we keep going together. Members chip in monthly to keep the room. No one is turned away for lack of funds.
@@ -300,6 +311,98 @@ syncMember(); // One-time: open donate flow, no preset interval. document.getElementById("onceBtn").href = ocDonate({ monthly: false }); + + // ── Signal sign-in ────────────────────────────────────────────── + // OpenID Connect against the club jump box. The authorization code + // comes back to this page (the registered redirect URI is the bare + // origin) and /api/nela-signin trades it for the profile server-side, + // where the client secret lives. We keep only the greeting bits. + const SIGNAL = { + issuer: "https://jump.nelacomputer.club", + clientId: "5653d749cfe76abf", + store: "ncc-signal", // localStorage key: { username, uid, at } + }; + + const hello = document.getElementById("hello"); + const outView = document.getElementById("signalOutView"); + const inView = document.getElementById("signalInView"); + const nameEl = document.getElementById("signalName"); + + function whoami() { + try { return JSON.parse(localStorage.getItem(SIGNAL.store)); } + catch { return null; } + } + + function renderSignal() { + const me = whoami(); + outView.hidden = !!me; + inView.hidden = !me; + if (me) { + nameEl.textContent = me.username; + hello.innerHTML = + 'Hi, ' + me.username.replace(/[<>&]/g, "") + + '! '; + } else { + hello.innerHTML = 'Support NCC '; + } + } + + document.getElementById("signalBtn").addEventListener("click", (e) => { + e.preventDefault(); + const state = crypto.randomUUID(); + sessionStorage.setItem("ncc-signal-state", state); + const p = new URLSearchParams({ + response_type: "code", + client_id: SIGNAL.clientId, + redirect_uri: location.origin, + state, + }); + location.href = SIGNAL.issuer + "/oauth/authorize?" + p; + }); + + document.getElementById("signalOff").addEventListener("click", (e) => { + e.preventDefault(); + localStorage.removeItem(SIGNAL.store); + renderSignal(); + }); + + // Finish the round-trip: ?code=... lands back here after authorize. + async function completeSignal() { + const params = new URLSearchParams(location.search); + const code = params.get("code"); + if (code) { + const state = sessionStorage.getItem("ncc-signal-state"); + sessionStorage.removeItem("ncc-signal-state"); + if (params.get("state") === state) { + try { + const res = await fetch("/api/nela-signin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }); + if (res.ok) { + const { username, uid } = await res.json(); + if (username) { + localStorage.setItem(SIGNAL.store, + JSON.stringify({ username, uid, at: Date.now() })); + } + console.log("🚪 signed in via signal:", username); + } else { + console.log("🚪 signal sign-in failed:", res.status); + } + } catch (err) { + console.log("🚪 signal sign-in error:", err); + } + } else { + console.log("🚪 signal sign-in: state mismatch, ignoring code"); + } + } + if (code || params.get("error")) { + history.replaceState(null, "", location.pathname); // tidy the URL + } + renderSignal(); + } + completeSignal();