diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..998a9d0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,149 @@ +# Nuto Repository Context + +## Project Overview + +Nuto is a URL shortener that runs entirely on Cloudflare Workers. It's a React Router v7 app in framework mode with SSR enabled: loaders and actions are the backend, Clerk handles auth, D1 provides persistence, and KV acts as the redirect cache. + +**Live site:** + +It's not a standalone API and has no separate server. All backend logic lives inside the React Router routes, and it should stay that way. + +## Tech Stack + +- **Framework:** React Router v7 (framework mode, `ssr: true`) +- **Auth:** Clerk (`@clerk/react-router`) +- **Database:** Cloudflare D1 +- **Cache:** Cloudflare KV (`URL_STORE`, the redirect cache) +- **UI:** Tailwind CSS with shadcn/ui (Radix) +- **Language:** TypeScript in strict mode +- **Testing:** Vitest with `@cloudflare/vitest-pool-workers` (real D1 and KV via miniflare) +- **Package manager:** pnpm (CI uses 10.14.0, there's a `pnpm-lock.yaml`). Never npm or yarn. + +## Project Structure + +```text +app/ # React Router v7 app (root, entry.server, css) + routes/ # Routes with loaders/actions (the project's real backend) + __tests__/ # Route integration tests (vitest-pool-workers + real D1/KV) + repository/ # Data-access layer (urls, clicks, users, KV redirect cache) + __tests__/ # Repository tests against real D1/KV +components/ # React components (landing, dashboard, links) + ui/ # shadcn/ui primitives (Radix) + links/ # Link management UI (create/edit/delete/sort) + icons/ # Brand logos +lib/ # cn() helper (tailwind-merge + clsx) +utils/ # Shared utilities (AES-GCM crypto, cycle detection, + __tests__/ # short code validation, link limits) + unit tests +workers/ # Worker entry point (workers/app.ts: fetch + scheduled) +types/ # Cloudflare Env type augmentation +public/ # Static assets +.github/workflows/ # CI (eslint + typecheck + vitest; deploy on push to main) +schema.sql # D1 schema (users, urls, clicks, settings, user_limits + # + trigger that enforces per-user link limits) +``` + +## Key Commands + +### Development + +```bash +pnpm dev # Start dev server (React Router + Cloudflare Vite plugin) +pnpm build # Production build (react-router build + wrangler build) +pnpm preview # Build and preview the production bundle +pnpm deploy # Build + wrangler deploy +``` + +### Code quality + +```bash +pnpm run typecheck # wrangler types + react-router typegen + tsc -b +pnpm exec eslint . # Lint (CI runs this on fresh checkouts) +``` + +### Testing + +```bash +pnpm vitest run # Full test suite (this is what CI runs) +pnpm test # Same suite through vitest directly +``` + +Route and repository tests run against real D1 and KV bindings via miniflare, applying `schema.sql?raw` to a fresh database in `beforeAll`. + +## Git Workflow + +Feature branches live in `dev`. `main` is the production branch and CI deploys to Cloudflare Workers on every push to it. + +Standing rules: + +- One branch per task, named by purpose: `fix/`, `refactor/`, `test/`, `docs/`. +- Base feature branches on `dev` (or on another feature branch when tasks build on each other; review stacked branches bottom-up). +- Small, atomic commits. Each commit is one related set of changes with a message that says what changed and why. +- Run the full test suite before every commit, not just at the end. +- Never open a PR, merge, or deploy anything without explicit approval. +- Never commit secrets. `.dev.vars` and `.env` are gitignored; keep it that way. + +## Environment Setup + +Local secrets live in `.dev.vars` (gitignored): + +- `CLERK_SECRET_KEY` — Clerk backend API key +- `CLERK_WEBHOOK_SECRET` — svix secret used to verify Clerk webhooks +- `PASSCODE_ENC_KEY` — base64 32-byte key for AES-GCM link-password encryption + +Non-secret vars (`VITE_CLERK_PUBLISHABLE_KEY`, Clerk redirect URLs, `MAX_LINKS_PER_USER`) are defined in `wrangler.jsonc` under `vars`. + +`GITHUB_CLIENT_*` and `GOOGLE_CLIENT_*` appear in the Env type augmentation but are not referenced by any source file. + +## Backend conventions + +- All backend logic lives in React Router loaders and actions. Don't add a separate server framework (Express, Fastify, etc.). +- All data access goes through `app/repository/`. Never call `context.cloudflare.env.DB.prepare()` (or `URL_STORE`) directly from a loader or action. +- `workers/app.ts` stays a thin entry point: `fetch` delegates to the React Router request handler, and `scheduled` only runs the expired-links cleanup. No business logic there. +- The redirect flow is KV first, D1 on miss. Expired entries get deleted and return 410; KV hits are repopulated with a 30-day TTL. +- Every query uses `.bind()` parameters. Never concatenate user input into a SQL string. + +## Security and privacy + +- The `clicks` table stores only `url_id`, `clicked_at`, `country`, and `user_agent`. Never add IP storage to click analytics. +- Treat any webhook payload as untrusted until verified with svix. Verification happens before the first DB write in `app/routes/webhooks.clerk.tsx`; keep that ordering. +- Never log or expose env secrets (`PASSCODE_ENC_KEY`, `CLERK_WEBHOOK_SECRET`, etc.). +- Never change the `wrangler.jsonc` bindings (D1 `DB`, KV `URL_STORE`) without confirmation. Production data is tied to those bindings. + +## Known issues (discuss before changing) + +- Link password hashing is unsalted SHA-256 (`hashPassword` in api.links.tsx, verification in redirect.tsx). Hardening is on hold for compatibility with already-saved passwords. The hash is also stored in KV (`storedHash`), and its first 16 chars are the verifier for the `pw_` cookie. +- The dashboard route in `app/routes/dashboard.tsx` has a dead action (no form posts to `/dashboard`; the UI posts to `/api/links`). If invoked, it would insert links with zero validation and store the password in plaintext (unhashed). +- The `user.updated` handler in `app/routes/webhooks.clerk.tsx` binds `undefined` for `subscription_plan`. D1 rejects `undefined` in bind, so the event likely fails at runtime with a 500. +- The dashboard loader decrypts `password_enc` and returns the plaintext passwords to the client in the loader data (so users can see them). This is the current design, but it's worth confirming it's intentional. +- The update path in api.links.tsx (handleUpdate) writes KV entries without `storedHash`, while the redirect loader's password wall requires `hasPassword && storedHash`. After editing a protected link, KV hits skip the password wall and redirect without a password until the entry expires or gets repopulated from a DB fallback. +- The GET loader of `/api/links` (`?id=...`) is unused by the UI (the dashboard uses its own loader; the UI only submits POST/PUT/DELETE forms), and its response includes the stored SHA-256 `password` hash. Worth deciding whether to remove the endpoint or drop the hash from the response. +- `app/routes/webhooks.clerk.tsx` logs user emails and IDs via `console.log`, and Workers observability is enabled, so PII persists in logs. + +## Important files + +| File | Purpose | +| --- | --- | +| `wrangler.jsonc` | Worker config: D1 (`DB`), KV (`URL_STORE`), cron, non-secret vars. Production bindings live here. | +| `schema.sql` | D1 schema and the link-limit trigger | +| `app/routes/` | All backend logic (loaders/actions) | +| `app/repository/` | Data-access layer for D1 and KV | +| `utils/cycle-detection.ts` | Shared redirect-loop detection used by api.links and redirect | +| `utils/crypto.ts` | AES-GCM encrypt/decrypt for link passwords | +| `workers/app.ts` | Thin Worker entry point (fetch + scheduled cleanup) | +| `react-router.config.ts` | Enables SSR | +| `vitest.config.ts` | Cloudflare test pool with real D1/KV bindings | + +## Build and deploy + +1. `pnpm build` runs `react-router build` and `wrangler build`. +2. `pnpm deploy` builds and deploys via Wrangler. +3. CI (`.github/workflows/ci.yml`) runs eslint, typecheck, and `pnpm vitest run` on every push/PR to `main` and `dev`, then deploys to Workers on pushes to `main`. +4. The `scheduled` handler runs on the `0 0 * * *` cron and deletes expired links. + +## Notes for AI assistants + +- `worker-configuration.d.ts` is generated by `wrangler types`. Running typecheck regenerates it and usually leaves local diff noise from wrangler version drift; discard that diff unless the update is intentional. +- `dist/`, `build/`, and `.react-router/` are build artifacts. The eslint config doesn't ignore them, so a local `eslint .` lints them and reports thousands of pre-existing problems; lint the changed source files instead. +- Don't switch the meta-framework, auth provider, or data stores: React Router v7, Clerk, D1, and KV are fixed choices. +- Use pnpm for everything that needs a package manager. +- TypeScript strict mode is on across all tsconfig projects; keep it that way. diff --git a/app/repository/__tests__/clicks.test.ts b/app/repository/__tests__/clicks.test.ts new file mode 100644 index 0000000..70dc0cb --- /dev/null +++ b/app/repository/__tests__/clicks.test.ts @@ -0,0 +1,95 @@ +/// +import { env } from "cloudflare:test"; +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import schema from "../../../schema.sql?raw"; +import { insertClick, updateLastClicked } from "../clicks"; +import { insertLink } from "../urls"; + +describe("clicks repository", () => { + beforeAll(async () => { + const cleanSchema = schema + .replace(/--.*/g, "") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/PRAGMA[^;]*;/gi, "") + .replace(/\s+/g, " ") + .trim(); + + await env.DB.exec(cleanSchema); + }); + + beforeEach(async () => { + await env.DB.prepare("DELETE FROM urls").run(); + await env.DB.prepare("DELETE FROM users").run(); + await env.DB.prepare( + `INSERT INTO users (id, email, subscription_plan, created_at) + VALUES ('user_1', 'owner@nuto.com', 'free', datetime('now'))`, + ).run(); + }); + + it("insertClick stores only the privacy-safe fields (no IP)", async () => { + await insertLink(env.DB, { + id: "track-1", + longUrl: "https://example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }); + + await insertClick(env.DB, { + urlId: "track-1", + country: "MX", + userAgent: "UA/1", + }); + await insertClick(env.DB, { + urlId: "track-1", + country: null, + userAgent: "UA/2", + }); + + const rows = (await env.DB + .prepare("SELECT * FROM clicks WHERE url_id = 'track-1'") + .all()) as unknown as { + results: Array>; + }; + + expect(rows.results).toHaveLength(2); + for (const row of rows.results) { + expect(Object.keys(row).sort()).toEqual([ + "clicked_at", + "country", + "id", + "url_id", + "user_agent", + ]); + } + expect(rows.results[0]).toMatchObject({ + url_id: "track-1", + country: "MX", + user_agent: "UA/1", + }); + expect(rows.results[1]).toMatchObject({ + url_id: "track-1", + country: null, + user_agent: "UA/2", + }); + }); + + it("updateLastClicked stamps the link's last_clicked", async () => { + await insertLink(env.DB, { + id: "stamp-1", + longUrl: "https://example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }); + + await updateLastClicked(env.DB, "stamp-1"); + + const row = await env.DB + .prepare("SELECT last_clicked FROM urls WHERE id = 'stamp-1'") + .first<{ last_clicked: string | null }>(); + expect(row?.last_clicked).not.toBeNull(); + }); +}); diff --git a/app/repository/__tests__/env.d.ts b/app/repository/__tests__/env.d.ts new file mode 100644 index 0000000..1d18099 --- /dev/null +++ b/app/repository/__tests__/env.d.ts @@ -0,0 +1,8 @@ +/// +/// + +declare module "cloudflare:test" { + export const env: Env; + export function createExecutionContext(): ExecutionContext; + export function waitOnExecutionContext(ctx: ExecutionContext): Promise; +} diff --git a/app/repository/__tests__/url-cache.test.ts b/app/repository/__tests__/url-cache.test.ts new file mode 100644 index 0000000..7a74b16 --- /dev/null +++ b/app/repository/__tests__/url-cache.test.ts @@ -0,0 +1,67 @@ +/// +import { env } from "cloudflare:test"; +import { describe, it, expect, beforeEach } from "vitest"; +import { + getRedirectEntry, + putRedirectEntry, + deleteRedirectEntry, +} from "../url-cache"; + +describe("url-cache repository", () => { + beforeEach(async () => { + await env.URL_STORE.delete("cache-slug"); + await env.URL_STORE.delete("legacy-slug"); + }); + + it("putRedirectEntry and getRedirectEntry round-trip an entry with storedHash", async () => { + await putRedirectEntry(env.URL_STORE, "cache-slug", { + longUrl: "https://example.com", + hasPassword: true, + storedHash: "abc123", + }); + + const entry = await getRedirectEntry(env.URL_STORE, "cache-slug"); + expect(entry).toEqual({ + longUrl: "https://example.com", + hasPassword: true, + storedHash: "abc123", + }); + }); + + it("getRedirectEntry returns null on a cache miss", async () => { + expect(await getRedirectEntry(env.URL_STORE, "missing")).toBeNull(); + }); + + it("getRedirectEntry falls back to the raw string for legacy non-JSON values", async () => { + await env.URL_STORE.put("legacy-slug", "https://legacy.example.com"); + + const entry = await getRedirectEntry(env.URL_STORE, "legacy-slug"); + expect(entry).toEqual({ + longUrl: "https://legacy.example.com", + hasPassword: false, + storedHash: null, + }); + }); + + it("getRedirectEntry falls back to the raw string for JSON without longUrl", async () => { + await env.URL_STORE.put("legacy-slug", '{"unexpected":"shape"}'); + + const entry = await getRedirectEntry(env.URL_STORE, "legacy-slug"); + expect(entry).toEqual({ + longUrl: '{"unexpected":"shape"}', + hasPassword: false, + storedHash: null, + }); + }); + + it("deleteRedirectEntry removes the cached entry", async () => { + await putRedirectEntry(env.URL_STORE, "cache-slug", { + longUrl: "https://example.com", + hasPassword: false, + }); + + await deleteRedirectEntry(env.URL_STORE, "cache-slug"); + + expect(await getRedirectEntry(env.URL_STORE, "cache-slug")).toBeNull(); + }); +}); diff --git a/app/repository/__tests__/urls.test.ts b/app/repository/__tests__/urls.test.ts new file mode 100644 index 0000000..baafb18 --- /dev/null +++ b/app/repository/__tests__/urls.test.ts @@ -0,0 +1,254 @@ +/// +import { env } from "cloudflare:test"; +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import schema from "../../../schema.sql?raw"; +import { + existsById, + getLongUrlById, + getRedirectTargetById, + getRedirectWithExpiryById, + findOwnedById, + getLinkDetailsByIdAndUser, + listActiveLinksForUser, + insertLink, + insertLinkWithCreatedAt, + renameLink, + updateLinkById, + deleteLinkById, +} from "../urls"; + +describe("urls repository", () => { + beforeAll(async () => { + const cleanSchema = schema + .replace(/--.*/g, "") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/PRAGMA[^;]*;/gi, "") + .replace(/\s+/g, " ") + .trim(); + + await env.DB.exec(cleanSchema); + }); + + beforeEach(async () => { + await env.DB.prepare("DELETE FROM urls").run(); + await env.DB.prepare("DELETE FROM users").run(); + await env.DB.prepare( + `INSERT INTO users (id, email, subscription_plan, created_at) + VALUES ('user_1', 'owner@nuto.com', 'free', datetime('now'))`, + ).run(); + await env.DB.prepare( + `INSERT INTO users (id, email, subscription_plan, created_at) + VALUES ('user_2', 'other@nuto.com', 'free', datetime('now'))`, + ).run(); + }); + + it("insertLink persists a link that existsById and getLongUrlById find", async () => { + await insertLink(env.DB, { + id: "code-1", + longUrl: "https://example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }); + + expect(await existsById(env.DB, "code-1")).toBe(true); + expect(await existsById(env.DB, "missing")).toBe(false); + expect(await getLongUrlById(env.DB, "code-1")).toBe( + "https://example.com", + ); + expect(await getLongUrlById(env.DB, "missing")).toBeNull(); + }); + + it("getRedirectTargetById returns the stored hash for protected links", async () => { + await insertLink(env.DB, { + id: "locked", + longUrl: "https://secure.example.com", + userId: "user_1", + expiresAt: null, + password: "abc123def456", + passwordEnc: "iv.ct", + }); + + const target = await getRedirectTargetById(env.DB, "locked"); + expect(target).toEqual({ + long_url: "https://secure.example.com", + password: "abc123def456", + }); + + const open = await insertLink(env.DB, { + id: "open", + longUrl: "https://open.example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }).then(() => getRedirectTargetById(env.DB, "open")); + expect(open).toEqual({ + long_url: "https://open.example.com", + password: null, + }); + }); + + it("getRedirectWithExpiryById returns the stored expiry", async () => { + await insertLink(env.DB, { + id: "timed", + longUrl: "https://example.com", + userId: "user_1", + expiresAt: "2030-01-01T00:00:00.000Z", + password: null, + passwordEnc: null, + }); + + const record = await getRedirectWithExpiryById(env.DB, "timed"); + expect(record?.long_url).toBe("https://example.com"); + expect(record?.expires_at).toBe("2030-01-01T00:00:00.000Z"); + expect(record?.password).toBeNull(); + }); + + it("findOwnedById only matches rows owned by the given user", async () => { + await insertLink(env.DB, { + id: "mine", + longUrl: "https://example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }); + + expect(await findOwnedById(env.DB, "mine", "user_1")).not.toBeNull(); + expect(await findOwnedById(env.DB, "mine", "user_2")).toBeNull(); + expect(await findOwnedById(env.DB, "missing", "user_1")).toBeNull(); + }); + + it("getLinkDetailsByIdAndUser counts clicks and enforces ownership", async () => { + await insertLink(env.DB, { + id: "popular", + longUrl: "https://example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }); + await env.DB + .prepare( + "INSERT INTO clicks (url_id, clicked_at, country, user_agent) VALUES ('popular', datetime('now'), NULL, 'UA/1')", + ) + .run(); + await env.DB + .prepare( + "INSERT INTO clicks (url_id, clicked_at, country, user_agent) VALUES ('popular', datetime('now'), NULL, 'UA/2')", + ) + .run(); + + const details = await getLinkDetailsByIdAndUser(env.DB, "popular", "user_1"); + expect(details).toMatchObject({ + shortCode: "popular", + longUrl: "https://example.com", + clicks: 2, + }); + expect(await getLinkDetailsByIdAndUser(env.DB, "popular", "user_2")).toBeNull(); + }); + + it("listActiveLinksForUser returns only the user's unexpired links", async () => { + await insertLink(env.DB, { + id: "active", + longUrl: "https://active.example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }); + await insertLink(env.DB, { + id: "expired", + longUrl: "https://expired.example.com", + userId: "user_1", + expiresAt: "2020-01-01T00:00:00.000Z", + password: null, + passwordEnc: null, + }); + await insertLink(env.DB, { + id: "foreign", + longUrl: "https://foreign.example.com", + userId: "user_2", + expiresAt: null, + password: null, + passwordEnc: null, + }); + + const rows = await listActiveLinksForUser(env.DB, "user_1"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + shortCode: "active", + longUrl: "https://active.example.com", + }); + }); + + it("insertLinkWithCreatedAt and deleteLinkById create and remove rows", async () => { + await insertLinkWithCreatedAt(env.DB, { + id: "dash", + longUrl: "https://example.com", + userId: "user_1", + createdAt: "2024-05-01T00:00:00.000Z", + password: null, + }); + + expect(await existsById(env.DB, "dash")).toBe(true); + + await deleteLinkById(env.DB, "dash"); + expect(await existsById(env.DB, "dash")).toBe(false); + }); + + it("renameLink moves the row to the new short code", async () => { + await insertLink(env.DB, { + id: "old-code", + longUrl: "https://example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }); + + await renameLink(env.DB, { + newId: "new-code", + longUrl: "https://renamed.example.com", + password: null, + passwordEnc: null, + updatedAt: new Date().toISOString(), + expiresAt: null, + currentId: "old-code", + userId: "user_1", + }); + + expect(await getLongUrlById(env.DB, "old-code")).toBeNull(); + expect(await getLongUrlById(env.DB, "new-code")).toBe( + "https://renamed.example.com", + ); + }); + + it("updateLinkById changes the stored fields for the same short code", async () => { + await insertLink(env.DB, { + id: "same-code", + longUrl: "https://before.example.com", + userId: "user_1", + expiresAt: null, + password: null, + passwordEnc: null, + }); + + await updateLinkById(env.DB, { + longUrl: "https://after.example.com", + password: "deadbeef", + passwordEnc: null, + updatedAt: new Date().toISOString(), + expiresAt: null, + id: "same-code", + }); + + const target = await getRedirectTargetById(env.DB, "same-code"); + expect(target).toEqual({ + long_url: "https://after.example.com", + password: "deadbeef", + }); + }); +}); diff --git a/app/repository/__tests__/users.test.ts b/app/repository/__tests__/users.test.ts new file mode 100644 index 0000000..dd337ae --- /dev/null +++ b/app/repository/__tests__/users.test.ts @@ -0,0 +1,97 @@ +/// +import { env } from "cloudflare:test"; +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import schema from "../../../schema.sql?raw"; +import { insertUser, updateUser, deleteUserById } from "../users"; + +describe("users repository", () => { + beforeAll(async () => { + const cleanSchema = schema + .replace(/--.*/g, "") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/PRAGMA[^;]*;/gi, "") + .replace(/\s+/g, " ") + .trim(); + + await env.DB.exec(cleanSchema); + }); + + beforeEach(async () => { + await env.DB.prepare("DELETE FROM users").run(); + }); + + it("insertUser stores the Clerk user with the default plan", async () => { + await insertUser(env.DB, { + id: "user_new", + email: "new@nuto.com", + subscriptionPlan: "FREE", + createdAt: "2024-05-01T00:00:00.000Z", + }); + + const row = await env.DB + .prepare( + "SELECT id, email, subscription_plan, created_at FROM users WHERE id = 'user_new'", + ) + .first<{ + id: string; + email: string; + subscription_plan: string; + created_at: string; + }>(); + + expect(row).toEqual({ + id: "user_new", + email: "new@nuto.com", + subscription_plan: "FREE", + created_at: "2024-05-01T00:00:00.000Z", + }); + }); + + it("updateUser updates the stored profile fields", async () => { + await insertUser(env.DB, { + id: "user_upd", + email: "before@nuto.com", + subscriptionPlan: "FREE", + createdAt: "2024-05-01T00:00:00.000Z", + }); + + await updateUser(env.DB, { + id: "user_upd", + email: "after@nuto.com", + subscriptionPlan: "PRO", + createdAt: "2024-06-01T00:00:00.000Z", + }); + + const row = await env.DB + .prepare( + "SELECT email, subscription_plan, created_at FROM users WHERE id = 'user_upd'", + ) + .first<{ + email: string; + subscription_plan: string; + created_at: string; + }>(); + + expect(row).toEqual({ + email: "after@nuto.com", + subscription_plan: "PRO", + created_at: "2024-06-01T00:00:00.000Z", + }); + }); + + it("deleteUserById removes the row", async () => { + await insertUser(env.DB, { + id: "user_del", + email: "del@nuto.com", + subscriptionPlan: "FREE", + createdAt: "2024-05-01T00:00:00.000Z", + }); + + await deleteUserById(env.DB, "user_del"); + + const row = await env.DB + .prepare("SELECT id FROM users WHERE id = 'user_del'") + .first(); + expect(row).toBeNull(); + }); +}); diff --git a/app/repository/clicks.ts b/app/repository/clicks.ts new file mode 100644 index 0000000..7b58af9 --- /dev/null +++ b/app/repository/clicks.ts @@ -0,0 +1,21 @@ +export async function insertClick( + db: D1Database, + click: { urlId: string; country: string | null; userAgent: string }, +): Promise { + await db + .prepare( + "INSERT INTO clicks (url_id, clicked_at, country, user_agent) VALUES (?, datetime('now'), ?, ?)", + ) + .bind(click.urlId, click.country, click.userAgent) + .run(); +} + +export async function updateLastClicked( + db: D1Database, + urlId: string, +): Promise { + await db + .prepare("UPDATE urls SET last_clicked = datetime('now') WHERE id = ?") + .bind(urlId) + .run(); +} diff --git a/app/repository/url-cache.ts b/app/repository/url-cache.ts new file mode 100644 index 0000000..2726018 --- /dev/null +++ b/app/repository/url-cache.ts @@ -0,0 +1,47 @@ +export interface RedirectCacheEntry { + longUrl: string; + hasPassword: boolean; + storedHash?: string | null; +} + +export interface RedirectCacheWriteOptions { + expiration?: number; + expirationTtl?: number; +} + +export async function getRedirectEntry( + kv: KVNamespace, + slug: string, +): Promise { + const raw = await kv.get(slug); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && "longUrl" in parsed) { + return { + longUrl: parsed.longUrl, + hasPassword: !!parsed.hasPassword, + storedHash: parsed.storedHash || null, + }; + } + } catch { + // fall through and treat the raw value as the long URL + } + return { longUrl: raw, hasPassword: false, storedHash: null }; +} + +export async function putRedirectEntry( + kv: KVNamespace, + slug: string, + entry: RedirectCacheEntry, + options?: RedirectCacheWriteOptions, +): Promise { + await kv.put(slug, JSON.stringify(entry), options); +} + +export async function deleteRedirectEntry( + kv: KVNamespace, + slug: string, +): Promise { + await kv.delete(slug); +} diff --git a/app/repository/urls.ts b/app/repository/urls.ts new file mode 100644 index 0000000..2ceff75 --- /dev/null +++ b/app/repository/urls.ts @@ -0,0 +1,235 @@ +export interface LinkRow { + id: string; + long_url: string; + user_id: string; + created_at: string; + updated_at: string | null; + expires_at: string | null; + password: string | null; + password_enc: string | null; + last_clicked: string | null; +} + +export async function existsById( + db: D1Database, + id: string, +): Promise { + const row = await db + .prepare("SELECT 1 FROM urls WHERE id = ? LIMIT 1") + .bind(id) + .first(); + return row !== null; +} + +export async function getLongUrlById( + db: D1Database, + id: string, +): Promise { + const row = await db + .prepare("SELECT long_url FROM urls WHERE id = ?") + .bind(id) + .first<{ long_url: string }>(); + return row?.long_url ?? null; +} + +export async function getRedirectTargetById( + db: D1Database, + id: string, +): Promise<{ long_url: string; password: string | null } | null> { + return db + .prepare("SELECT long_url, password FROM urls WHERE id = ?") + .bind(id) + .first<{ long_url: string; password: string | null }>(); +} + +export async function getRedirectWithExpiryById( + db: D1Database, + id: string, +): Promise<{ + long_url: string; + password: string | null; + expires_at: string | null; +} | null> { + return db + .prepare("SELECT long_url, password, expires_at FROM urls WHERE id = ?") + .bind(id) + .first<{ long_url: string; password: string | null; expires_at: string | null }>(); +} + +export async function findOwnedById( + db: D1Database, + id: string, + userId: string, +): Promise { + return db + .prepare("SELECT * FROM urls WHERE id = ? AND user_id = ?") + .bind(id, userId) + .first(); +} + +export interface LinkDetails { + shortCode: string; + longUrl: string; + createdAt: string; + expiresAt: string | null; + password: string | null; + clicks: number; +} + +export async function getLinkDetailsByIdAndUser( + db: D1Database, + shortCode: string, + userId: string, +): Promise { + return db + .prepare( + `SELECT + urls.id as shortCode, + urls.long_url as longUrl, + urls.created_at as createdAt, + urls.expires_at as expiresAt, + urls.password, + COUNT(clicks.id) as clicks + FROM urls + LEFT JOIN clicks ON urls.id = clicks.url_id + WHERE urls.id = ? AND urls.user_id = ? + GROUP BY urls.id`, + ) + .bind(shortCode, userId) + .first(); +} + +export interface DashboardLinkRow { + shortCode: string; + longUrl: string; + createdAt: string; + expiresAt: string | null; + passwordEnc: string | null; + clicks: number; + lastClicked: string | null; +} + +export async function listActiveLinksForUser( + db: D1Database, + userId: string, +): Promise { + const result = await db + .prepare( + `SELECT + urls.id AS shortCode, + urls.long_url AS longUrl, + urls.created_at AS createdAt, + urls.expires_at AS expiresAt, + urls.password_enc AS passwordEnc, + COUNT(clicks.id) AS clicks, + urls.last_clicked AS lastClicked + FROM urls + LEFT JOIN clicks ON urls.id = clicks.url_id + WHERE urls.user_id = ? AND (urls.expires_at IS NULL OR urls.expires_at > datetime('now')) + GROUP BY urls.id + ORDER BY COALESCE(urls.last_clicked, urls.created_at) DESC`, + ) + .bind(userId) + .all(); + return result.results || []; +} + +export interface NewLink { + id: string; + longUrl: string; + userId: string; + expiresAt: string | null; + password: string | null; + passwordEnc: string | null; +} + +export async function insertLink(db: D1Database, link: NewLink): Promise { + await db + .prepare( + `INSERT INTO urls (id, long_url, user_id, created_at, expires_at, password, password_enc) + VALUES (?, ?, ?, datetime('now'), ?, ?, ?)`, + ) + .bind(link.id, link.longUrl, link.userId, link.expiresAt, link.password, link.passwordEnc) + .run(); +} + +export async function insertLinkWithCreatedAt( + db: D1Database, + link: { + id: string; + longUrl: string; + userId: string; + createdAt: string; + password: string | null; + }, +): Promise { + await db + .prepare( + "INSERT INTO urls (id, long_url, user_id, created_at, password) VALUES (?, ?, ?, ?, ?)", + ) + .bind(link.id, link.longUrl, link.userId, link.createdAt, link.password) + .run(); +} + +export async function renameLink( + db: D1Database, + link: { + newId: string; + longUrl: string; + password: string | null; + passwordEnc: string | null; + updatedAt: string; + expiresAt: string | null; + currentId: string; + userId: string; + }, +): Promise { + await db + .prepare( + "UPDATE urls SET id = ?, long_url = ?, password = ?, password_enc = ?, updated_at = ?, expires_at = ? WHERE id = ? AND user_id = ?", + ) + .bind( + link.newId, + link.longUrl, + link.password, + link.passwordEnc, + link.updatedAt, + link.expiresAt, + link.currentId, + link.userId, + ) + .run(); +} + +export async function updateLinkById( + db: D1Database, + link: { + longUrl: string; + password: string | null; + passwordEnc: string | null; + updatedAt: string; + expiresAt: string | null; + id: string; + }, +): Promise { + await db + .prepare( + "UPDATE urls SET long_url = ?, password = ?, password_enc = ?, updated_at = ?, expires_at = ? WHERE id = ?", + ) + .bind( + link.longUrl, + link.password, + link.passwordEnc, + link.updatedAt, + link.expiresAt, + link.id, + ) + .run(); +} + +export async function deleteLinkById(db: D1Database, id: string): Promise { + await db + .prepare("DELETE FROM urls WHERE id = ?") + .bind(id) + .run(); +} diff --git a/app/repository/users.ts b/app/repository/users.ts new file mode 100644 index 0000000..a73e845 --- /dev/null +++ b/app/repository/users.ts @@ -0,0 +1,40 @@ +export async function insertUser( + db: D1Database, + user: { + id: string; + email: string; + subscriptionPlan: string; + createdAt: string; + }, +): Promise { + await db + .prepare( + "INSERT INTO users (id, email, subscription_plan, created_at) VALUES (?, ?, ?, ?)", + ) + .bind(user.id, user.email, user.subscriptionPlan, user.createdAt) + .run(); +} + +export async function updateUser( + db: D1Database, + user: { + id: string; + email: string; + subscriptionPlan: string | undefined; + createdAt: string; + }, +): Promise { + await db + .prepare( + "UPDATE users SET email = ?, subscription_plan = ?, created_at = ? WHERE id = ?", + ) + .bind(user.email, user.subscriptionPlan, user.createdAt, user.id) + .run(); +} + +export async function deleteUserById(db: D1Database, id: string): Promise { + await db + .prepare("DELETE FROM users WHERE id = ?") + .bind(id) + .run(); +} diff --git a/app/routes/api.links.tsx b/app/routes/api.links.tsx index 0663016..dc9ad29 100644 --- a/app/routes/api.links.tsx +++ b/app/routes/api.links.tsx @@ -5,6 +5,22 @@ import { encryptPassword } from "@/utils/crypto"; import { validateShortCode } from "@/utils/validate-short-code"; import { enforceUrlLimit } from "@/utils/enforce-link-limit"; import { isSelfReferential } from "@utils/is-self-referencial"; +import { createsCycle } from "@utils/cycle-detection"; +import { + existsById, + getLongUrlById, + getLinkDetailsByIdAndUser, + findOwnedById, + insertLink, + renameLink, + updateLinkById, + deleteLinkById, +} from "../repository/urls"; +import { + getRedirectEntry, + putRedirectEntry, + deleteRedirectEntry, +} from "../repository/url-cache"; export async function loader({ request, context, params }: LoaderFunctionArgs) { const { userId } = await getAuth({ request, context, params }); @@ -53,91 +69,27 @@ async function hashPassword(password: string) { } async function shortCodeTaken(context: any, shortCode: string) { - const row = await context.cloudflare.env.DB.prepare( - "SELECT 1 FROM urls WHERE id = ? LIMIT 1", - ) - .bind(shortCode) - .first(); - if (row) return { taken: true, source: "db" as const }; - - const kv = await context.cloudflare.env.URL_STORE.get(shortCode); - if (kv) return { taken: true, source: "kv" as const }; - - return { taken: false, source: null } as const; -} - -function extractSlugOnSameHostFromLongUrl( - longUrl: string, - hostHeader: string | null, -) { - try { - const url = new URL( - longUrl.startsWith("http") ? longUrl : `http://${longUrl}`, - ); - const reqHost = (hostHeader || "").toLowerCase().split(":")[0]; - if (!reqHost || url.hostname.toLowerCase() !== reqHost) return null; - const path = url.pathname.replace(/^\/+/, "").replace(/\/+$/, ""); - const slug = path.split("/")[0] || ""; - return slug || null; - } catch { - return null; + if (await existsById(context.cloudflare.env.DB, shortCode)) { + return { taken: true, source: "db" as const }; } -} -async function createsCycleAtPersist( - context: any, - startSlug: string, - firstTargetUrl: string, - hostHeader: string | null, - maxDepth = 5, -) { - const visited = new Set([startSlug]); - let depth = 0; - let nextSlug = extractSlugOnSameHostFromLongUrl(firstTargetUrl, hostHeader); - - while (nextSlug && depth < maxDepth) { - if (visited.has(nextSlug)) return true; - - visited.add(nextSlug); - - // Fetch the next long_url only if that short code exists - const nextRow = await context.cloudflare.env.DB.prepare( - "SELECT long_url FROM urls WHERE id = ?", - ) - .bind(nextSlug) - .first(); - - if (!nextRow) return false; - - const nextLong = (nextRow as { long_url: string }).long_url; - const normalized = nextLong?.startsWith("http") - ? nextLong - : `http://${nextLong}`; - nextSlug = extractSlugOnSameHostFromLongUrl(normalized, hostHeader); - depth++; - } + const entry = await getRedirectEntry( + context.cloudflare.env.URL_STORE, + shortCode, + ); + if (entry) return { taken: true, source: "kv" as const }; - return false; + return { taken: false, source: null } as const; } // Function to get a specific link async function handleGetLink(context: any, userId: string, shortCode: string) { try { - const link = await context.cloudflare.env.DB.prepare( - `SELECT - urls.id as shortCode, - urls.long_url as longUrl, - urls.created_at as createdAt, - urls.expires_at as expiresAt, - urls.password, - COUNT(clicks.id) as clicks - FROM urls - LEFT JOIN clicks ON urls.id = clicks.url_id - WHERE urls.id = ? AND urls.user_id = ? - GROUP BY urls.id`, - ) - .bind(shortCode, userId) - .first(); + const link = await getLinkDetailsByIdAndUser( + context.cloudflare.env.DB, + shortCode, + userId, + ); if (!link) { return new Response("Link not found", { status: 404 }); @@ -235,7 +187,11 @@ async function handleCreate(request: Request, context: any, userId: string) { } // Detect cycles across different short codes on same host - if (await createsCycleAtPersist(context, shortCode, normalized, host)) { + if ( + await createsCycle(shortCode, normalized, host, (slug) => + getLongUrlById(context.cloudflare.env.DB, slug), + ) + ) { return new Response( JSON.stringify({ success: false, @@ -257,14 +213,16 @@ async function handleCreate(request: Request, context: any, userId: string) { const max = Number(context.cloudflare.env.MAX_LINKS_PER_USER ?? 50); await enforceUrlLimit(context.cloudflare.env.DB, userId, max); - await context.cloudflare.env.DB.prepare( - `INSERT INTO urls (id, long_url, user_id, created_at, expires_at, password, password_enc) - VALUES (?, ?, ?, datetime('now'), ?, ?, ?)`, - ) - .bind(shortCode, longUrl, userId, expiresAt, passwordHash, passwordEnc) - .run(); + await insertLink(context.cloudflare.env.DB, { + id: shortCode, + longUrl, + userId, + expiresAt, + password: passwordHash, + passwordEnc, + }); - const kvOptions: Record = {}; + const kvOptions: { expiration?: number } = {}; if (expiresAt) { const expirationTimestamp = Math.floor( new Date(expiresAt).getTime() / 1000, @@ -275,13 +233,14 @@ async function handleCreate(request: Request, context: any, userId: string) { kvOptions.expiration = expirationTimestamp; } - await context.cloudflare.env.URL_STORE.put( + await putRedirectEntry( + context.cloudflare.env.URL_STORE, shortCode, - JSON.stringify({ + { longUrl, hasPassword: !!passwordHash, storedHash: passwordHash, - }), + }, kvOptions, ); @@ -375,7 +334,11 @@ async function handleUpdate(request: Request, context: any, userId: string) { ); } // Detect cycles across different short codes on same host - if (await createsCycleAtPersist(context, shortCode, url.toString(), host)) { + if ( + await createsCycle(shortCode, url.toString(), host, (slug) => + getLongUrlById(context.cloudflare.env.DB, slug), + ) + ) { return new Response( JSON.stringify({ error: @@ -388,11 +351,11 @@ async function handleUpdate(request: Request, context: any, userId: string) { try { // Verify that the link belongs to the user - const link = await context.cloudflare.env.DB.prepare( - "SELECT * FROM urls WHERE id = ? AND user_id = ?", - ) - .bind(originalShortCode, userId) - .first(); + const link = await findOwnedById( + context.cloudflare.env.DB, + originalShortCode, + userId, + ); if (!link) { return new Response("Link not found or you don't have permission", { @@ -415,38 +378,42 @@ async function handleUpdate(request: Request, context: any, userId: string) { } // Update database - await context.cloudflare.env.DB.prepare( - "UPDATE urls SET id = ?, long_url = ?, password = ?, password_enc = ?, updated_at = ?, expires_at = ? WHERE id = ? AND user_id = ?", - ) - .bind( - shortCode, - longUrl, - passwordHash, - passwordEnc, - now, - expiresAt, - originalShortCode, - userId, - ) - .run(); - - await context.cloudflare.env.URL_STORE.delete(originalShortCode); - await context.cloudflare.env.URL_STORE.put( + await renameLink(context.cloudflare.env.DB, { + newId: shortCode, + longUrl, + password: passwordHash, + passwordEnc, + updatedAt: now, + expiresAt, + currentId: originalShortCode, + userId, + }); + + await deleteRedirectEntry( + context.cloudflare.env.URL_STORE, + originalShortCode, + ); + await putRedirectEntry( + context.cloudflare.env.URL_STORE, shortCode, - JSON.stringify({ longUrl, hasPassword: !!passwordHash }), + { longUrl, hasPassword: !!passwordHash }, { expirationTtl: 60 * 60 * 24 * 30 }, ); } else { // If no shortCode editing: update fields and KV - await context.cloudflare.env.DB.prepare( - "UPDATE urls SET long_url = ?, password = ?, password_enc = ?, updated_at = ?, expires_at = ? WHERE id = ?", - ) - .bind(longUrl, passwordHash, passwordEnc, now, expiresAt, shortCode) - .run(); + await updateLinkById(context.cloudflare.env.DB, { + longUrl, + password: passwordHash, + passwordEnc, + updatedAt: now, + expiresAt, + id: shortCode, + }); - await context.cloudflare.env.URL_STORE.put( + await putRedirectEntry( + context.cloudflare.env.URL_STORE, shortCode, - JSON.stringify({ longUrl, hasPassword: !!passwordHash }), + { longUrl, hasPassword: !!passwordHash }, { expirationTtl: 60 * 60 * 24 * 30 }, ); } @@ -482,11 +449,11 @@ async function handleDelete(request: Request, context: any, userId: string) { try { // Verify that the link belongs to the user - const link = await context.cloudflare.env.DB.prepare( - "SELECT * FROM urls WHERE id = ? AND user_id = ?", - ) - .bind(shortCode, userId) - .first(); + const link = await findOwnedById( + context.cloudflare.env.DB, + shortCode, + userId, + ); if (!link) { return new Response("Link not found or you don't have permission", { @@ -495,12 +462,10 @@ async function handleDelete(request: Request, context: any, userId: string) { } // Delete from database - await context.cloudflare.env.DB.prepare("DELETE FROM urls WHERE id = ?") - .bind(shortCode) - .run(); + await deleteLinkById(context.cloudflare.env.DB, shortCode); // Delete from KV - await context.cloudflare.env.URL_STORE.delete(shortCode); + await deleteRedirectEntry(context.cloudflare.env.URL_STORE, shortCode); return new Response(JSON.stringify({ success: true }), { status: 200, diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index f3ad674..2b0e81d 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -12,6 +12,10 @@ import type { ActionFunctionArgs } from "react-router"; import { decryptPassword } from "@/utils/crypto"; import { copyToClipboard } from "@/utils/copy-to-clipboard"; +import { + insertLinkWithCreatedAt, + listActiveLinksForUser, +} from "../repository/urls"; import type { SortValue, SortKey } from "@/components/links/sort-links"; @@ -54,17 +58,13 @@ export async function action({ request, context, params }: ActionFunctionArgs) { } try { - await context.cloudflare.env.DB.prepare( - "INSERT INTO urls (id, long_url, user_id, created_at, password) VALUES (?, ?, ?, ?, ?)" - ) - .bind( - shortCode, - longUrl, - userId, - new Date().toISOString(), - password || null - ) - .run(); + await insertLinkWithCreatedAt(context.cloudflare.env.DB, { + id: shortCode, + longUrl, + userId, + createdAt: new Date().toISOString(), + password: password || null, + }); return { success: true }; } catch (error) { @@ -80,33 +80,10 @@ export async function loader(args: Route.LoaderArgs) { } try { - const rowsRes = await args.context.cloudflare.env.DB.prepare( - `SELECT - urls.id AS shortCode, - urls.long_url AS longUrl, - urls.created_at AS createdAt, - urls.expires_at AS expiresAt, - urls.password_enc AS passwordEnc, - COUNT(clicks.id) AS clicks, - urls.last_clicked AS lastClicked - FROM urls - LEFT JOIN clicks ON urls.id = clicks.url_id - WHERE urls.user_id = ? AND (urls.expires_at IS NULL OR urls.expires_at > datetime('now')) - GROUP BY urls.id - ORDER BY COALESCE(urls.last_clicked, urls.created_at) DESC` - ) - .bind(userId) - .all(); - - const raw = (rowsRes.results || []) as Array<{ - shortCode: string; - longUrl: string; - createdAt: string; - expiresAt: string | null; - passwordEnc: string | null; - clicks: number; - lastClicked: string | null; - }>; + const raw = await listActiveLinksForUser( + args.context.cloudflare.env.DB, + userId, + ); const encKey = args.context.cloudflare.env.PASSCODE_ENC_KEY; const links: Link[] = await Promise.all( diff --git a/app/routes/redirect.tsx b/app/routes/redirect.tsx index 6f05273..3508c17 100644 --- a/app/routes/redirect.tsx +++ b/app/routes/redirect.tsx @@ -7,6 +7,18 @@ import { import InAppSpy from "inapp-spy"; import { inAppEscape } from "@utils/in-app-escape"; import { isSelfReferential } from "@utils/is-self-referencial"; +import { createsCycle } from "@utils/cycle-detection"; +import { + getLongUrlById, + getRedirectTargetById, + getRedirectWithExpiryById, + deleteLinkById, +} from "../repository/urls"; +import { insertClick, updateLastClicked } from "../repository/clicks"; +import { + getRedirectEntry, + putRedirectEntry, +} from "../repository/url-cache"; import type { Route } from "./+types/redirect"; import PasswordWall from "@/components/password-wall"; @@ -19,64 +31,6 @@ export function meta({ params }: Route.MetaArgs) { ]; } -// Helpers to detect cycles between short links on same host -async function getLongUrlBySlug( - context: AppLoadContext, - code: string, -): Promise { - try { - const row = await context.cloudflare.env.DB.prepare( - "SELECT long_url FROM urls WHERE id = ?", - ) - .bind(code) - .first(); - if (!row) return null; - const { long_url } = row as { long_url: string }; - return long_url?.startsWith("http") ? long_url : `http://${long_url}`; - } catch { - return null; - } -} - -function extractSlugOnSameHost(targetUrl: string, hostHeader: string | null) { - try { - const url = new URL( - targetUrl.startsWith("http") ? targetUrl : `http://${targetUrl}`, - ); - const reqHost = (hostHeader || "").toLowerCase().split(":")[0]; - if (!reqHost || url.hostname.toLowerCase() !== reqHost) return null; - const path = url.pathname.replace(/^\/+/, "").replace(/\/+$/, ""); - const slug = path.split("/")[0] || ""; - return slug || null; - } catch { - return null; - } -} - -async function createsCycle( - context: AppLoadContext, - startSlug: string, - firstTargetUrl: string, - hostHeader: string | null, - maxDepth = 5, -): Promise { - const visited = new Set([startSlug]); - let depth = 0; - let nextSlug = extractSlugOnSameHost(firstTargetUrl, hostHeader); - - while (nextSlug && depth < maxDepth) { - if (visited.has(nextSlug)) return true; // cycle detected - visited.add(nextSlug); - - const nextLong = await getLongUrlBySlug(context, nextSlug); - if (!nextLong) return false; - - nextSlug = extractSlugOnSameHost(nextLong, hostHeader); - depth++; - } - return false; -} - export async function action({ params, context, request }: ActionFunctionArgs) { const { slug } = params as { slug: string }; if (!slug) return new Response("Bad Request", { status: 400 }); @@ -84,18 +38,11 @@ export async function action({ params, context, request }: ActionFunctionArgs) { const formData = await request.formData(); const password = (formData.get("password") as string) || ""; - const record = await context.cloudflare.env.DB.prepare( - "SELECT long_url, password FROM urls WHERE id = ?", - ) - .bind(slug) - .first(); + const record = await getRedirectTargetById(context.cloudflare.env.DB, slug); if (!record) return new Response("Not Found", { status: 404 }); - const { long_url, password: storedHash } = record as { - long_url: string; - password: string | null; - }; + const { long_url, password: storedHash } = record; if (!storedHash) { const dest = long_url.startsWith("http") ? long_url : `http://${long_url}`; @@ -141,10 +88,11 @@ export async function action({ params, context, request }: ActionFunctionArgs) { // Prevent cross-slug cycles on same host const hasCycle = await createsCycle( - context as unknown as AppLoadContext, slug, dest, request.headers.get("host"), + (code) => + getLongUrlById(context.cloudflare.env.DB, code).catch(() => null), ); if (hasCycle) { return new Response("Not Found", { status: 404 }); @@ -154,16 +102,12 @@ export async function action({ params, context, request }: ActionFunctionArgs) { context.cloudflare.ctx.waitUntil( (async () => { try { - await context.cloudflare.env.DB.prepare( - "INSERT INTO clicks (url_id, clicked_at, country, user_agent) VALUES (?, datetime('now'), ?, ?)", - ) - .bind(slug, country, userAgent) - .run(); - await context.cloudflare.env.DB.prepare( - "UPDATE urls SET last_clicked = datetime('now') WHERE id = ?", - ) - .bind(slug) - .run(); + await insertClick(context.cloudflare.env.DB, { + urlId: slug, + country, + userAgent, + }); + await updateLastClicked(context.cloudflare.env.DB, slug); } catch (err) { console.error(`[Action /${slug}] Async click log failed`, err); } @@ -200,66 +144,48 @@ export async function loader({ let storedHash: string | null = null; // Try KV first - const kvRaw = await context.cloudflare.env.URL_STORE.get(slug); - - if (kvRaw) { - try { - const parsed = JSON.parse(kvRaw); - if (parsed && typeof parsed === "object" && "longUrl" in parsed) { - longUrl = parsed.longUrl; - hasPassword = !!parsed.hasPassword; - storedHash = parsed.storedHash || null; - } else { - longUrl = kvRaw; - } - } catch { - longUrl = kvRaw; - } + const entry = await getRedirectEntry(context.cloudflare.env.URL_STORE, slug); + + if (entry) { + longUrl = entry.longUrl; + hasPassword = entry.hasPassword; + storedHash = entry.storedHash ?? null; } else { // Fallback to DB solely if KV misses - const record = await context.cloudflare.env.DB.prepare( - "SELECT long_url, password, expires_at FROM urls WHERE id = ?", - ) - .bind(slug) - .first(); + const record = await getRedirectWithExpiryById( + context.cloudflare.env.DB, + slug, + ); if (!record) { console.error(`[Loader /${slug}] Not found in DB`); throw new Response("Not Found", { status: 404 }); } - const typedRecord = record as { - long_url: string; - password: string | null; - expires_at: string | null; - }; + const { long_url, password, expires_at } = record; // Check expiration - if ( - typedRecord.expires_at && - new Date(typedRecord.expires_at) < new Date() - ) { + if (expires_at && new Date(expires_at) < new Date()) { context.cloudflare.ctx.waitUntil( - context.cloudflare.env.DB.prepare("DELETE FROM urls WHERE id = ?") - .bind(slug) - .run(), + deleteLinkById(context.cloudflare.env.DB, slug), ); throw new Response("Expired", { status: 410 }); } - longUrl = typedRecord.long_url; - hasPassword = !!typedRecord.password; - storedHash = typedRecord.password; + longUrl = long_url; + hasPassword = !!password; + storedHash = password; // Async KV repopulation (Includes storedHash to prevent DB hits on secured links) context.cloudflare.ctx.waitUntil( - context.cloudflare.env.URL_STORE.put( + putRedirectEntry( + context.cloudflare.env.URL_STORE, slug, - JSON.stringify({ + { longUrl, hasPassword, storedHash, - }), + }, { expirationTtl: 2592000 }, // 30 days ), ); @@ -302,7 +228,11 @@ export async function loader({ } // Prevent cross-slug cycles on same host - if (await createsCycle(context, slug, longUrl, request.headers.get("host"))) { + if ( + await createsCycle(slug, longUrl, request.headers.get("host"), (code) => + getLongUrlById(context.cloudflare.env.DB, code).catch(() => null), + ) + ) { throw new Response("Not Found", { status: 404 }); } @@ -310,16 +240,12 @@ export async function loader({ context.cloudflare.ctx.waitUntil( (async () => { try { - await context.cloudflare.env.DB.prepare( - "INSERT INTO clicks (url_id, clicked_at, country, user_agent) VALUES (?, datetime('now'), ?, ?)", - ) - .bind(slug, country, userAgent) - .run(); - await context.cloudflare.env.DB.prepare( - "UPDATE urls SET last_clicked = datetime('now') WHERE id = ?", - ) - .bind(slug) - .run(); + await insertClick(context.cloudflare.env.DB, { + urlId: slug, + country, + userAgent, + }); + await updateLastClicked(context.cloudflare.env.DB, slug); } catch (err) { console.error(`[Loader /${slug}] Async click log failed`, err); } diff --git a/app/routes/webhooks.clerk.tsx b/app/routes/webhooks.clerk.tsx index 346ef3e..d60488d 100644 --- a/app/routes/webhooks.clerk.tsx +++ b/app/routes/webhooks.clerk.tsx @@ -1,6 +1,11 @@ import type { ActionFunctionArgs } from "react-router"; import { Webhook } from "svix"; import { int64ToIso8601Auto } from "@/utils/int64-to-iso8601"; +import { + insertUser, + updateUser, + deleteUserById, +} from "../repository/users"; export async function action({ request, context }: ActionFunctionArgs) { const WEBHOOK_SECRET = context.cloudflare.env.CLERK_WEBHOOK_SECRET; @@ -62,17 +67,12 @@ export async function action({ request, context }: ActionFunctionArgs) { if (eventType === "user.created") { try { - await context.cloudflare.env.DB.prepare( - "INSERT INTO users (id, email, subscription_plan, created_at) VALUES (?, ?, ?, ?)" - ) - .bind( - id, - email_addresses[0]?.email_address || "", - "FREE", // default - int64ToIso8601Auto(created_at) // Convert to ISO 8601 - // new Date().toISOString() - ) - .run(); + await insertUser(context.cloudflare.env.DB, { + id, + email: email_addresses[0]?.email_address || "", + subscriptionPlan: "FREE", // default + createdAt: int64ToIso8601Auto(created_at), // Convert to ISO 8601 + }); console.log("User created in D1:", { id, @@ -89,16 +89,12 @@ export async function action({ request, context }: ActionFunctionArgs) { if (eventType === "user.updated") { try { - await context.cloudflare.env.DB.prepare( - "UPDATE users SET email = ?, subscription_plan = ?, created_at = ? WHERE id = ?" - ) - .bind( - email_addresses[0]?.email_address || "", - undefined, - int64ToIso8601Auto(created_at), - id - ) - .run(); + await updateUser(context.cloudflare.env.DB, { + id, + email: email_addresses[0]?.email_address || "", + subscriptionPlan: undefined, + createdAt: int64ToIso8601Auto(created_at), + }); console.log("User updated in D1:", { id, @@ -115,9 +111,7 @@ export async function action({ request, context }: ActionFunctionArgs) { if (eventType === "user.deleted") { try { - await context.cloudflare.env.DB.prepare("DELETE FROM users WHERE id = ?") - .bind(id) - .run(); + await deleteUserById(context.cloudflare.env.DB, id); console.log("User deleted from D1:", { id, diff --git a/utils/__tests__/cycle-detection.test.ts b/utils/__tests__/cycle-detection.test.ts new file mode 100644 index 0000000..5659ad7 --- /dev/null +++ b/utils/__tests__/cycle-detection.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from "vitest"; +import { + extractSlugOnSameHost, + createsCycle, + type LongUrlLookup, +} from "../cycle-detection"; + +function lookupFrom(links: Record): LongUrlLookup { + return async (slug) => links[slug] ?? null; +} + +describe("extractSlugOnSameHost", () => { + it("extracts the first path segment of a same-host URL", () => { + expect(extractSlugOnSameHost("http://localhost/abc", "localhost")).toBe( + "abc", + ); + }); + + it("strips the port from the host header before comparing hosts", () => { + expect( + extractSlugOnSameHost("http://localhost:3000/abc", "localhost:3000"), + ).toBe("abc"); + expect( + extractSlugOnSameHost("http://localhost:3000/abc", "localhost"), + ).toBe("abc"); + }); + + it("prepends a scheme when the target URL has none", () => { + expect(extractSlugOnSameHost("localhost/abc", "localhost")).toBe("abc"); + }); + + it("ignores extra path segments and surrounding slashes", () => { + expect(extractSlugOnSameHost("http://localhost/a/b/", "localhost")).toBe( + "a", + ); + }); + + it("compares host and hostname case-insensitively", () => { + expect(extractSlugOnSameHost("http://LocalHost/ABC", "LOCALHOST")).toBe( + "ABC", + ); + }); + + it("returns null for a different host", () => { + expect(extractSlugOnSameHost("https://other.com/abc", "localhost")).toBe( + null, + ); + }); + + it("returns null for a root path on the same host", () => { + expect(extractSlugOnSameHost("http://localhost/", "localhost")).toBe(null); + }); + + it("returns null when the host header is missing", () => { + expect(extractSlugOnSameHost("http://localhost/abc", null)).toBe(null); + expect(extractSlugOnSameHost("http://localhost/abc", "")).toBe(null); + }); + + it("returns null for an unparseable URL", () => { + expect(extractSlugOnSameHost("http://", "localhost")).toBe(null); + }); +}); + +describe("createsCycle", () => { + const host = "localhost"; + + it("detects a cycle when the first target points back at the start slug", async () => { + const lookup = lookupFrom({}); + const result = await createsCycle( + "loop-a", + `http://${host}/loop-a`, + host, + lookup, + ); + expect(result).toBe(true); + }); + + it("detects a direct two-link cycle", async () => { + const lookup = lookupFrom({ "loop-b": `http://${host}/loop-a` }); + const result = await createsCycle( + "loop-a", + `http://${host}/loop-b`, + host, + lookup, + ); + expect(result).toBe(true); + }); + + it("follows the chain until it finds the cycle", async () => { + const lookup = lookupFrom({ + "chain-1": `http://${host}/chain-2`, + "chain-2": `http://${host}/chain-3`, + "chain-3": `http://${host}/chain-1`, + }); + const result = await createsCycle( + "chain-1", + `http://${host}/chain-2`, + host, + lookup, + ); + expect(result).toBe(true); + }); + + it("returns false when the chain ends at an unknown slug", async () => { + const lookup = lookupFrom({ "chain-1": `http://${host}/missing` }); + const result = await createsCycle( + "start", + `http://${host}/chain-1`, + host, + lookup, + ); + expect(result).toBe(false); + }); + + it("returns false when the first target is on another host", async () => { + const lookup = lookupFrom({ "chain-1": `http://${host}/chain-2` }); + const result = await createsCycle( + "start", + "https://elsewhere.com/chain-1", + host, + lookup, + ); + expect(result).toBe(false); + }); + + it("stops walking once the chain leaves the origin host", async () => { + const lookup = lookupFrom({ + "chain-1": "https://elsewhere.com/chain-2", + }); + const result = await createsCycle( + "start", + `http://${host}/chain-1`, + host, + lookup, + ); + expect(result).toBe(false); + }); + + it("stops after maxDepth hops even without a repeat", async () => { + const lookup = lookupFrom({ + "hop-1": `http://${host}/hop-2`, + "hop-2": `http://${host}/hop-3`, + "hop-3": `http://${host}/hop-4`, + "hop-4": `http://${host}/hop-5`, + }); + const result = await createsCycle( + "start", + `http://${host}/hop-1`, + host, + lookup, + 3, + ); + expect(result).toBe(false); + }); + + it("detects cycles across short codes with ports in the host header", async () => { + const lookup = lookupFrom({ "loop-b": "http://localhost:3000/loop-a" }); + const result = await createsCycle( + "loop-a", + "http://localhost:3000/loop-b", + "localhost:3000", + lookup, + ); + expect(result).toBe(true); + }); + + it("calls the lookup with each visited slug", async () => { + const seen: string[] = []; + const lookup: LongUrlLookup = async (slug) => { + seen.push(slug); + return `http://${host}/next-${seen.length}`; + }; + const result = await createsCycle( + "start", + `http://${host}/first`, + host, + lookup, + 2, + ); + expect(result).toBe(false); + expect(seen).toEqual(["first", "next-1"]); + }); +}); diff --git a/utils/cycle-detection.ts b/utils/cycle-detection.ts new file mode 100644 index 0000000..08263d4 --- /dev/null +++ b/utils/cycle-detection.ts @@ -0,0 +1,45 @@ +export function extractSlugOnSameHost( + targetUrl: string, + hostHeader: string | null, +): string | null { + try { + const url = new URL( + targetUrl.startsWith("http") ? targetUrl : `http://${targetUrl}`, + ); + const reqHost = (hostHeader || "").toLowerCase().split(":")[0]; + if (!reqHost || url.hostname.toLowerCase() !== reqHost) return null; + const path = url.pathname.replace(/^\/+/, "").replace(/\/+$/, ""); + const slug = path.split("/")[0] || ""; + return slug || null; + } catch { + return null; + } +} + +export type LongUrlLookup = (slug: string) => Promise; + +export async function createsCycle( + startSlug: string, + firstTargetUrl: string, + hostHeader: string | null, + getLongUrlBySlug: LongUrlLookup, + maxDepth = 5, +): Promise { + const visited = new Set([startSlug]); + let depth = 0; + let nextSlug = extractSlugOnSameHost(firstTargetUrl, hostHeader); + + while (nextSlug && depth < maxDepth) { + if (visited.has(nextSlug)) return true; + + visited.add(nextSlug); + + const nextLong = await getLongUrlBySlug(nextSlug); + if (!nextLong) return false; + + nextSlug = extractSlugOnSameHost(nextLong, hostHeader); + depth++; + } + + return false; +}