diff --git a/.claude/settings.local.json b/.claude/settings.local.json index eb10111..39f422f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,16 @@ "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/api/ideas/1/notes)", "Bash(kill %1)", "Bash(lsof -ti:3000)", - "Bash(xargs kill)" + "Bash(xargs kill)", + "Bash(bunx tsc *)", + "Bash(lsof -i:3000 -P -n)", + "Bash(kill 35084)", + "Bash(curl -s http://localhost:3000/api/auth/providers)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/api/me)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/api/ideas)", + "Bash(curl -s http://localhost:3000/api/explore)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000/auth/github)", + "Bash(bun *)" ] } } diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dad9ce9 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Copy to ".env" and fill in. Bun auto-loads .env. +# You only need to configure the provider(s) you want — the login screen +# shows just the ones with both an ID and secret set. + +# Public base URL of this app (used to build OAuth redirect URIs). +# Must match what you register in the OAuth app settings. +BASE_URL=http://localhost:3000 + +# --- GitHub OAuth --- +# Create one at: https://github.com/settings/developers -> "New OAuth App" +# Homepage URL: http://localhost:3000 +# Authorization callback URL: http://localhost:3000/auth/github/callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# --- Google OAuth --- +# Create credentials at: https://console.cloud.google.com/apis/credentials +# Application type: Web application +# Authorized redirect URI: http://localhost:3000/auth/google/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Optional: change the port (defaults to 3000) +# PORT=3000 diff --git a/.gitignore b/.gitignore index 39bb136..8ce7b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ *.db *.db-shm *.db-wal +.env diff --git a/README.md b/README.md index 2364c5c..d99362e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # ✨ Spark Slop A small, fast web app for capturing **project ideas** and growing them with -**timestamped notes** — like a comment thread for each idea. +**timestamped notes** — like a comment thread for each idea. Ideas are +**private to your account** by default, with opt-in sharing. Built with **Bun**, **TypeScript**, and **SQLite** (`bun:sqlite`). No external runtime dependencies — the whole backend is the Bun standard library. @@ -10,8 +11,46 @@ runtime dependencies — the whole backend is the Bun standard library. - **Bun.serve** — HTTP server + static file serving - **bun:sqlite** — embedded database (single `spark_slop.db` file, WAL mode) +- **OAuth** sign-in (GitHub and/or Google) with cookie sessions — no passwords stored - **Vanilla TS/JS frontend** — no build step, no framework -- A landing page (`/`) and the app (`/app`) + +## Auth & privacy + +- **Sign in with GitHub or Google.** Both are optional — the login screen only + shows providers you've configured. Sessions are HttpOnly cookies (30 days); + no passwords are ever stored. +- **Private by default.** Every idea belongs to its owner and is invisible to + everyone else. +- **Per-idea public toggle.** Flip an idea to public to list it on the Explore + feed (`/explore`), browsable by anyone — including logged-out visitors. +- **Shareable read-only links.** Generate a secret link (`/share/`) that + lets anyone view one idea and its notes without an account. Works + independently of public/private status, and can be revoked anytime. + +## Setup + +1. Install dev types (already done if you cloned with `bun.lock`): + + ```bash + bun install + ``` + +2. Configure at least one OAuth provider. Copy the example env file and fill in + the credentials: + + ```bash + cp .env.example .env + ``` + + **GitHub:** create an OAuth app at + with callback URL `http://localhost:3000/auth/github/callback`. + + **Google:** create OAuth credentials at + (Web application) with + authorized redirect URI `http://localhost:3000/auth/google/callback`. + + Bun auto-loads `.env`. If you change `BASE_URL` (e.g. for production), update + the callback/redirect URLs in the provider settings to match. ## Run it @@ -28,29 +67,66 @@ PORT=8080 bun run start ``` The SQLite database file (`spark_slop.db`) is created automatically on first run. +(If you ran an older version without auth, the schema self-migrates to add the +new columns — existing ideas will have no owner; delete `spark_slop.db` for a +clean slate.) ## Project layout ``` src/ - db.ts # SQLite schema + typed query functions - server.ts # Bun.serve: JSON API + static files + db.ts # SQLite schema, migration guard, ownership-scoped queries + auth.ts # OAuth (GitHub/Google) flows, sessions, cookies + server.ts # Bun.serve: auth routes + JSON API + static files public/ index.html # landing page - app.html # the app (idea list + notes) - app.js # frontend logic (API client, rendering) - styles.css # modern dark UI + app.html # the app (auth-gated: idea list, notes, privacy controls) + app.js # app logic (login gate, ideas, notes, visibility, sharing) + explore.html # public Explore feed + explore.js + share.html # read-only viewer (shared links + public ideas) + share.js + common.js # shared helpers (API client, time formatting, rendering) + styles.css # modern dark UI ``` ## API -| Method | Path | Description | -| ------ | ------------------------- | -------------------------- | -| GET | `/api/ideas` | List ideas (with note counts) | -| POST | `/api/ideas` | Create an idea `{title, description}` | -| GET | `/api/ideas/:id` | Get one idea | -| PUT | `/api/ideas/:id` | Update an idea | -| DELETE | `/api/ideas/:id` | Delete an idea (cascades notes) | -| GET | `/api/ideas/:id/notes` | List notes for an idea | -| POST | `/api/ideas/:id/notes` | Add a note `{body}` | -| DELETE | `/api/notes/:id` | Delete a note | +### Public (no auth) + +| Method | Path | Description | +| ------ | ------------------------- | ---------------------------------------- | +| GET | `/api/auth/providers` | Which OAuth providers are configured | +| GET | `/api/me` | Current user (401 if signed out) | +| GET | `/api/explore` | List all public ideas | +| GET | `/api/public/ideas/:id` | A public idea + its notes (read-only) | +| GET | `/api/shared/:token` | An idea + notes via secret share token | + +### Auth + +| Method | Path | Description | +| ------ | -------------------------- | --------------------------------- | +| GET | `/auth/github` | Start GitHub OAuth | +| GET | `/auth/github/callback` | GitHub redirect target | +| GET | `/auth/google` | Start Google OAuth | +| GET | `/auth/google/callback` | Google redirect target | +| GET | `/auth/logout` | Clear session and sign out | + +### Authenticated (owner-scoped) + +| Method | Path | Description | +| ------ | ----------------------------- | ------------------------------------ | +| GET | `/api/ideas` | List your ideas (with note counts) | +| POST | `/api/ideas` | Create an idea `{title, description}` | +| GET | `/api/ideas/:id` | Get one of your ideas | +| PUT | `/api/ideas/:id` | Update an idea | +| DELETE | `/api/ideas/:id` | Delete an idea (cascades notes) | +| PUT | `/api/ideas/:id/visibility` | Set `{visibility: "public"\|"private"}` | +| POST | `/api/ideas/:id/share` | Create/rotate a share link | +| DELETE | `/api/ideas/:id/share` | Revoke the share link | +| GET | `/api/ideas/:id/notes` | List notes for an idea | +| POST | `/api/ideas/:id/notes` | Add a note `{body}` | +| DELETE | `/api/notes/:id` | Delete a note | + +All authenticated routes return `401` when signed out and `404` for ideas/notes +you don't own — ownership is enforced in the SQL, not just the route layer. diff --git a/public/app.html b/public/app.html index 6a32b2e..664b40a 100644 --- a/public/app.html +++ b/public/app.html @@ -18,11 +18,29 @@ SparkSlop - ← Home + -
+ + + +
@@ -32,7 +50,6 @@
-

New idea

@@ -67,7 +84,6 @@
-
👈 @@ -79,7 +95,7 @@
- + diff --git a/public/app.js b/public/app.js index ffabc7f..cfbf77d 100644 --- a/public/app.js +++ b/public/app.js @@ -1,84 +1,99 @@ -// --- Tiny API client --- -const api = { - async req(method, path, body) { - const res = await fetch(path, { - method, - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - const data = res.status === 204 ? null : await res.json().catch(() => null); - if (!res.ok) throw new Error((data && data.error) || "Request failed"); - return data; - }, - ideas: () => api.req("GET", "/api/ideas"), - createIdea: (title, description) => +// --- API endpoints (relies on window.api from common.js) --- +const ideasApi = { + list: () => api.req("GET", "/api/ideas"), + create: (title, description) => api.req("POST", "/api/ideas", { title, description }), - deleteIdea: (id) => api.req("DELETE", `/api/ideas/${id}`), + remove: (id) => api.req("DELETE", `/api/ideas/${id}`), + setVisibility: (id, visibility) => + api.req("PUT", `/api/ideas/${id}/visibility`, { visibility }), + enableShare: (id) => api.req("POST", `/api/ideas/${id}/share`), + disableShare: (id) => api.req("DELETE", `/api/ideas/${id}/share`), notes: (id) => api.req("GET", `/api/ideas/${id}/notes`), addNote: (id, body) => api.req("POST", `/api/ideas/${id}/notes`, { body }), deleteNote: (id) => api.req("DELETE", `/api/notes/${id}`), }; -// --- State --- +let me = null; let ideas = []; let selectedId = null; -// --- Helpers --- -const $ = (sel) => document.querySelector(sel); - -function escapeHtml(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); +// --- Auth bootstrap --- +async function init() { + $("#idea-form").addEventListener("submit", onCreateIdea); + try { + me = await api.req("GET", "/api/me"); + showApp(); + await refresh(); + } catch (e) { + if (e.status === 401) showLogin(); + else toast(e.message, true); + } } -// SQLite stores UTC datetimes as "YYYY-MM-DD HH:MM:SS"; parse as UTC. -function parseUTC(s) { - return new Date(s.replace(" ", "T") + "Z"); +async function showLogin() { + $("#app-main").hidden = true; + $("#login-gate").hidden = false; + + const params = new URLSearchParams(location.search); + if (params.get("auth_error")) { + const el = $("#login-error"); + el.hidden = false; + el.textContent = + params.get("auth_error") === "state" + ? "Sign-in session expired or was tampered with. Please try again." + : "Could not complete sign-in. Please try again."; + } + + try { + const { providers } = await api.req("GET", "/api/auth/providers"); + const box = $("#provider-buttons"); + if (!providers.length) { + $("#login-note").hidden = false; + return; + } + box.innerHTML = providers.map(providerButton).join(""); + box.querySelectorAll(".provider-btn").forEach((btn) => { + btn.addEventListener("click", () => { + location.href = `/auth/${btn.dataset.provider}`; + }); + }); + } catch (e) { + toast(e.message, true); + } } -function relativeTime(s) { - const then = parseUTC(s); - const diff = (Date.now() - then.getTime()) / 1000; - if (diff < 45) return "just now"; - if (diff < 90) return "a minute ago"; - if (diff < 3600) return `${Math.round(diff / 60)} min ago`; - if (diff < 7200) return "an hour ago"; - if (diff < 86400) return `${Math.round(diff / 3600)} hours ago`; - if (diff < 172800) return "yesterday"; - if (diff < 604800) return `${Math.round(diff / 86400)} days ago`; - return then.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", +function showApp() { + $("#login-gate").hidden = true; + $("#app-main").hidden = false; + renderUserChip(); +} + +function renderUserChip() { + const nav = $("#nav-right"); + // Remove any existing chip first. + nav.querySelector(".user-chip")?.remove(); + const avatar = me.avatar_url + ? `` + : `${escapeHtml( + (me.name || "?")[0].toUpperCase() + )}`; + const chip = document.createElement("div"); + chip.className = "user-chip"; + chip.innerHTML = `${avatar}${escapeHtml( + me.name + )}`; + chip.querySelector(".logout").addEventListener("click", () => { + location.href = "/auth/logout"; }); + nav.appendChild(chip); } -function fullTime(s) { - return parseUTC(s).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }); -} - -let toastTimer; -function toast(msg, isError = false) { - const el = $("#toast"); - el.textContent = msg; - el.classList.toggle("error", isError); - el.classList.add("show"); - clearTimeout(toastTimer); - toastTimer = setTimeout(() => el.classList.remove("show"), 2600); -} - -// --- Rendering --- +// --- Rendering: idea list --- function renderList() { const list = $("#ideas-list"); $("#idea-count").textContent = ideas.length; - if (ideas.length === 0) { + if (!ideas.length) { list.innerHTML = '
No ideas yet. Add your first one!
'; return; @@ -90,19 +105,27 @@ function renderList() {

${escapeHtml(idea.title)}

- ${idea.note_count} note${idea.note_count === 1 ? "" : "s"} + ${idea.note_count} note${ + idea.note_count === 1 ? "" : "s" + } + ${ + idea.visibility === "public" ? "🌐 public" : "🔒 private" + } updated ${relativeTime(idea.updated_at)}
` ) .join(""); - list.querySelectorAll(".idea-item").forEach((el) => { - el.addEventListener("click", () => selectIdea(Number(el.dataset.id))); - }); + list + .querySelectorAll(".idea-item") + .forEach((el) => + el.addEventListener("click", () => selectIdea(Number(el.dataset.id))) + ); } -async function renderDetail() { +// --- Rendering: detail + privacy + notes --- +function renderDetail() { const panel = $("#detail-panel"); const idea = ideas.find((i) => i.id === selectedId); @@ -112,13 +135,57 @@ async function renderDetail() { return; } + const shareUrl = idea.share_token + ? `${location.origin}/share/${idea.share_token}` + : ""; + panel.innerHTML = `

${escapeHtml(idea.title)}

- ${idea.description ? `

${escapeHtml(idea.description)}

` : ""} + ${ + idea.description + ? `

${escapeHtml(idea.description)}

` + : "" + }
Created ${fullTime(idea.created_at)}
+ +
+ ${ + idea.visibility === "public" ? "🌐 Public" : "🔒 Private" + } + +
+ + +

Notes

Loading…
@@ -129,6 +196,16 @@ async function renderDetail() { `; $("#del-idea").addEventListener("click", () => onDeleteIdea(idea.id)); + $("#vis-toggle").addEventListener("change", (e) => + onToggleVisibility(idea.id, e.target.checked) + ); + + $("#share-enable")?.addEventListener("click", () => onEnableShare(idea.id)); + $("#share-disable")?.addEventListener("click", () => onDisableShare(idea.id)); + $("#share-copy")?.addEventListener("click", async () => { + await navigator.clipboard.writeText(shareUrl).catch(() => {}); + toast("Link copied to clipboard 🔗"); + }); const form = $("#note-form"); form.addEventListener("submit", (e) => { @@ -145,45 +222,20 @@ async function renderDetail() { loadNotes(idea.id); } -function renderNotes(notes) { - const container = $("#notes-container"); - if (!container) return; - if (notes.length === 0) { - container.innerHTML = - '
No notes yet — add the first one below.
'; - return; - } - container.innerHTML = notes - .map( - (n) => ` -
-
📝
-
-
${escapeHtml(n.body)}
-
- ${relativeTime(n.created_at)} - delete -
-
-
` - ) - .join(""); - - container.querySelectorAll(".del").forEach((el) => { - el.addEventListener("click", () => onDeleteNote(Number(el.dataset.note))); - }); -} - // --- Actions --- async function refresh() { try { - ideas = await api.ideas(); + ideas = await ideasApi.list(); renderList(); renderDetail(); } catch (e) { toast(e.message, true); } } +async function refreshListOnly() { + ideas = await ideasApi.list(); + renderList(); +} function selectIdea(id) { selectedId = id; @@ -193,8 +245,9 @@ function selectIdea(id) { async function loadNotes(id) { try { - const notes = await api.notes(id); - if (selectedId === id) renderNotes(notes); + const notes = await ideasApi.notes(id); + if (selectedId === id) + renderNotesInto($("#notes-container"), notes, onDeleteNote); } catch (e) { toast(e.message, true); } @@ -206,7 +259,7 @@ async function onCreateIdea(e) { const description = $("#idea-desc").value.trim(); if (!title) return; try { - const idea = await api.createIdea(title, description); + const idea = await ideasApi.create(title, description); $("#idea-form").reset(); await refresh(); selectIdea(idea.id); @@ -219,7 +272,7 @@ async function onCreateIdea(e) { async function onDeleteIdea(id) { if (!confirm("Delete this idea and all its notes?")) return; try { - await api.deleteIdea(id); + await ideasApi.remove(id); if (selectedId === id) selectedId = null; await refresh(); toast("Idea deleted"); @@ -228,28 +281,57 @@ async function onDeleteIdea(id) { } } +async function onToggleVisibility(id, makePublic) { + try { + const updated = await ideasApi.setVisibility(id, makePublic ? "public" : "private"); + mergeIdea(updated); + renderList(); + renderDetail(); + toast(makePublic ? "Now public on Explore 🌐" : "Set to private 🔒"); + } catch (err) { + toast(err.message, true); + renderDetail(); + } +} + +async function onEnableShare(id) { + try { + const updated = await ideasApi.enableShare(id); + mergeIdea(updated); + renderDetail(); + toast("Share link created 🔗"); + } catch (err) { + toast(err.message, true); + } +} + +async function onDisableShare(id) { + try { + const updated = await ideasApi.disableShare(id); + mergeIdea(updated); + renderDetail(); + toast("Share link disabled"); + } catch (err) { + toast(err.message, true); + } +} + async function onAddNote(id) { const input = $("#note-body"); const body = input.value.trim(); if (!body) return; try { - await api.addNote(id, body); + await ideasApi.addNote(id, body); input.value = ""; - // Refresh notes immediately, plus list for the updated count/order. await Promise.all([loadNotes(id), refreshListOnly()]); } catch (err) { toast(err.message, true); } } -async function refreshListOnly() { - ideas = await api.ideas(); - renderList(); -} - async function onDeleteNote(noteId) { try { - await api.deleteNote(noteId); + await ideasApi.deleteNote(noteId); if (selectedId) await Promise.all([loadNotes(selectedId), refreshListOnly()]); toast("Note deleted"); } catch (err) { @@ -257,6 +339,10 @@ async function onDeleteNote(noteId) { } } -// --- Init --- -$("#idea-form").addEventListener("submit", onCreateIdea); -refresh(); +// Replace an idea in local state with a fresh copy (preserving note_count). +function mergeIdea(updated) { + const idx = ideas.findIndex((i) => i.id === updated.id); + if (idx !== -1) ideas[idx] = { ...ideas[idx], ...updated }; +} + +init(); diff --git a/public/common.js b/public/common.js new file mode 100644 index 0000000..90549d6 --- /dev/null +++ b/public/common.js @@ -0,0 +1,109 @@ +// Shared helpers used by app.js, explore.js, and share.js. + +window.$ = (sel, root = document) => root.querySelector(sel); + +window.api = { + async req(method, path, body) { + const res = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data = res.status === 204 ? null : await res.json().catch(() => null); + if (!res.ok) { + const e = new Error((data && data.error) || "Request failed"); + e.status = res.status; + throw e; + } + return data; + }, +}; + +window.escapeHtml = (s) => + String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +// SQLite stores UTC datetimes as "YYYY-MM-DD HH:MM:SS"; parse as UTC. +window.parseUTC = (s) => new Date(s.replace(" ", "T") + "Z"); + +window.relativeTime = (s) => { + const then = parseUTC(s); + const diff = (Date.now() - then.getTime()) / 1000; + if (diff < 45) return "just now"; + if (diff < 90) return "a minute ago"; + if (diff < 3600) return `${Math.round(diff / 60)} min ago`; + if (diff < 7200) return "an hour ago"; + if (diff < 86400) return `${Math.round(diff / 3600)} hours ago`; + if (diff < 172800) return "yesterday"; + if (diff < 604800) return `${Math.round(diff / 86400)} days ago`; + return then.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +}; + +window.fullTime = (s) => + parseUTC(s).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + +let toastTimer; +window.toast = (msg, isError = false) => { + const el = $("#toast"); + if (!el) return; + el.textContent = msg; + el.classList.toggle("error", isError); + el.classList.add("show"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove("show"), 2600); +}; + +// Render a list of notes into a container element (read-only unless onDelete given). +window.renderNotesInto = (container, notes, onDelete) => { + if (!container) return; + if (!notes.length) { + container.innerHTML = + '
No notes yet.
'; + return; + } + container.innerHTML = notes + .map( + (n) => ` +
+
📝
+
+
${escapeHtml(n.body)}
+
+ ${relativeTime( + n.created_at + )} + ${onDelete ? `delete` : ""} +
+
+
` + ) + .join(""); + if (onDelete) { + container.querySelectorAll(".del").forEach((el) => + el.addEventListener("click", () => onDelete(Number(el.dataset.note))) + ); + } +}; + +// Provider sign-in buttons (used by login gate). +window.providerButton = (p) => { + const icons = { + github: + '', + google: + '', + }; + return ``; +}; diff --git a/public/explore.html b/public/explore.html new file mode 100644 index 0000000..450f172 --- /dev/null +++ b/public/explore.html @@ -0,0 +1,43 @@ + + + + + + Explore — Spark Slop + + + + + + + +
+
+
+
+

🌐 Explore public ideas

+

Ideas people have chosen to share with the world.

+
+
+
+
Loading…
+
+
+
+ +
+ + + + diff --git a/public/explore.js b/public/explore.js new file mode 100644 index 0000000..e4e3c09 --- /dev/null +++ b/public/explore.js @@ -0,0 +1,36 @@ +async function load() { + const grid = $("#explore-grid"); + try { + const ideas = await api.req("GET", "/api/explore"); + if (!ideas.length) { + grid.innerHTML = + '
🌱No public ideas yet. Be the first to share one!
'; + return; + } + grid.innerHTML = ideas + .map( + (idea) => ` + +

${escapeHtml(idea.title)}

+

${ + idea.description + ? escapeHtml(idea.description) + : "No description" + }

+
+ ${idea.note_count} note${ + idea.note_count === 1 ? "" : "s" + } + by ${escapeHtml(idea.author_name || "someone")} + · ${relativeTime(idea.updated_at)} +
+
` + ) + .join(""); + } catch (e) { + grid.innerHTML = `
Could not load ideas: ${escapeHtml( + e.message + )}
`; + } +} +load(); diff --git a/public/index.html b/public/index.html index 21981bd..14bd5d6 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,10 @@ SparkSlop - Open the app → + @@ -39,8 +42,8 @@ comment thread for your own brain.

@@ -64,11 +67,12 @@

- -

Fast & local

+ 🔒 +

Private by default

- Powered by Bun and SQLite. Your data lives in a single file on your - machine — snappy, private, and yours. + Sign in with GitHub or Google. Every idea is yours alone — until you + choose to share it with a secret read-only link or list it on the + public Explore feed.

diff --git a/public/share.html b/public/share.html new file mode 100644 index 0000000..e59c1b9 --- /dev/null +++ b/public/share.html @@ -0,0 +1,37 @@ + + + + + + Shared idea — Spark Slop + + + + + + + +
+
+
+
Loading…
+
+
+
+ +
+ + + + diff --git a/public/share.js b/public/share.js new file mode 100644 index 0000000..612c45f --- /dev/null +++ b/public/share.js @@ -0,0 +1,57 @@ +// Universal read-only viewer: +// /share/:token -> fetch /api/shared/:token (secret link) +// /share?idea=:id -> fetch /api/public/ideas/:id (public Explore item) +function resolveEndpoint() { + const parts = location.pathname.split("/").filter(Boolean); // ["share", token?] + if (parts.length >= 2) return `/api/shared/${encodeURIComponent(parts[1])}`; + const id = new URLSearchParams(location.search).get("idea"); + if (id) return `/api/public/ideas/${encodeURIComponent(id)}`; + return null; +} + +async function load() { + const root = $("#share-root"); + const endpoint = resolveEndpoint(); + if (!endpoint) { + root.innerHTML = + '
🤔No idea specified.
'; + return; + } + + try { + const idea = await api.req("GET", endpoint); + const isPublic = idea.visibility === "public"; + root.innerHTML = ` +
+ 👁️ You're viewing a read-only ${ + isPublic ? "public" : "shared" + } idea${idea.author_name ? ` by ${escapeHtml(idea.author_name)}` : ""}. +
+
+
+

${escapeHtml(idea.title)}

+ ${ + isPublic ? "🌐 Public" : "🔒 Shared" + } +
+ ${ + idea.description + ? `

${escapeHtml(idea.description)}

` + : "" + } +
Created ${fullTime(idea.created_at)}
+
+

Notes

+
+
+ `; + renderNotesInto($("#notes-container"), idea.notes || []); + } catch (e) { + root.innerHTML = `
🔒${ + e.status === 404 + ? "This idea doesn't exist, is private, or the link was disabled." + : escapeHtml(e.message) + }
`; + } +} +load(); diff --git a/public/styles.css b/public/styles.css index 94a1219..f410e31 100644 --- a/public/styles.css +++ b/public/styles.css @@ -424,6 +424,224 @@ textarea:focus { margin-bottom: 12px; } +/* ---------- Nav right / user chip ---------- */ +.nav-right { + display: flex; + align-items: center; + gap: 12px; +} +.user-chip { + display: flex; + align-items: center; + gap: 9px; + padding: 5px 6px 5px 12px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + font-size: 0.88rem; +} +.user-chip img, +.user-chip .avatar-fallback { + width: 28px; + height: 28px; + border-radius: 50%; +} +.user-chip .avatar-fallback { + background: var(--grad); + display: grid; + place-items: center; + font-size: 0.85rem; +} +.user-chip .logout { + color: var(--muted); + cursor: pointer; + padding: 0 6px; +} +.user-chip .logout:hover { + color: var(--danger); +} + +/* ---------- Login gate ---------- */ +.login-gate { + min-height: calc(100vh - 68px); + display: grid; + place-items: center; + padding: 40px 20px; +} +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 44px 38px; + max-width: 420px; + width: 100%; + text-align: center; + box-shadow: var(--shadow); +} +.login-card .spark.big { + font-size: 2.6rem; + display: block; + margin-bottom: 14px; +} +.login-card h1 { + font-size: 1.6rem; + margin-bottom: 10px; + letter-spacing: -0.02em; +} +.login-card p { + color: var(--muted); + margin-bottom: 26px; +} +.provider-buttons { + display: flex; + flex-direction: column; + gap: 12px; +} +.provider-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 13px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-weight: 600; + font-size: 0.98rem; + cursor: pointer; + transition: border-color 0.2s ease, transform 0.12s ease; +} +.provider-btn:hover { + border-color: var(--accent); + transform: translateY(-1px); +} +.provider-btn svg { + width: 20px; + height: 20px; +} +.login-error { + color: var(--danger); + margin-top: 18px !important; + margin-bottom: 0 !important; + font-size: 0.9rem; +} +.login-note { + font-size: 0.85rem; + margin-top: 18px !important; + margin-bottom: 0 !important; +} + +/* ---------- Privacy controls ---------- */ +.privacy-bar { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + margin-bottom: 18px; +} +.vis-badge { + font-size: 0.78rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; +} +.vis-badge.private { + background: rgba(139, 147, 167, 0.18); + color: var(--muted); +} +.vis-badge.public { + background: rgba(0, 212, 255, 0.16); + color: var(--accent-2); +} + +/* Toggle switch */ +.switch { + display: inline-flex; + align-items: center; + gap: 9px; + cursor: pointer; + font-size: 0.88rem; + color: var(--muted); +} +.switch input { + display: none; +} +.switch .track { + width: 40px; + height: 22px; + border-radius: 999px; + background: var(--surface-2); + border: 1px solid var(--border); + position: relative; + transition: background 0.2s ease; +} +.switch .track::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--muted); + transition: transform 0.2s ease, background 0.2s ease; +} +.switch input:checked + .track { + background: rgba(0, 212, 255, 0.25); + border-color: var(--accent-2); +} +.switch input:checked + .track::after { + transform: translateX(18px); + background: var(--accent-2); +} + +.share-box { + margin-top: 4px; + margin-bottom: 18px; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px; +} +.share-box .share-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.share-box h4 { + font-size: 0.92rem; +} +.share-row { + display: flex; + gap: 8px; +} +.share-row input { + flex: 1; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.82rem; +} +.share-hint { + color: var(--muted); + font-size: 0.82rem; + margin-top: 8px; +} + +.read-only-banner { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 14px; + font-size: 0.85rem; + color: var(--muted); + margin-bottom: 20px; +} + .toast { position: fixed; bottom: 24px; diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..03be066 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,272 @@ +import { + upsertUser, + createSession, + getSessionUser, + deleteSession, + type User, +} from "./db"; + +export const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"; +const SESSION_COOKIE = "spark_session"; +const STATE_COOKIE = "spark_oauth_state"; +const SESSION_DAYS = 30; + +interface ProviderConfig { + id: "github" | "google"; + label: string; + clientId?: string; + clientSecret?: string; +} + +const providers: ProviderConfig[] = [ + { + id: "github", + label: "GitHub", + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + }, + { + id: "google", + label: "Google", + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }, +]; + +export function configuredProviders() { + return providers + .filter((p) => p.clientId && p.clientSecret) + .map((p) => ({ id: p.id, label: p.label })); +} + +function provider(id: string): ProviderConfig | undefined { + return providers.find( + (p) => p.id === id && p.clientId && p.clientSecret + ); +} + +// --- token / cookie helpers --- + +function randomToken(bytes = 32): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} +export { randomToken }; + +export function parseCookies(req: Request): Record { + const header = req.headers.get("cookie"); + if (!header) return {}; + const out: Record = {}; + for (const part of header.split(";")) { + const idx = part.indexOf("="); + if (idx === -1) continue; + out[part.slice(0, idx).trim()] = decodeURIComponent( + part.slice(idx + 1).trim() + ); + } + return out; +} + +function cookie( + name: string, + value: string, + maxAgeSeconds: number +): string { + const secure = BASE_URL.startsWith("https") ? " Secure;" : ""; + return `${name}=${encodeURIComponent( + value + )}; Path=/; HttpOnly; SameSite=Lax;${secure} Max-Age=${maxAgeSeconds}`; +} + +export function currentUser(req: Request): User | null { + const token = parseCookies(req)[SESSION_COOKIE]; + if (!token) return null; + return getSessionUser(token); +} + +// --- OAuth flow --- + +function redirect(location: string, headers: Record = {}) { + return new Response(null, { status: 302, headers: { Location: location, ...headers } }); +} + +// Step 1: send the user to the provider's consent screen. +export function startOAuth(providerId: string): Response { + const p = provider(providerId); + if (!p) return new Response("Unknown or unconfigured provider", { status: 404 }); + + const state = randomToken(16); + const redirectUri = `${BASE_URL}/auth/${p.id}/callback`; + let url: string; + + if (p.id === "github") { + const q = new URLSearchParams({ + client_id: p.clientId!, + redirect_uri: redirectUri, + scope: "read:user user:email", + state, + }); + url = `https://github.com/login/oauth/authorize?${q}`; + } else { + const q = new URLSearchParams({ + client_id: p.clientId!, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid email profile", + state, + access_type: "online", + prompt: "select_account", + }); + url = `https://accounts.google.com/o/oauth2/v2/auth?${q}`; + } + + // Bind the state to the chosen provider so we can validate the callback. + return redirect(url, { + "Set-Cookie": cookie(STATE_COOKIE, `${p.id}:${state}`, 600), + }); +} + +// Step 2: handle the provider's redirect back to us. +export async function handleCallback( + providerId: string, + req: Request +): Promise { + const p = provider(providerId); + if (!p) return new Response("Unknown provider", { status: 404 }); + + const url = new URL(req.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const expected = parseCookies(req)[STATE_COOKIE]; + + if (!code || !state || expected !== `${p.id}:${state}`) { + return redirect("/app?auth_error=state"); + } + + const redirectUri = `${BASE_URL}/auth/${p.id}/callback`; + + try { + const profile = + p.id === "github" + ? await githubProfile(p, code, redirectUri) + : await googleProfile(p, code, redirectUri); + + const user = upsertUser(profile); + const token = randomToken(32); + const expires = new Date(Date.now() + SESSION_DAYS * 86400_000); + createSession(token, user.id, isoSqlite(expires)); + + // Clear state cookie, set session cookie. + return redirect("/app", { + "Set-Cookie": cookie(SESSION_COOKIE, token, SESSION_DAYS * 86400), + }); + } catch (e) { + console.error("OAuth callback failed:", e); + return redirect("/app?auth_error=exchange"); + } +} + +export function logout(req: Request): Response { + const token = parseCookies(req)[SESSION_COOKIE]; + if (token) deleteSession(token); + return redirect("/", { + "Set-Cookie": cookie(SESSION_COOKIE, "", 0), + }); +} + +// --- provider-specific profile fetching --- + +async function githubProfile(p: ProviderConfig, code: string, redirectUri: string) { + const tokenRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + client_id: p.clientId, + client_secret: p.clientSecret, + code, + redirect_uri: redirectUri, + }), + }); + const tokenJson = (await tokenRes.json()) as { access_token?: string }; + const accessToken = tokenJson.access_token; + if (!accessToken) throw new Error("No access token from GitHub"); + + const auth = { Authorization: `Bearer ${accessToken}`, "User-Agent": "spark-slop" }; + const userRes = await fetch("https://api.github.com/user", { headers: auth }); + const gh = (await userRes.json()) as { + id: number; + login: string; + name?: string; + avatar_url?: string; + email?: string; + }; + + // GitHub may not expose a public email; fetch the primary verified one. + let email = gh.email ?? null; + if (!email) { + const emailRes = await fetch("https://api.github.com/user/emails", { + headers: auth, + }); + if (emailRes.ok) { + const emails = (await emailRes.json()) as { + email: string; + primary: boolean; + verified: boolean; + }[]; + email = + emails.find((e) => e.primary && e.verified)?.email ?? + emails.find((e) => e.verified)?.email ?? + emails[0]?.email ?? + null; + } + } + + return { + provider: "github", + provider_id: String(gh.id), + email, + name: gh.name || gh.login, + avatar_url: gh.avatar_url ?? null, + }; +} + +async function googleProfile(p: ProviderConfig, code: string, redirectUri: string) { + const tokenRes = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: p.clientId!, + client_secret: p.clientSecret!, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + }); + const tokenJson = (await tokenRes.json()) as { access_token?: string }; + if (!tokenJson.access_token) throw new Error("No access token from Google"); + + const userRes = await fetch( + "https://www.googleapis.com/oauth2/v2/userinfo", + { headers: { Authorization: `Bearer ${tokenJson.access_token}` } } + ); + const g = (await userRes.json()) as { + id: string; + email?: string; + name?: string; + picture?: string; + }; + + return { + provider: "google", + provider_id: g.id, + email: g.email ?? null, + name: g.name || g.email || "Google user", + avatar_url: g.picture ?? null, + }; +} + +// Format a Date as SQLite's "YYYY-MM-DD HH:MM:SS" in UTC. +function isoSqlite(d: Date): string { + return d.toISOString().slice(0, 19).replace("T", " "); +} diff --git a/src/db.ts b/src/db.ts index 300d946..c551e3a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -6,13 +6,40 @@ export const db = new Database("spark_slop.db", { create: true }); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + email TEXT, + name TEXT NOT NULL DEFAULT '', + avatar_url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (provider, provider_id) + ); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); +`); + db.exec(` CREATE TABLE IF NOT EXISTS ideas ( id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'private', + share_token TEXT UNIQUE, created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `); @@ -27,14 +54,55 @@ db.exec(` `); db.exec(`CREATE INDEX IF NOT EXISTS idx_notes_idea_id ON notes(idea_id);`); +db.exec(`CREATE INDEX IF NOT EXISTS idx_ideas_user_id ON ideas(user_id);`); + +// --- Lightweight migration: add columns missing from older databases --- +function columns(table: string): Set { + const rows = db.query(`PRAGMA table_info(${table})`).all() as { + name: string; + }[]; + return new Set(rows.map((r) => r.name)); +} +const ideaCols = columns("ideas"); +if (!ideaCols.has("user_id")) + db.exec(`ALTER TABLE ideas ADD COLUMN user_id INTEGER`); +if (!ideaCols.has("visibility")) + db.exec( + `ALTER TABLE ideas ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'` + ); +if (!ideaCols.has("share_token")) + db.exec(`ALTER TABLE ideas ADD COLUMN share_token TEXT`); + +export type Visibility = "private" | "public"; + +export interface User { + id: number; + provider: string; + provider_id: string; + email: string | null; + name: string; + avatar_url: string | null; + created_at: string; +} + +export interface Session { + token: string; + user_id: number; + created_at: string; + expires_at: string; +} export interface Idea { id: number; + user_id: number; title: string; description: string; + visibility: Visibility; + share_token: string | null; created_at: string; updated_at: string; note_count?: number; + author_name?: string; } export interface Note { @@ -44,35 +112,140 @@ export interface Note { created_at: string; } -// --- Idea queries --- +// --- User queries --- -export function listIdeas(): Idea[] { +export function upsertUser(u: { + provider: string; + provider_id: string; + email: string | null; + name: string; + avatar_url: string | null; +}): User { + const existing = db + .query(`SELECT * FROM users WHERE provider = ? AND provider_id = ?`) + .get(u.provider, u.provider_id) as User | null; + + if (existing) { + return db + .query( + `UPDATE users SET email = ?, name = ?, avatar_url = ? + WHERE id = ? RETURNING *` + ) + .get(u.email, u.name, u.avatar_url, existing.id) as User; + } + return db + .query( + `INSERT INTO users (provider, provider_id, email, name, avatar_url) + VALUES (?, ?, ?, ?, ?) RETURNING *` + ) + .get(u.provider, u.provider_id, u.email, u.name, u.avatar_url) as User; +} + +// --- Session queries --- + +export function createSession( + token: string, + userId: number, + expiresAt: string +): void { + db.query( + `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)` + ).run(token, userId, expiresAt); +} + +export function getSessionUser(token: string): User | null { + return ( + (db + .query( + `SELECT u.* FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token = ? AND s.expires_at > datetime('now')` + ) + .get(token) as User) ?? null + ); +} + +export function deleteSession(token: string): void { + db.query(`DELETE FROM sessions WHERE token = ?`).run(token); +} + +// --- Idea queries (ownership-scoped) --- + +export function listIdeas(userId: number): Idea[] { return db .query( `SELECT i.*, COUNT(n.id) AS note_count FROM ideas i LEFT JOIN notes n ON n.idea_id = i.id + WHERE i.user_id = ? GROUP BY i.id ORDER BY i.updated_at DESC` ) + .all(userId) as Idea[]; +} + +export function getOwnedIdea(id: number, userId: number): Idea | null { + return ( + (db + .query(`SELECT * FROM ideas WHERE id = ? AND user_id = ?`) + .get(id, userId) as Idea) ?? null + ); +} + +export function getPublicIdeaById(id: number): Idea | null { + return ( + (db + .query( + `SELECT i.*, u.name AS author_name + FROM ideas i JOIN users u ON u.id = i.user_id + WHERE i.id = ? AND i.visibility = 'public'` + ) + .get(id) as Idea) ?? null + ); +} + +export function getIdeaByShareToken(token: string): Idea | null { + return ( + (db + .query( + `SELECT i.*, u.name AS author_name + FROM ideas i JOIN users u ON u.id = i.user_id + WHERE i.share_token = ?` + ) + .get(token) as Idea) ?? null + ); +} + +export function listPublicIdeas(): Idea[] { + return db + .query( + `SELECT i.*, u.name AS author_name, COUNT(n.id) AS note_count + FROM ideas i + JOIN users u ON u.id = i.user_id + LEFT JOIN notes n ON n.idea_id = i.id + WHERE i.visibility = 'public' + GROUP BY i.id + ORDER BY i.updated_at DESC + LIMIT 200` + ) .all() as Idea[]; } -export function getIdea(id: number): Idea | null { - return (db.query(`SELECT * FROM ideas WHERE id = ?`).get(id) as Idea) ?? null; -} - -export function createIdea(title: string, description: string): Idea { - const row = db +export function createIdea( + userId: number, + title: string, + description: string +): Idea { + return db .query( - `INSERT INTO ideas (title, description) VALUES (?, ?) RETURNING *` + `INSERT INTO ideas (user_id, title, description) VALUES (?, ?, ?) RETURNING *` ) - .get(title, description) as Idea; - return row; + .get(userId, title, description) as Idea; } export function updateIdea( id: number, + userId: number, title: string, description: string ): Idea | null { @@ -81,23 +254,56 @@ export function updateIdea( .query( `UPDATE ideas SET title = ?, description = ?, updated_at = datetime('now') - WHERE id = ? + WHERE id = ? AND user_id = ? RETURNING *` ) - .get(title, description, id) as Idea) ?? null + .get(title, description, id, userId) as Idea) ?? null ); } -export function deleteIdea(id: number): boolean { - const res = db.query(`DELETE FROM ideas WHERE id = ?`).run(id); - return res.changes > 0; +export function setVisibility( + id: number, + userId: number, + visibility: Visibility +): Idea | null { + return ( + (db + .query( + `UPDATE ideas SET visibility = ?, updated_at = datetime('now') + WHERE id = ? AND user_id = ? RETURNING *` + ) + .get(visibility, id, userId) as Idea) ?? null + ); +} + +export function setShareToken( + id: number, + userId: number, + token: string | null +): Idea | null { + return ( + (db + .query( + `UPDATE ideas SET share_token = ? WHERE id = ? AND user_id = ? RETURNING *` + ) + .get(token, id, userId) as Idea) ?? null + ); +} + +export function deleteIdea(id: number, userId: number): boolean { + return ( + db.query(`DELETE FROM ideas WHERE id = ? AND user_id = ?`).run(id, userId) + .changes > 0 + ); } // --- Note queries --- export function listNotes(ideaId: number): Note[] { return db - .query(`SELECT * FROM notes WHERE idea_id = ? ORDER BY created_at ASC, id ASC`) + .query( + `SELECT * FROM notes WHERE idea_id = ? ORDER BY created_at ASC, id ASC` + ) .all(ideaId) as Note[]; } @@ -105,14 +311,21 @@ export function createNote(ideaId: number, body: string): Note { const note = db .query(`INSERT INTO notes (idea_id, body) VALUES (?, ?) RETURNING *`) .get(ideaId, body) as Note; - // Touch the parent idea so it sorts to the top. db.query(`UPDATE ideas SET updated_at = datetime('now') WHERE id = ?`).run( ideaId ); return note; } -export function deleteNote(id: number): boolean { - const res = db.query(`DELETE FROM notes WHERE id = ?`).run(id); - return res.changes > 0; +// Returns the idea_id of the deleted note, or null if it didn't exist / +// wasn't owned by the user (ownership enforced via the join). +export function deleteNoteOwned(noteId: number, userId: number): boolean { + return ( + db + .query( + `DELETE FROM notes + WHERE id = ? AND idea_id IN (SELECT id FROM ideas WHERE user_id = ?)` + ) + .run(noteId, userId).changes > 0 + ); } diff --git a/src/server.ts b/src/server.ts index 1bd8866..c5f00d2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,13 +1,27 @@ import { listIdeas, - getIdea, + getOwnedIdea, + getPublicIdeaById, + getIdeaByShareToken, + listPublicIdeas, createIdea, updateIdea, + setVisibility, + setShareToken, deleteIdea, listNotes, createNote, - deleteNote, + deleteNoteOwned, + type User, } from "./db"; +import { + currentUser, + configuredProviders, + startOAuth, + handleCallback, + logout, + randomToken, +} from "./auth"; const PORT = Number(process.env.PORT ?? 3000); const PUBLIC_DIR = new URL("../public/", import.meta.url).pathname; @@ -18,11 +32,9 @@ function json(data: unknown, status = 200): Response { headers: { "Content-Type": "application/json" }, }); } - function err(message: string, status = 400): Response { return json({ error: message }, status); } - async function readJson(req: Request): Promise { try { return await req.json(); @@ -31,14 +43,30 @@ async function readJson(req: Request): Promise { } } +// Expose only safe, owner-facing fields. +function publicView(idea: any, includeNotes = false) { + const base = { + id: idea.id, + title: idea.title, + description: idea.description, + visibility: idea.visibility, + author_name: idea.author_name ?? null, + created_at: idea.created_at, + updated_at: idea.updated_at, + note_count: idea.note_count, + }; + return includeNotes ? { ...base, notes: listNotes(idea.id) } : base; +} + async function serveStatic(pathname: string): Promise { - // Map "/" -> index.html, "/app" -> app.html, otherwise the literal file. let rel = pathname === "/" ? "index.html" : pathname.slice(1); if (rel === "app") rel = "app.html"; + if (rel === "explore") rel = "explore.html"; + // /share/:token and /share?idea= -> the read-only viewer page. + if (rel === "share" || rel.startsWith("share/")) rel = "share.html"; let file = Bun.file(PUBLIC_DIR + rel); if (!(await file.exists())) { - // SPA-ish fallback for unknown non-asset routes. if (!rel.includes(".")) file = Bun.file(PUBLIC_DIR + "index.html"); } if (!(await file.exists())) return new Response("Not found", { status: 404 }); @@ -51,7 +79,8 @@ const server = Bun.serve({ const url = new URL(req.url); const { pathname } = url; - // ---------- API ---------- + if (pathname.startsWith("/auth/")) return handleAuthRoutes(req, pathname); + if (pathname.startsWith("/api/")) { try { return await handleApi(req, pathname); @@ -61,23 +90,76 @@ const server = Bun.serve({ } } - // ---------- Static ---------- return serveStatic(pathname); }, }); +function handleAuthRoutes(req: Request, pathname: string): Promise | Response { + let m = pathname.match(/^\/auth\/(github|google)$/); + if (m) return startOAuth(m[1]!); + + m = pathname.match(/^\/auth\/(github|google)\/callback$/); + if (m) return handleCallback(m[1]!, req); + + if (pathname === "/auth/logout") return logout(req); + + return new Response("Not found", { status: 404 }); +} + async function handleApi(req: Request, pathname: string): Promise { const method = req.method; + // ---- Public, no auth required ---- + if (pathname === "/api/auth/providers" && method === "GET") { + return json({ providers: configuredProviders() }); + } + + if (pathname === "/api/me" && method === "GET") { + const user = currentUser(req); + if (!user) return err("Not authenticated", 401); + return json(safeUser(user)); + } + + if (pathname === "/api/explore" && method === "GET") { + return json(listPublicIdeas().map((i) => publicView(i))); + } + + // Public idea detail (read-only) — visibility must be 'public'. + let m = pathname.match(/^\/api\/public\/ideas\/(\d+)$/); + if (m && method === "GET") { + const idea = getPublicIdeaById(Number(m[1])); + return idea ? json(publicView(idea, true)) : err("Not found", 404); + } + + // Shared read-only view by secret token. + m = pathname.match(/^\/api\/shared\/([a-f0-9]+)$/); + if (m && method === "GET") { + const idea = getIdeaByShareToken(m[1]!); + return idea ? json(publicView(idea, true)) : err("Not found", 404); + } + + // ---- Everything below requires authentication ---- + const user = currentUser(req); + if (!user) return err("Not authenticated", 401); + + return handleAuthedApi(req, pathname, method, user); +} + +async function handleAuthedApi( + req: Request, + pathname: string, + method: string, + user: User +): Promise { // /api/ideas if (pathname === "/api/ideas") { - if (method === "GET") return json(listIdeas()); + if (method === "GET") return json(listIdeas(user.id)); if (method === "POST") { const { title, description } = await readJson(req); if (!title || typeof title !== "string" || !title.trim()) return err("Title is required"); return json( - createIdea(title.trim(), (description ?? "").toString().trim()), + createIdea(user.id, title.trim(), (description ?? "").toString().trim()), 201 ); } @@ -89,7 +171,7 @@ async function handleApi(req: Request, pathname: string): Promise { if (m) { const id = Number(m[1]); if (method === "GET") { - const idea = getIdea(id); + const idea = getOwnedIdea(id, user.id); return idea ? json(idea) : err("Idea not found", 404); } if (method === "PUT") { @@ -97,13 +179,41 @@ async function handleApi(req: Request, pathname: string): Promise { if (!title || !title.trim()) return err("Title is required"); const updated = updateIdea( id, + user.id, title.trim(), (description ?? "").toString().trim() ); return updated ? json(updated) : err("Idea not found", 404); } if (method === "DELETE") { - return deleteIdea(id) ? json({ ok: true }) : err("Idea not found", 404); + return deleteIdea(id, user.id) + ? json({ ok: true }) + : err("Idea not found", 404); + } + return err("Method not allowed", 405); + } + + // /api/ideas/:id/visibility { visibility: 'public' | 'private' } + m = pathname.match(/^\/api\/ideas\/(\d+)\/visibility$/); + if (m && method === "PUT") { + const { visibility } = await readJson(req); + if (visibility !== "public" && visibility !== "private") + return err("visibility must be 'public' or 'private'"); + const updated = setVisibility(Number(m[1]), user.id, visibility); + return updated ? json(updated) : err("Idea not found", 404); + } + + // /api/ideas/:id/share POST = enable/rotate, DELETE = disable + m = pathname.match(/^\/api\/ideas\/(\d+)\/share$/); + if (m) { + const id = Number(m[1]); + if (method === "POST") { + const updated = setShareToken(id, user.id, randomToken(16)); + return updated ? json(updated) : err("Idea not found", 404); + } + if (method === "DELETE") { + const updated = setShareToken(id, user.id, null); + return updated ? json(updated) : err("Idea not found", 404); } return err("Method not allowed", 405); } @@ -112,7 +222,7 @@ async function handleApi(req: Request, pathname: string): Promise { m = pathname.match(/^\/api\/ideas\/(\d+)\/notes$/); if (m) { const ideaId = Number(m[1]); - if (!getIdea(ideaId)) return err("Idea not found", 404); + if (!getOwnedIdea(ideaId, user.id)) return err("Idea not found", 404); if (method === "GET") return json(listNotes(ideaId)); if (method === "POST") { const { body } = await readJson(req); @@ -124,15 +234,23 @@ async function handleApi(req: Request, pathname: string): Promise { // /api/notes/:id m = pathname.match(/^\/api\/notes\/(\d+)$/); - if (m) { - const id = Number(m[1]); - if (method === "DELETE") { - return deleteNote(id) ? json({ ok: true }) : err("Note not found", 404); - } - return err("Method not allowed", 405); + if (m && method === "DELETE") { + return deleteNoteOwned(Number(m[1]), user.id) + ? json({ ok: true }) + : err("Note not found", 404); } return err("Not found", 404); } +function safeUser(u: User) { + return { + id: u.id, + name: u.name, + email: u.email, + avatar_url: u.avatar_url, + provider: u.provider, + }; +} + console.log(`✨ Spark Slop running at http://localhost:${server.port}`);