v2
This commit is contained in:
+272
@@ -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<string, string> {
|
||||
const header = req.headers.get("cookie");
|
||||
if (!header) return {};
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string> = {}) {
|
||||
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<Response> {
|
||||
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", " ");
|
||||
}
|
||||
@@ -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<string> {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
+137
-19
@@ -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<any> {
|
||||
try {
|
||||
return await req.json();
|
||||
@@ -31,14 +43,30 @@ async function readJson(req: Request): Promise<any> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<Response> {
|
||||
// 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> | 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<Response> {
|
||||
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<Response> {
|
||||
// /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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
|
||||
// /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}`);
|
||||
|
||||
Reference in New Issue
Block a user