Files
gerbeur/API.md
khannurien 4468438ba7 v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:08:59 +00:00

9.4 KiB

gerbeur's URLs

Every page and every API endpoint follows one of two rules. This file summarizes them. Each router's module doc in api/routes/ lists its own endpoints in full, and a route change updates both. shared/paths.ts builds and parses the page paths.

The two rules

Pages. The path is a hierarchy. @name is a user. Any other word under the root is a category or an app page. Under a user, each segment is a fixed tab name, and under dumps or playlists, a slug. A view of a page is one more segment. A default view is the bare path, with no redirect.

API. A resource is /api/<plural>/<key>[/<noun>]. Its key is its id, except a user's, which may also be @name. The @ keeps the two apart, so no regex sniffing is needed. A read by page path mirrors the page: /api/users/@bob/dumps/<slug>. The caller's own things live under /api/me. Media is a sub-resource. A lookup by another key is a query filter. Each concept has one word, the same as in pages.

Pages

Path Views Access Page
/ /hot, /new, /journal, /follow; the bare / is the viewer's default tab anyone, /follow signed in Index
/<category> /<category>/hot, /new, /journal, /follow anyone Index
/@<name> the overview: dumps beside upvoted anyone UserPublicProfile
/@<name>/dumps, /upvoted, /playlists, /invitees anyone UserPublicProfile
/@<name>/follow /@<name>/follow/playlists; the bare tab lists users anyone UserPublicProfile
/@<name>/manage user:manage, on others' profiles UserPublicProfile
/@<name>/dumps/<slug> anyone; a private dump, its poster Dump
/@<name>/dumps/<slug>/edit its poster, dump:moderate DumpEdit
/@<name>/playlists/<slug> anyone; a private playlist, its owner PlaylistDetail
/search?q= /search/users?q=, /search/playlists?q= anyone Search
/notifications signed in Notifications
/settings signed in Settings
/manage category:manage Manage
/login, /register?token= signed out UserLogin, UserRegister
/reset-password?token=, /unsubscribe?token= anyone ResetPassword, Unsubscribe

/?share_url= opens the create form on a link shared from Android, /?chat=open opens the chat over any page, and #comment-<id> scrolls a dump's page to a comment.

Names

  • Slugs of categories, dumps and playlists are lowercase letters and digits in words joined by single hyphens (shared/slug.ts). A category's is at most 50 characters, a dump's or a playlist's at most 60.
  • A dump's or a playlist's slug is made from its title when it is created, unique among its owner's items of that kind: a clash gets -2, -3. It changes only when someone edits it: the edit forms propose slugify(newTitle) when the title changes. Its owner may change it 3 times a day; moderators (dump:moderate, playlist:moderate) may edit any slug, at any time.
  • A username is 1 to 32 letters, digits and underscores, unique in any case. A page writes it in its stored case, and another case still resolves. A user may rename themselves once every 30 days from /settings; those with user:manage rename anyone, at any time, from /@<name>/manage.
  • Aliases. A slug or a name left behind stays an alias for good, which always names the item or user itself, so redirects never chain. Nobody else can take one. An item can take back one of its own. Deleting an item frees its aliases.
  • Redirects. A page reached by an alias, or by a name in another case, gets the item from the API under its current path and replaces its URL. The server answers the same path with a 301 to crawlers and full page loads.
  • Reserved words. A category can't take a word the root already uses: hot new journal follow search login register notifications reset-password unsubscribe settings manage api ws assets emojibase (api/lib/reserved-slugs.ts). Dump and playlist slugs reserve nothing, and no slug has an @.

API

Every JSON endpoint answers the envelope of shared/api.ts: { "success": true, "data": … }, or { "success": false, "error": { "code", "message" } } with the matching HTTP status. A list answers { "items", "hasMore" } and takes ?page= (from 1) and ?limit= (20 by default, 100 at most). "Signed in" means a session token as Authorization: Bearer …. Media can't send a header, so a private file, thumbnail, cover or attachment takes the owner's media token as ?token=.

me

  • GET /api/me — signed in. The caller's own user.
  • PATCH /api/me — signed in. Changes the description, email and username.
  • GET /api/me/media-token — signed in. A media token.
  • GET, PATCH /api/me/settings — signed in. The caller's settings.
  • PUT /api/me/password — signed in, with the current one. A new password, and fresh tokens.
  • PUT /api/me/avatar — signed in. A new avatar (multipart).
  • GET /api/me/playlists?contains=:dumpId — signed in. The caller's playlists, and whether each holds the dump.
  • GET /api/me/follow — signed in. The ids of the users and playlists followed.
  • PUT, DELETE /api/me/follow/users/:id, /api/me/follow/playlists/:id — signed in. Follows or stops following.
  • GET /api/me/feed?from=users|playlists&category= — signed in. The dumps of what the caller follows.
  • GET /api/me/notifications — signed in. The caller's notifications.
  • POST /api/me/notifications/read — signed in. Marks them all read.

users

  • POST /api/users — with an invite. Signs up.
  • GET /api/users?q= — anyone. Up to 8 users whose name starts with q.
  • GET /api/users/:key — anyone. A profile.
  • GET /api/users/:key/avatar — anyone. The avatar.
  • GET /api/users/:key/dumps, /upvoted, /playlists — anyone; private items to their owner only. Their lists.
  • GET /api/users/:key/dumps/:slug, /playlists/:slug — anyone; private items to their owner only. An item by its page path, by an alias too.
  • GET /api/users/:key/follow/users, /follow/playlists — anyone. What they follow.
  • GET /api/users/:key/invitees — anyone. The tree of users they invited.
  • PATCH /api/users/:key/role, /username — user:manage. Another user's role, or anyone's name.

dumps

  • POST /api/dumps — signed in. Posts a link (JSON) or a file (multipart).
  • GET /api/dumps — anyone. The feed; ?category=<slug> narrows it, and ?url= answers the dumps of the same link instead.
  • GET /api/dumps/:id — anyone who may see it. A dump.
  • GET /api/dumps/:id/voters, /related, /playlists — the same. Who upvoted it, the dumps linked to or from it, the playlists holding it.
  • PUT /api/dumps/:id — its poster, dump:moderate. Title, slug, note, visibility, categories, link.
  • POST /api/dumps/:id/metadata — the same. Fetches a link's preview again.
  • DELETE /api/dumps/:id — the same. Deletes it.
  • GET, PUT /api/dumps/:id/file — the file; a new one, by its poster or dump:moderate.
  • GET, PUT, DELETE /api/dumps/:id/thumbnail — the thumbnail; a custom one, set or removed by its poster or dump:moderate.
  • GET, POST /api/dumps/:id/comments — anyone who may see the dump; posting signed in. Its comments.

playlists

  • POST /api/playlists — signed in. Creates a playlist.
  • GET /api/playlists/:id — anyone who may see it. A playlist and its dumps.
  • PUT, DELETE /api/playlists/:id — its owner, playlist:moderate. Title, slug, description, visibility; deletion.
  • POST, DELETE /api/playlists/:id/dumps/:dumpId — the same. Adds or removes a dump.
  • PUT /api/playlists/:id/order — the same. Reorders its dumps.
  • GET, PUT /api/playlists/:id/cover — the cover; a new one, by its owner or playlist:moderate.

comments

  • PATCH, DELETE /api/comments/:id — its author, comment:moderate. Edits or deletes a comment.
  • GET /api/comments/:id/likers — anyone. Who likes it.

categories

  • GET /api/categories — anyone. Every category.
  • POST /api/categories, PUT, DELETE /api/categories/:id — category:manage. Changes them.

chat

  • GET /api/chat?before=&after= — signed in. A page of messages. The chat itself goes over /ws.

invites

  • POST /api/invites — signed in. A new invite.
  • GET /api/invites/:token — anyone. Whether it still works.

sessions, password-resets, unsubscribe

  • POST /api/sessions — anyone. Signs in.
  • POST /api/password-resets — anyone. Emails a reset link.
  • PUT /api/password-resets/:token — with the link's token. A new password.
  • POST /api/unsubscribe?token= — with a digest's token. Turns it off. Its v3 path, POST /api/users/unsubscribe, stays for good, since sent emails' List-Unsubscribe headers name it.
  • GET /api/search?q= — anyone. A page of dumps, and up to 20 users and playlists.

attachments

  • POST /api/attachments — signed in. Stores an image.
  • GET /api/attachments/:id — anyone who may see the text holding it.

remote

  • GET /api/remote/preview?url= — signed in. A link's preview.
  • GET /api/remote/image?url= — anyone. A remote image through this site.
  • GET /api/remote/bandcamp?url= — anyone. A Bandcamp page's tracks.
  • GET /api/remote/youtube?list= — anyone. A YouTube playlist's tracklist.

/ws

  • GET /ws?token= — anyone; a session token signs it in. The WebSocket of live updates and the chat (shared/ws.ts).