diff --git a/.env.example b/.env.example index 3689356..c62a04c 100644 --- a/.env.example +++ b/.env.example @@ -3,19 +3,13 @@ # Full documentation: README.md → "Environment variables" # ============================================================================= -# --- Site & OAuth ------------------------------------------------------------- -# Production: set to your canonical origin (e.g. https://kaichen.dev) in Vercel. -# Used by Supabase OAuth redirect URLs from the browser client. -NEXT_PUBLIC_SITE_URL= - # --- Supabase ---------------------------------------------------------------- -# Project URL and anon key (safe to expose in the browser bundle). -# Used by getSupabaseAnon(), guestbook POST, gallery reads, /admin client, etc. +# Only used by /api/lastfm/now-playing for optional listening history. +# NEXT_PUBLIC_SUPABASE_URL is paired with the service role key for server-side +# reads/writes against `listening_history` / `listening_stats`. NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= # Server-only: bypasses Row Level Security — NEVER expose to the client. -# Required for /api/lastfm/now-playing DB writes (listening_history / listening_stats). SUPABASE_SERVICE_ROLE_KEY= # --- Last.fm ----------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771cd7d..7bc79f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,6 @@ on: jobs: ci: runs-on: ubuntu-latest - env: - # `next build` prerenders pages that call `createBrowserClient` at module scope (`/admin`, `/gallery`). - # Real secrets are not required for compile; placeholders satisfy the client constructor. - NEXT_PUBLIC_SUPABASE_URL: https://ci-build-placeholder.supabase.co - NEXT_PUBLIC_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiJ9.ci-build-placeholder steps: - uses: actions/checkout@v6 diff --git a/CLAUDE.md b/CLAUDE.md index ca61d93..f3bc460 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,6 @@ Canonical documentation: **[README.md](README.md)** (setup, routes, APIs, env, C | **Client polling** | `app/hooks/use-now-playing.ts` — polls every **10s**; `Cache-Control` on API allows short CDN cache | | **GitHub** | `GET /api/github/contributions` — GraphQL calendar + REST; `GET /api/github/stars` — star counts | | **Weather** | `GET /api/weather` — Open-Meteo, fixed coordinates (Berkeley), revalidated fetch | -| **Gallery** | Supabase Storage + `gallery_photos`; admin at `/admin` (OAuth + allowlisted email in app — **RLS must match in Supabase**) | | **Notes** | MDX under `app/notes/**/page.mdx`; pipeline in `next.config.ts` (Webpack + `@mdx-js/loader`); components in `mdx-components.tsx` and `components/notes/` | | **Observability** | Sentry via `instrumentation*.ts` + `sentry.*.config.ts` (DSN optional); Vercel Analytics / Speed Insights in root layout | | **Theme** | `app/components/theme-provider.tsx` + inline script in `app/layout.tsx` — default **light** when unset | @@ -39,8 +38,8 @@ npm run lint && npm run typecheck && npm run test && npm run build ## Important paths -- [`lib/supabase.ts`](lib/supabase.ts) — `getSupabaseAnon()` lazy singleton - [`lib/now-playing.ts`](lib/now-playing.ts) — types for Last.fm payload +- [`lib/lastfm-now-playing-helpers.ts`](lib/lastfm-now-playing-helpers.ts) — pure helpers used by the now-playing route (tested) - [`app/lib/substack.ts`](app/lib/substack.ts) — RSS fetch + parse (tested) - [`next.config.ts`](next.config.ts) — MDX webpack rule, `withSentryConfig` diff --git a/README.md b/README.md index b465f90..e7d6192 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,11 @@ This repository is a **[Next.js 16](https://nextjs.org/)** application using the The site combines: - A **marketing-style home page** (identity, listening status, weather, **GitHub pinned repositories** via GraphQL with a static fallback, Substack headlines, and links to **news.kaichen.dev** and the **Berkeley library hours** page). -- **Dynamic data** from Last.fm, GitHub, Open-Meteo, optional Supabase-backed gallery and listening history, and **UC Berkeley library hours** scraped from the official hours page (cached, JSON API available). +- **Dynamic data** from Last.fm, GitHub, Open-Meteo, optional Supabase-backed listening history, and **UC Berkeley library hours** scraped from the official hours page (cached, JSON API available). - **MDX-powered notes** under `/notes` with math (KaTeX), GitHub-flavored Markdown, and syntax-highlighted code blocks — multiple course segments (UC Berkeley and SUSTech); see [MDX lecture notes](#mdx-lecture-notes). - **Optional observability** via Sentry (client, server, edge) and Vercel Analytics / Speed Insights. -There is **no** `middleware.ts` in this repo; auth for admin flows uses Supabase OAuth and route handlers under `app/auth/`. +There is **no** `middleware.ts` (or `proxy.ts`) in this repo — every route is publicly accessible and rendered by the App Router directly. --- @@ -160,18 +160,15 @@ kaichen.dev/ │ ├── about/ # Bio / CV-style page + OG │ ├── projects/ # Projects + GitHub heatmap + OG │ ├── notes/ # Notes index, course pages, MDX note routes -│ ├── gallery/ # Public gallery + OG -│ ├── admin/ # Supabase-auth gallery admin; /admin/gallery → redirect /admin -│ ├── api/ # Route handlers (Last.fm, GitHub, weather, guestbook, UCB libraries) +│ ├── api/ # Route handlers (Last.fm, GitHub, weather, UCB libraries) │ ├── berkeley-libraries/ # UC Berkeley library hours (HTML from lib.berkeley.edu) -│ ├── auth/callback/ # Supabase OAuth exchange → redirect │ ├── components/ # UI: nav, cards, theme, weather, listening, GitHub heatmap, … │ ├── hooks/ # e.g. use-now-playing.ts │ └── lib/ # og.tsx, substack RSS, GitHub pinned repos (GraphQL) ├── components/notes/ # MDX shortcodes: Theorem, Proof, Definition, Example, NoteBlock ├── lib/ # Shared server-oriented helpers + Vitest tests -│ ├── supabase.ts # Lazy anon Supabase client (getSupabaseAnon) │ ├── now-playing.ts # Types for Last.fm payload +│ ├── lastfm-now-playing-helpers.ts │ ├── ucb-library-hours.ts # Fetch + parse lib.berkeley.edu/hours (Cheerio) │ ├── weather-open-meteo.ts │ └── *.test.ts @@ -209,7 +206,7 @@ kaichen.dev/ | Content | **MDX** via `@mdx-js/loader` + `remark-gfm`, `remark-math`, `rehype-katex`, `rehype-highlight` | | Scraping | **cheerio** — parses UC Berkeley library hours HTML server-side | | Fonts | `@fontsource/*` (Nunito, Bitter, JetBrains Mono), `geist` (sans/mono CSS variables) | -| Auth / data | Supabase (`@supabase/supabase-js`, `@supabase/ssr`) for OAuth, gallery, optional listening DB writes | +| Data | Supabase (`@supabase/supabase-js`) — optional listening history DB writes (service role) for `/api/lastfm/now-playing` | | Monitoring | `@sentry/nextjs` (optional DSN), Vercel Analytics + Speed Insights | | Testing | Vitest **3** | @@ -227,9 +224,6 @@ Pinned versions are in [`package.json`](package.json). | `/notes` | Index of courses (see [MDX lecture notes](#mdx-lecture-notes)); links to external [SUSTech-Kai-Notes](https://github.com/kaiiiichen/SUSTech-Kai-Notes) for broader collections | | `/notes/...` | Nested segments per course (e.g. `cs61a`, `data100`, `cs217`, `ma121`–`ma337`); individual notes are `page.mdx` | | `/berkeley-libraries` | UC Berkeley library **open/closed** status and hours, parsed from [lib.berkeley.edu/hours](https://www.lib.berkeley.edu/hours); data revalidates every **15 minutes** | -| `/gallery` | Photo grid + lightbox; data from Supabase `gallery_photos` + Storage | -| `/admin` | Google OAuth via Supabase; **restricted to an allowlisted email** in client code — **you must enforce the same rules in Supabase RLS** for production safety | -| `/admin/gallery` | Redirects to `/admin` | **External nav (no in-app route):** the main nav includes **News** → [news.kaichen.dev](https://news.kaichen.dev) and **Blog** → [Substack](https://kaiiiichen.substack.com/); there is no `/blog` or `/news` route in this repo. @@ -248,9 +242,6 @@ All handlers live under `app/api/`. | `GET /api/github/stars?repo=owner/name` | Returns `stargazers_count` and `archived` for a repo | `revalidate = 3600`; optional `GITHUB_TOKEN` for rate limits | | `GET /api/weather` | Open-Meteo forecast for fixed Berkeley coordinates | `fetch` with `next.revalidate = 600` | | `GET /api/ucb-libraries` | Same payload as `/berkeley-libraries`: JSON with `libraries`, `fetchedAt`, `sourceUrl`, or `ok: false` + `error` | Uses `getUCBLibraryHours()`; upstream fetch `revalidate: 900` (15 minutes) | -| `POST /api/guestbook` | JSON body `{ email, message }` → insert into Supabase `guestbook` via anon client | No auth; relies on **Supabase RLS** and sensible limits in the database | - -**Guestbook** is only referenced from API + docs; ensure any front-end or future form respects abuse concerns (rate limits, validation) at the edge or in Supabase policies. **Berkeley libraries:** parsing depends on the HTML structure of lib.berkeley.edu. If the upstream page changes, [`lib/ucb-library-hours.ts`](lib/ucb-library-hours.ts) may need updates (see error responses when zero libraries parse). @@ -264,10 +255,8 @@ Copy [`.env.example`](.env.example) to `.env.local`. **Never commit** real secre | Variable | Role | | --- | --- | -| `NEXT_PUBLIC_SITE_URL` | Canonical site origin; used for Supabase OAuth `redirectTo` (set production URL on Vercel). | -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL. | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase anonymous key (browser + server routes using `getSupabaseAnon()`). | -| `SUPABASE_SERVICE_ROLE_KEY` | **Server-only.** Used by `/api/lastfm/now-playing` for DB writes and any server path that must bypass RLS — keep off the client bundle. | +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL. Paired with `SUPABASE_SERVICE_ROLE_KEY` inside `/api/lastfm/now-playing` for the optional listening history. | +| `SUPABASE_SERVICE_ROLE_KEY` | **Server-only.** Used by `/api/lastfm/now-playing` for DB writes/reads against `listening_history` / `listening_stats` — keep off the client bundle. | | `LASTFM_API_KEY` | Last.fm API. If unset, the now-playing API returns a graceful “not playing” / DB fallback without calling Last.fm. | | `GITHUB_TOKEN` | Fine-grained or classic PAT for GitHub API (contributions + stars + **pinned repos** on the home page). If missing, contribution/stars features may error or return empty data; pinned projects fall back to a **static list** in [`app/lib/github-pinned.ts`](app/lib/github-pinned.ts). | | `GITHUB_LOGIN` | Optional. GitHub username for **pinned repositories** and related API calls (defaults to `kaiiiichen` if unset). Set when forking so the home page shows your pins. | @@ -287,9 +276,9 @@ vercel env pull .env.vercel.check That path is gitignored — do not commit it. -### CI placeholders +### CI -[`.github/workflows/ci.yml`](.github/workflows/ci.yml) sets dummy `NEXT_PUBLIC_SUPABASE_*` values so `next build` can prerender pages that call `createBrowserClient` at module scope (e.g. `/admin`). These are **not** real credentials. +CI does not need any real Supabase keys to build — every Supabase client in this repo is constructed lazily inside a function body, so `next build` succeeds without `NEXT_PUBLIC_SUPABASE_*` set. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml). --- @@ -316,7 +305,7 @@ To add a new course: add a card on the notes index, create `app/notes//pag | **GitHub GraphQL** | Contribution calendar | | **GitHub REST** | Repo stars, commit search | | **Open-Meteo** | Weather (no API key) | -| **Supabase** | Auth, gallery tables + storage, guestbook insert, listening history (optional) | +| **Supabase** | Optional `listening_history` / `listening_stats` writes (service role) for the now-playing route | | **Substack RSS** | Home page “latest posts” (`app/lib/substack.ts`) | | **lib.berkeley.edu** | Library hours HTML (scraped server-side; not an official API) | @@ -326,7 +315,7 @@ To add a new course: add a card on the notes index, create `app/notes//pag - **Node 20**, **npm install** then **`npm run dev`**. - If MDX fails to compile, confirm you did not remove the `--webpack` flag from scripts. -- **Supabase:** for full gallery/admin behavior, configure a project and env vars; for static pages only, you can omit keys where the build allows (see CI placeholders for build-time behavior). +- **Supabase:** only `/api/lastfm/now-playing` uses Supabase (service role) for the optional listening history. The site builds and serves every page without any Supabase env set; the route just falls back to live Last.fm + in-memory cache when the DB is unreachable. ### Common issues @@ -334,7 +323,7 @@ To add a new course: add a card on the notes index, create `app/notes//pag | --- | --- | | MDX differs between dev and prod | Ensure both use Webpack (`--webpack`). | | GitHub widgets empty | `GITHUB_TOKEN` set and not expired; API rate limits. | -| OAuth redirect wrong host | `NEXT_PUBLIC_SITE_URL` and Supabase redirect URLs match Vercel domain. | +| "Recently played" never persists across deploys | `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` set; tables `listening_history` / `listening_stats` exist with the expected columns. | | Sentry noisy locally | DSN unset disables reporting; or lower sample rate in `instrumentation-client.ts`. | --- @@ -348,7 +337,7 @@ npm run test npm run test:watch ``` -There are currently **no** Playwright/E2E tests in this repo; manual browser checks matter for layout and OAuth flows. +There are currently **no** Playwright/E2E tests in this repo; manual browser checks matter for layout and visual polish. --- @@ -397,7 +386,7 @@ to non-merge commits via `git interpret-trailers` (idempotent). Automation that ## Deployment 1. Connect the GitHub repository to **Vercel**. -2. Set environment variables in the Vercel project (production + preview as needed), especially `NEXT_PUBLIC_SITE_URL` and Supabase URLs for OAuth. +2. Set environment variables in the Vercel project (production + preview as needed), especially `GITHUB_TOKEN`, `LASTFM_API_KEY`, and the Supabase keys if you want listening history persistence. 3. Pushes to `main` typically deploy production; preview deployments use PR branches. Manual CLI (after `vercel link`): @@ -435,7 +424,7 @@ Replace at minimum: | Last.fm username | `app/api/lastfm/now-playing/route.ts` | | GitHub login / repos / pins | `app/api/github/contributions/route.ts`, `app/components/project-stars.tsx`, [`app/lib/github-pinned.ts`](app/lib/github-pinned.ts), env `GITHUB_LOGIN` | | Berkeley library page | `lib/ucb-library-hours.ts`, `app/berkeley-libraries/page.tsx`, `app/api/ucb-libraries/route.ts` | -| Supabase project + admin allowlist | `lib/supabase.ts`, `app/admin/page.tsx`, Supabase dashboard (RLS, Storage) | +| Supabase tables | `app/api/lastfm/now-playing/route.ts`, Supabase dashboard (`listening_history`, `listening_stats`) | | Substack feeds | `app/lib/substack.ts` | | Weather location | `app/api/weather/route.ts`, weather UI components | | Theme / fonts | `app/layout.tsx`, `app/globals.css`, `app/components/theme-provider.tsx` | diff --git a/SECURITY.md b/SECURITY.md index 330d4b1..3f5f4bf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ We care about vulnerabilities that affect: - **Production** behavior at `kaichen.dev` (including Vercel-hosted Next.js routes and static assets). - **Repository automation** (GitHub Actions workflows, Dependabot-related automation) when they could lead to secret exfiltration or unauthorized repository changes. -- **User data** processed through **Supabase** (auth sessions, gallery metadata, guestbook rows, listening history) when issues stem from **this application’s** code or documented deployment practices. +- **User data** processed through **Supabase** (listening history) when issues stem from **this application’s** code or documented deployment practices. We do **not** provide a formal bug bounty program. Reports are handled **best-effort**. @@ -64,7 +64,6 @@ Reports may be **declined** or redirected when they concern: - Third-party services’ policies (Last.fm, GitHub, Vercel, Supabase product bugs) — use their official channels. - **Social engineering** or account takeover of maintainer accounts outside this codebase. - **Theoretical** issues without a plausible attack path against deployed configuration. -- Content **spam** on optional features (e.g. guestbook) unless tied to a clear application defect; operational mitigations (rate limits, RLS) may be tracked separately. --- diff --git a/app/admin/gallery/page.tsx b/app/admin/gallery/page.tsx deleted file mode 100644 index 0d38382..0000000 --- a/app/admin/gallery/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function AdminGalleryRedirect() { - redirect("/admin"); -} diff --git a/app/admin/page.tsx b/app/admin/page.tsx deleted file mode 100644 index 1ec447a..0000000 --- a/app/admin/page.tsx +++ /dev/null @@ -1,370 +0,0 @@ -"use client"; - -import { useEffect, useState, useRef } from "react"; -import { createBrowserClient } from "@supabase/ssr"; -import type { User } from "@supabase/supabase-js"; - -const ALLOWED_EMAIL = "kaichen0728@gmail.com"; - -const supabase = createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! -); - -type Photo = { - id: string; - url: string; - description: string | null; - taken_at: string | null; -}; - -function storagePathFromUrl(url: string): string { - // Public URL format: .../storage/v1/object/public/gallery/{path} - const marker = "/gallery/"; - const idx = url.indexOf(marker); - return idx !== -1 ? url.slice(idx + marker.length) : url; -} - -export default function Admin() { - const [user, setUser] = useState(undefined); - const [denied, setDenied] = useState(false); - - // Upload state - const [file, setFile] = useState(null); - const [desc, setDesc] = useState(""); - const [takenAt, setTakenAt] = useState(new Date().toISOString().slice(0, 10)); - const [uploading, setUploading] = useState(false); - const [uploadSuccess, setUploadSuccess] = useState(false); - const [uploadError, setUploadError] = useState(""); - const fileRef = useRef(null); - - // Photos + delete state - const [photos, setPhotos] = useState(null); - const [confirmId, setConfirmId] = useState(null); // inline confirm - const [deleting, setDeleting] = useState(null); - const [deleteError, setDeleteError] = useState(""); - - useEffect(() => { - const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { - const u = session?.user ?? null; - if (u && u.email !== ALLOWED_EMAIL) { - setDenied(true); - setUser(null); - supabase.auth.signOut(); - return; - } - if (!denied) setUser(u ?? null); - }); - return () => subscription.unsubscribe(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Load photos once authed - useEffect(() => { - if (!user) return; - supabase - .from("gallery_photos") - .select("id, url, description, taken_at") - .order("taken_at", { ascending: false }) - .then(({ data }) => setPhotos(data ?? [])); - }, [user]); - - async function signIn() { - const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin; - let nextPath = "/admin"; - const fromQuery = new URLSearchParams(window.location.search).get("next"); - if (fromQuery && fromQuery.startsWith("/") && !fromQuery.startsWith("//")) { - nextPath = fromQuery; - } - const callback = new URL("/auth/callback", siteUrl); - callback.searchParams.set("next", nextPath); - await supabase.auth.signInWithOAuth({ - provider: "google", - options: { redirectTo: callback.toString() }, - }); - } - - async function signOut() { - await supabase.auth.signOut(); - setUser(null); - } - - async function handleUpload(e: React.FormEvent) { - e.preventDefault(); - if (!file) return; - setUploading(true); - setUploadError(""); - setUploadSuccess(false); - - try { - const ext = file.name.split(".").pop() ?? "jpg"; - const path = `${crypto.randomUUID()}.${ext}`; - - const { error: uploadErr } = await supabase.storage - .from("gallery") - .upload(path, file, { contentType: file.type, upsert: false }); - - if (uploadErr) throw new Error(uploadErr.message); - - const { data: urlData } = supabase.storage.from("gallery").getPublicUrl(path); - - const { data: inserted, error: insertErr } = await supabase - .from("gallery_photos") - .insert({ - url: urlData.publicUrl, - description: desc.trim() || null, - taken_at: takenAt || null, - }) - .select("id, url, description, taken_at") - .single(); - - if (insertErr) throw new Error(insertErr.message); - - setUploadSuccess(true); - setFile(null); - setDesc(""); - setTakenAt(new Date().toISOString().slice(0, 10)); - if (fileRef.current) fileRef.current.value = ""; - if (inserted) setPhotos((prev) => [inserted, ...(prev ?? [])]); - } catch (err: unknown) { - setUploadError(err instanceof Error ? err.message : "Upload failed"); - } finally { - setUploading(false); - } - } - - async function handleDelete(photo: Photo) { - setDeleting(photo.id); - setDeleteError(""); - setConfirmId(null); - - try { - const storagePath = storagePathFromUrl(photo.url); - - const { error: storageErr } = await supabase.storage - .from("gallery") - .remove([storagePath]); - - if (storageErr) throw new Error(`Storage: ${storageErr.message}`); - - const { error: dbErr } = await supabase - .from("gallery_photos") - .delete() - .eq("id", photo.id); - - if (dbErr) throw new Error(`DB: ${dbErr.message}`); - - setPhotos((prev) => (prev ?? []).filter((p) => p.id !== photo.id)); - } catch (err: unknown) { - setDeleteError(err instanceof Error ? err.message : "Delete failed"); - } finally { - setDeleting(null); - } - } - - // ── Auth screens ──────────────────────────────────────────────── - - if (user === undefined) { - return ( -
-

Loading…

-
- ); - } - - if (denied) { - return ( -
-

Admin

-

Access denied.

-
- ); - } - - if (!user) { - return ( -
-

Admin

-

Sign in to manage the site.

- -
- ); - } - - // ── Authed ─────────────────────────────────────────────────────── - - return ( -
- - {/* Header */} -
-

- Admin -

- -
- - {/* ── Upload ─────────────────────────────────────────────── */} -
-
Gallery · Upload
- -
-
- - setFile(e.target.files?.[0] ?? null)} - style={{ fontFamily: "'Nunito'", fontSize: 13 }} - className="w-full text-zinc-600 dark:text-zinc-400 file:mr-3 file:py-1 file:px-3 file:rounded-sm file:border-0 file:text-xs file:font-medium file:bg-zinc-100 dark:file:bg-zinc-800 file:text-zinc-600 dark:file:text-zinc-400 hover:file:bg-zinc-200 dark:hover:file:bg-zinc-700 transition-colors" - /> -
- -
- -