Compare commits

Author SHA1 Message Date
dhkatzandClaude Opus 5.5 2e9c2f32cb feat(web): group the token permission picker by resource
Pull request / title (pull_request) Successful in 1s
CI / test (pull_request) Successful in 1m13s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
Each resource (Products, Members, Imports, ...) is a collapsed section with a select-all checkbox and a count of
what is chosen, so the list stays short as permissions are added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:37:50 -08:00
dhkatzandClaude Opus 5.5 2b88a52bcb feat: personal API tokens
Pull request / title (pull_request) Successful in 0s
CI / test (pull_request) Successful in 1m9s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped
Tokens for scripts, made, listed and revoked on the account page. Each grants some of the person's own permissions
at chosen places (a product of theirs, a catalog whose team they're on, or the hub for moderators); every check is
the intersection with what they hold now. Tokens work only on routes that check a permission (session-only routes
answer token_not_allowed), are shown once and stored as hashes, note their last use, and mark the audit entries of
what they do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:58:58 -08:00
dhkatz 58a3e70a63 feat: file blocks (#10)
Release / release (push) Successful in 3s
CI / test (push) Successful in 1m13s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 12s
Phase 3, part 2 (`docs/design.md`, "Next"): file blocks. Moderators block a file network-wide by its sha256, so a removed product can't come back under another name or catalog.

**Blocking**
- A file's page (Console → Blocks → a file) shows whether it's blocked and the products using it, so a moderator sees what a block removes before confirming ("Block and remove 2 products").
- Moderators reach it from a product's files (each has a Block link for them) or by pasting a sha256 into the Blocks tab, which also lists blocked files.
- Blocking a file:
  - removes every product with a revision using it. Each removal is audited as `product.remove` with the file, so creators are notified and can appeal it as before.
  - puts a `{type: "block", sha256}` change in the changes feed of every catalog hosting it, meaning: delete it whatever uses it. (t5de-catalog doesn't read the feed yet.)
  - refuses uploads with it from then on (409 `blocked`). Each attempt is audited as `file.blocked_upload` with the person and catalog, so moderators see who tried.
- Unblocking lets the file in again. The products it removed stay removed until a moderator restores them, one by one.
- Who: `product:remove` hub-wide (moderators and admins). The Blocks tab shows only for them.

**API**: `GET /api/v1/blocks`, and `GET`/`PUT`/`DELETE /api/v1/blocks/:sha256` (`docs/products.md`).

**Migration** `0013_file_blocks`: the `file_block` table, and a GIN index on revisions' file lists, so finding the products using a file doesn't scan every revision.

**Checks**
- 53 API tests pass. The new `blocks.test.ts` covers:
  - who may block
  - the preview of what a block removes
  - removal of every product using the file, across catalogs and creators
  - the changes feed, notifications and appeals
  - refused uploads and their audit entry
  - unblocking, and restoring a removed product afterwards
- Type checks pass.
- On a local hub, through the website's own forms:
  - the Block link on a product's files, for moderators only
  - the Blocks tab, for moderators only (404 for the creator)
  - the file's page, then blocking: the product is gone, its creator notified and offered Appeal
  - a re-upload through the catalog API: refused, and audited
  - unblocking, then Restore
  - Fixed along the way: an empty reason gave a generic "Some of that isn't right"; it now says "Give a reason."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 3, part 2 (`docs/design.md`, "Next"): file blocks. Moderators block a file network-wide by its sha256, so a removed product can't come back under another name or catalog.

**Blocking**
- A file's page (Console → Blocks → a file) shows whether it's blocked and the products using it, so a moderator sees what a block removes before confirming ("Block and remove 2 products").
- Moderators reach it from a product's files (each has a Block link for them) or by pasting a sha256 into the Blocks tab, which also lists blocked files.
- Blocking a file:
  - removes every product with a revision using it. Each removal is audited as `product.remove` with the file, so creators are notified and can appeal it as before.
  - puts a `{type: "block", sha256}` change in the changes feed of every catalog hosting it, meaning: delete it whatever uses it. (t5de-catalog doesn't read the feed yet.)
  - refuses uploads with it from then on (409 `blocked`). Each attempt is audited as `file.blocked_upload` with the person and catalog, so moderators see who tried.
- Unblocking lets the file in again. The products it removed stay removed until a moderator restores them, one by one.
- Who: `product:remove` hub-wide (moderators and admins). The Blocks tab shows only for them.

**API**: `GET /api/v1/blocks`, and `GET`/`PUT`/`DELETE /api/v1/blocks/:sha256` (`docs/products.md`).

**Migration** `0013_file_blocks`: the `file_block` table, and a GIN index on revisions' file lists, so finding the products using a file doesn't scan every revision.

**Checks**
- 53 API tests pass. The new `blocks.test.ts` covers:
  - who may block
  - the preview of what a block removes
  - removal of every product using the file, across catalogs and creators
  - the changes feed, notifications and appeals
  - refused uploads and their audit entry
  - unblocking, and restoring a removed product afterwards
- Type checks pass.
- On a local hub, through the website's own forms:
  - the Block link on a product's files, for moderators only
  - the Blocks tab, for moderators only (404 for the creator)
  - the file's page, then blocking: the product is gone, its creator notified and offered Appeal
  - a re-upload through the catalog API: refused, and audited
  - unblocking, then Restore
  - Fixed along the way: an empty reason gave a generic "Some of that isn't right"; it now says "Give a reason."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 22:48:07 +00:00
dhkatz 7299e10b08 feat: cases (reports, reviews, requests, appeals) and notifications (#9)
Release / release (push) Successful in 3s
CI / test (push) Successful in 17m26s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 15s
Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.

**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
  - a thread of notes, where internal notes are visible only to those deciding
  - an assignee (moderators assign cases to moderators)
  - a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
  - Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
  - Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
  - Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
  - From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
  - Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
  - Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.

**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
  - a moderator edited or locked your product, or undid a change (with the reason)
  - your product was removed or restored
  - your case was decided
  - someone replied on your case
  - a catalog decided your membership

**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.

**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.

**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)

**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
  - who sees and decides each kind of case, and that the reporter stays private
  - rate limits and duplicate reports
  - the thread and internal notes, and assigning
  - what deciding each kind of case does
  - appeals by the person affected only, decided by another moderator
  - restoring a removed product
  - the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
  - The catalog's team could see who reported.
  - A missing Resolve/Decline choice was treated as Decline; now it's refused.
  - The Unassign button sent the typed name instead of nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 3, part 1 (`docs/design.md`, "Next"): cases and notifications. File blocks, API tokens and two-factor sign-in come as separate PRs.

**Cases**
- One queue (Console → Cases) for everything someone has to decide. Each case has:
  - a thread of notes, where internal notes are visible only to those deciding
  - an assignee (moderators assign cases to moderators)
  - a decision, Resolve or Decline, with a written resolution that the person who opened it sees
- **Reports:** anyone signed in can report a product or catalog from its page (the API also takes rooms and people).
  - Limits: 10 an hour, one open report per person and thing, and you can't report yourself.
  - Reports about a product go to the teams of the catalogs hosting it, as well as to moderators.
  - Only the reporter and moderators know who reported. The audit entry for a report isn't tagged with what it's about, so a product's creator can't find out from their audit log either.
- **Rating reviews:** a creator lowering a rating from adult to general now opens a review instead of being refused. The rest of the edit still saves. Resolving the review lowers the rating, recorded in the product's history.
- **Removal requests:** a catalog's team can ask moderators to remove a product it hosts, everywhere. Resolving it removes the product.
- **Deletion requests:** a catalog's owner asks for deletion from the catalog's console page, and a moderator decides, as agreed. Resolving it marks the catalog `deleted`: its key stops working, nothing is available from it, and its changes feed says so.
- **Appeals:**
  - From the audit log, the person an action affected can appeal it, once: their product removed, delisted or edited by someone else; their catalog suspended or rejected; a ban from a catalog or a declined request to join.
  - Another moderator decides. An admin may decide an appeal of their own action, and the audit log flags it.
  - Resolving records the decision; the moderator then undoes the action where it's done. For that, there's now a Restore for removed products (Console → Products, for moderators); the design mentioned one, but it was never built.
- The console shows a "Cases" tab, and a to-do with the number of open cases the person could decide.

**Notifications**
- A bell in the header with the unread count, and a notifications page with "Mark all as read".
- Each is also sent to the person's proxies over the realtime connection (`{"type": "notification"}`), so the client can show them later.
- They cover:
  - a moderator edited or locked your product, or undid a change (with the reason)
  - your product was removed or restored
  - your case was decided
  - someone replied on your case
  - a catalog decided your membership

**Also changed**
- A product's creator and moderators can now see it when it's adult, even without an adult account. Before, a creator who made their own product adult couldn't open its page any more, and neither could moderators. Getting and wearing adult products still need an adult account.

**Not done yet** (in `docs/console.md`): email notifications (the hub sends no email yet), canned reasons, private moderator notes on people and products, and second approval for the most destructive actions.

**Migration** `0012_cases`: the `moderation_case`, `case_note` and `notification` tables. (`case` is a reserved word in SQL, hence the table's name.)

**Checks**
- 51 API tests pass. The 7 in the new `cases.test.ts` cover:
  - who sees and decides each kind of case, and that the reporter stays private
  - rate limits and duplicate reports
  - the thread and internal notes, and assigning
  - what deciding each kind of case does
  - appeals by the person affected only, decided by another moderator
  - restoring a removed product
  - the notifications
- Type checks pass.
- On a local hub I went through every flow with the website's own forms: report from a product page; the catalog owner's note and decision; assigning, an internal note and a decision as admin; removal by a moderator, the creator's appeal from the audit log, the admin's decision and Restore; a creator's rating review and the decision lowering the rating; a deletion request and its decline; the notifications, the bell's count, and Mark all as read.
- Fixed along the way:
  - The catalog's team could see who reported.
  - A missing Resolve/Decline choice was treated as Decline; now it's refused.
  - The Unassign button sent the typed name instead of nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 22:14:08 +00:00
dhkatz e07a6c6dac feat: product drafts, moderator edits, info history, unlisted products, tags and categories (#8)
Release / release (push) Successful in 13s
CI / test (push) Successful in 1m5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 4s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 16s
Phase 2 of the plan in `docs/design.md` ("Next"): product info.

**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.

**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.

**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.

**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).

**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.

**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.

**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.

**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.

**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 2 of the plan in `docs/design.md` ("Next"): product info.

**Drafts**
- A new upload is a draft. Only its creator and moderators can see it, and nobody can get or wear it.
- After uploading, the website goes straight to the product's review page, where the creator checks its info and presses Publish.
- Drafts not published within 30 days are deleted (checked every 10 minutes), and their catalogs get `delete` entries in their changes feed.
- The console's overview shows a "Drafts" to-do.

**Editing is a permission check now** (`product:edit`, from #7)
- Moderators can edit anyone's product. They must give a reason, which the creator sees.
- Moderators can lock fields (`product:lock`). Once a field is locked, only people with `product:edit-locked` can change it, so the creator sees it greyed out with "Locked by a moderator."
- Raising a rating to adult is immediate for anyone who can edit. Lowering it takes `product:rate-lower` (moderators), and is refused while any revision's files are rated adult. With cases (phase 3), a creator's request to lower it will open a rating review.

**Info history**
- Every edit, publish, lock change and undo is recorded with who, when, what changed (before and after) and why.
- It's shown on the edit page to the creator and moderators.
- "Undo" puts back what an entry changed, as a new entry, by the same rules (a creator can't undo a moderator's change to a field that's now locked).
- Moderator actions are also in the creator's audit log.

**Unlisted products and tags**
- An unlisted product is left out of search, lists and creator pages, except for its creator. Its link still works and people can still get it.
- Tags: up to 10 per product, lowercased.
- `/products` filters by `tag` and `catalog`, and a product's tags link to the filtered list.
- People who don't manage a product don't see its state labels (draft, unlisted, locks).

**Console → Products**
- Lists your products with drafts included; moderators can switch to everyone's.
- Filters: draft, published, unlisted, locked, deleted, removed, plus search.
- Shows how many libraries each product is in, and an Edit or Review button.

**Category**
- A product can be filed under a different IMVU category from its parent's.
- The choices are every category path the hub has seen on any product's parent, plus each level of those paths. They're limited to the same top-level category as the parent's, so a product stays the same kind (clothing, furniture or rooms), which the proxy relies on for the shop and inventory tabs.
- The list grows as products are uploaded. IMVU's own category tree, if it has a public one, would replace it.
- Category works like the other info fields: it can be locked, it's in the history, and it can be undone. The product list's category filter, and everything that shows a product's categories (including what the proxy gets), use the chosen path.

**Migration** `0011_product_info`: new product columns and the `product_info_change` table. Existing products count as published (with `published_at` set from their creation date) and listed.

**Compatibility**
- `t5de-catalog` needs no change: the hub's new `draft` field in the registration answer is ignored, and the website doesn't rely on it.
- The proxy only loads products from libraries, and drafts can't be added to one.

**Checks**
- 44 API tests pass, including a new one for moderator edits, locks, history, undo, unlisted, tags, the console list and draft purging, and new checks for category choices.
- Type checks pass.
- On a local hub I loaded the pages as the creator, an admin, another person and a signed-out visitor.
- I submitted the forms through the website, the way a browser does without JavaScript: save info, publish, choosing a category (and undoing it), a moderator's save with and without a reason, save locks, and undo, including a creator's undo refused by a lock. Each saved or refused correctly with the right message.
- Not tried: the upload form's redirect after a real upload to a running catalog.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 14:23:26 +00:00
dhkatz 2cbedd26fc feat: permissions, a scoped console, and catalog management (#7)
Release / release (push) Successful in 4s
CI / test (push) Successful in 1m8s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 14s
Phase 1 of `docs/console.md` (builds on #6, whose docs commit it includes; merge #6 first).

**API**
- `permissions.ts`: `resource:action` permissions, built-in roles as bundles at a scope (hub, catalog, product), `can`/`require`, "may give" checks. A missing permission answers 403 with `missing: {permission, scope}`.
- Catalog management: settings (incl. rating ceiling, upload policy, mirroring), key rotation, operators, membership (open / approval / invite links, bans), delisting, suspension by moderators, and `GET /api/v1/catalog/changes` for catalogs.
- Membership is enforced: getting a product (403 `members_only` with the catalogs to join), the library list, and outfits relayed to others. Seeing products still needs no membership.
- Console for everyone who controls a catalog, product or hosted room; `/console/audit` (tagged entries, scoped by permission, with filters), `/console/permissions`; `/me` says whether the console is for them.
- Migration 0010: `role_assignment`, `catalog_member`, `catalog_invite`, `catalog_change`, catalog settings, audit `tags` (backfilled from old entries).

**Website**
- Console overview by what you manage; audit log page with filters; catalog list scoped to your teams; a catalog management page (settings, key, team, members, invitations, products, moderation, recent changes).
- Public catalog pages with join / ask / accept invitation / leave; product pages say "members only" and link the catalogs.

**Checked**: 43 API tests pass (8 new in `catalogs.test.ts`); API, web and db type checks; every new page rendered against a local hub for owner, operator, member, outsider and admin; the Eden DELETE-with-query calls tested against the API. Server-action forms weren't clicked through in a browser.

Not in this PR (later phases): catalog deletion requests (with cases), drafts and moderator product edits, notifications, API tokens, two-factor, imports, charts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Phase 1 of `docs/console.md` (builds on #6, whose docs commit it includes; merge #6 first).

**API**
- `permissions.ts`: `resource:action` permissions, built-in roles as bundles at a scope (hub, catalog, product), `can`/`require`, "may give" checks. A missing permission answers 403 with `missing: {permission, scope}`.
- Catalog management: settings (incl. rating ceiling, upload policy, mirroring), key rotation, operators, membership (open / approval / invite links, bans), delisting, suspension by moderators, and `GET /api/v1/catalog/changes` for catalogs.
- Membership is enforced: getting a product (403 `members_only` with the catalogs to join), the library list, and outfits relayed to others. Seeing products still needs no membership.
- Console for everyone who controls a catalog, product or hosted room; `/console/audit` (tagged entries, scoped by permission, with filters), `/console/permissions`; `/me` says whether the console is for them.
- Migration 0010: `role_assignment`, `catalog_member`, `catalog_invite`, `catalog_change`, catalog settings, audit `tags` (backfilled from old entries).

**Website**
- Console overview by what you manage; audit log page with filters; catalog list scoped to your teams; a catalog management page (settings, key, team, members, invitations, products, moderation, recent changes).
- Public catalog pages with join / ask / accept invitation / leave; product pages say "members only" and link the catalogs.

**Checked**: 43 API tests pass (8 new in `catalogs.test.ts`); API, web and db type checks; every new page rendered against a local hub for owner, operator, member, outsider and admin; the Eden DELETE-with-query calls tested against the API. Server-action forms weren't clicked through in a browser.

Not in this PR (later phases): catalog deletion requests (with cases), drafts and moderator product edits, notifications, API tokens, two-factor, imports, charts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 13:13:42 +00:00
dhkatz ed3a188b82 docs: permissions, console and imported IMVU products (#6)
Release / release (push) Successful in 5s
CI / test (push) Successful in 1m4s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 21s
Design for the next phases (discussed 2026-11-04):

- `docs/console.md` (new): permissions (`resource:action`), roles as bundles given at a scope (hub, catalog, product, room), one console for everyone who controls something, a tagged audit log scoped by permission, cases (reports, appeals, reviews, catalog deletion), notifications, API tokens, two-factor sign-in for sensitive permissions, "why can't I" and view-as, charts.
- `docs/imports.md` (new): catalogs importing IMVU products with the creator's grant (whole shop or chosen products), admin-recorded grants, requests, unclaimed creators, the job queue, `t5de-importer`, withdrawal, the spike.
- `docs/products.md`: drafts, unlisted products, membership enforced, catalog management routes, moderator edits, info history and locks, file blocks, the catalogs' changes feed, mirroring, the `t5de-import` token and importer routes.
- `docs/design.md`: pointers, phases and the later list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Design for the next phases (discussed 2026-11-04):

- `docs/console.md` (new): permissions (`resource:action`), roles as bundles given at a scope (hub, catalog, product, room), one console for everyone who controls something, a tagged audit log scoped by permission, cases (reports, appeals, reviews, catalog deletion), notifications, API tokens, two-factor sign-in for sensitive permissions, "why can't I" and view-as, charts.
- `docs/imports.md` (new): catalogs importing IMVU products with the creator's grant (whole shop or chosen products), admin-recorded grants, requests, unclaimed creators, the job queue, `t5de-importer`, withdrawal, the spike.
- `docs/products.md`: drafts, unlisted products, membership enforced, catalog management routes, moderator edits, info history and locks, file blocks, the catalogs' changes feed, mirroring, the `t5de-import` token and importer routes.
- `docs/design.md`: pointers, phases and the later list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-05 13:05:58 +00:00
dhkatz d5cd2be5d0 feat: age verification with an ID document (Didit), and closed registration (#4)
Release / release (push) Successful in 4s
CI / test (push) Successful in 1m19s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 7s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 20s
## Age verification with Didit

A new provider (`apps/api/src/age/didit.ts`) behind the existing vendor-neutral age interface. A person verifies once with an ID document, a passive liveness check and a face match; after that, all their linked IMVU accounts count as adult. The hub keeps only "over 18": never the document, photo or birth date.

- Webhooks are checked against Didit's signatures (the raw body or V2, with `-Previous` during secret rotation), and old ones are rejected. If the webhook has no decision, the hub fetches it from Didit.
- A later answer replaces an earlier one. An approval that's taken back (say, after a manual review) becomes `revoked`. A check still in review stays pending.
- Limits, for every provider: one unfinished check per person (starting again resumes it), at most 3 checks in 30 days, none once verified, and a monthly cap (`DIDIT_MONTHLY_LIMIT`, default 30: since 2026-11-01, Didit's free plan is $10 of credit a month, and a check costs $0.30).
- People are only ever sent back to the hub's website.
- The Account page gets an Age Verification card.
- Configuration: `AGE_PROVIDERS=imvu,didit`, `DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. The webhook is `<API_URL>/api/v1/age/webhook/didit`.

## Closed registration

Registration is open or closed. It's an admin's setting in the console, with its default from `REGISTRATION`.

- When it's closed, new accounts can only sign in until a moderator approves them, or declines them (which deletes the account).
- The website shows people who are waiting a waiting page, and the API treats them as signed out everywhere but `/me`.
- The console gets a Waiting count, a Waiting for approval filter, Approve and Decline on a person's page, and a Registration setting.
- The migration adds `user.approved` (existing accounts stay approved) and `hub_setting`.

## Testing

- New tests cover Didit (with a fake Didit server), the limits, revocation, return addresses and registration.
- `bun run test`: 35 pass. `bun run check-types` passes.
- Checked locally: the account, waiting, sign-up and console pages render.
- Not run against Didit itself yet. The sandbox workflow "T5DE Age Check" is published, and the next step is a real webhook once this is on staging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

## Age verification with Didit

A new provider (`apps/api/src/age/didit.ts`) behind the existing vendor-neutral age interface. A person verifies once with an ID document, a passive liveness check and a face match; after that, all their linked IMVU accounts count as adult. The hub keeps only "over 18": never the document, photo or birth date.

- Webhooks are checked against Didit's signatures (the raw body or V2, with `-Previous` during secret rotation), and old ones are rejected. If the webhook has no decision, the hub fetches it from Didit.
- A later answer replaces an earlier one. An approval that's taken back (say, after a manual review) becomes `revoked`. A check still in review stays pending.
- Limits, for every provider: one unfinished check per person (starting again resumes it), at most 3 checks in 30 days, none once verified, and a monthly cap (`DIDIT_MONTHLY_LIMIT`, default 30: since 2026-11-01, Didit's free plan is $10 of credit a month, and a check costs $0.30).
- People are only ever sent back to the hub's website.
- The Account page gets an Age Verification card.
- Configuration: `AGE_PROVIDERS=imvu,didit`, `DIDIT_API_KEY`, `DIDIT_WORKFLOW_ID`, `DIDIT_WEBHOOK_SECRET`. The webhook is `<API_URL>/api/v1/age/webhook/didit`.

## Closed registration

Registration is open or closed. It's an admin's setting in the console, with its default from `REGISTRATION`.

- When it's closed, new accounts can only sign in until a moderator approves them, or declines them (which deletes the account).
- The website shows people who are waiting a waiting page, and the API treats them as signed out everywhere but `/me`.
- The console gets a Waiting count, a Waiting for approval filter, Approve and Decline on a person's page, and a Registration setting.
- The migration adds `user.approved` (existing accounts stay approved) and `hub_setting`.

## Testing

- New tests cover Didit (with a fake Didit server), the limits, revocation, return addresses and registration.
- `bun run test`: 35 pass. `bun run check-types` passes.
- Checked locally: the account, waiting, sign-up and console pages render.
- Not run against Didit itself yet. The sandbox workflow "T5DE Age Check" is published, and the next step is a real webhook once this is on staging.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-04 11:37:21 +00:00
release-bot 86ebe976ba chore(release): v0.2.0 (#3)
Release / release (push) Successful in 3s
CI / test (push) Successful in 1m10s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 6s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 4s
Merging this releases **v0.2.0**: release-bot tags it on main, CI builds its image, and Komodo deploys it to prod. It updates itself as pull requests are merged.

## 0.2.0 (2026-11-03)

### Features

- **web:** Download link to t5de.dev in the header (#2)

Merging this releases **v0.2.0**: release-bot tags it on main, CI builds its image, and Komodo deploys it to prod. It updates itself as pull requests are merged.

## 0.2.0 (2026-11-03)

### Features

- **web:** Download link to t5de.dev in the header (#2)

Co-authored-by: release-bot <[email protected]>
2026-11-03 11:29:26 +00:00
dhkatz efd5d52198 feat(web): Download link to t5de.dev in the header (#2)
Release / release (push) Successful in 3s
CI / test (push) Successful in 1m2s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 4s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 12s
A Download link in the header, beside the account menu or Sign in, to t5de.dev/download: T5DE's website with the latest setup, its version and checksum. Gitea's release pages are confusing for people new to it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

A Download link in the header, beside the account menu or Sign in, to t5de.dev/download: T5DE's website with the latest setup, its version and checksum. Gitea's release pages are confusing for people new to it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: dhkatz <[email protected]>
2026-11-03 10:54:20 +00:00
dhkatz f8ab3f965e ci: release pull requests, and conventional-commit PR titles (#1)
Release / release (push) Successful in 2s
CI / test (push) Successful in 57s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s
Releases as pull requests, the same in all five app repos (hub, catalog, live, riftbound, karaoke). The Gitea side (PRs only into main, squash merges, required checks, `v*` tags only by release-bot, its `RELEASE_TOKEN` org secret) is dhkatz/infra `modules/app-repo`.

- **`.gitea/workflows/pull-request.yml`**: the PR title must be a conventional commit (`feat(api): …`, `fix: …`, `feat!: …`); it becomes the commit on main. `chore(release)` only from release-bot's `release` branch. Required to merge (`Pull request / title`).
- **`.gitea/default_merge_message/SQUASH_TEMPLATE.md`**: the squash commit is `<PR title> (#<n>)` plus the description.
- **`.gitea/workflows/release.yml`**, on every push to main, as release-bot (`RELEASE_TOKEN`):
  - if the commit is a merged `chore(release): vX.Y.Z`: tag it vX.Y.Z (lightweight), push the tag (CI builds the image, Komodo deploys prod), publish the release with git-cliff's notes;
  - otherwise, if anything releasable landed since the last tag: rewrite the `release` branch (CHANGELOG.md) and open or update `chore(release): vX.Y.Z`.
- **`cliff.toml`** (git-cliff 2.14.2, pinned by sha512): feat → minor, fix/perf/revert → patch, breaking → minor while 0.x; chore/ci/docs/test/style/build/refactor alone don't release. Only conventional commits count, so the history before this is left out.
- **`CHANGELOG.md`**: header only; release PRs fill it.
- Agent notes (CLAUDE.md / AGENTS.md): the PR flow and title format; never tag by hand.

After merging, `Release` runs on main and should report nothing to release (this PR is `ci:`); the first `feat:`/`fix:` PR merged after it opens the first release PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #1
Co-authored-by: dhkatz <[email protected]>
2026-11-03 08:40:29 +00:00
dhkatzandClaude Opus 5.5 21e1bd8790 A slim Staging bar on staging
CI / test (push) Successful in 57s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 5s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 5s
Shown above the header when the site is served from a staging. address, or with HUB_ENVIRONMENT=staging
(production hides it), read per request since staging and production run the same image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 09:09:22 -08:00
dhkatzandClaude Opus 5.5 975e11e077 CI builds and pushes the images; Komodo only deploys
CI / test (push) Successful in 54s
CI / image (apps/web/Dockerfile, t5de-hub-web) (push) Successful in 4s
CI / image (apps/api/Dockerfile, t5de-hub-api) (push) Successful in 7s
After the tests pass, main pushes :latest-main and :main-<commit>, and vX.Y.Z tags push :X.Y.Z, :X.Y and
:latest to git.t5de.dev (registry login: the t5de org secret REGISTRY_TOKEN). Komodo deploys them once the
ref's CI has passed; it no longer builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:53:10 +00:00
dhkatzandClaude Opus 5.5 7d20426d1b CI only tests again: Komodo builds and deploys
CI / test (push) Successful in 1m10s
Komodo now watches the t5de org (Gitea webhook → its t5de-push action, defined in t5de-infra): it builds and
deploys a commit to staging once this CI passes on main, and to prod for vX.Y.Z tags. So the deploy job and
.gitea/scripts/deploy.py go, and this repo no longer needs to know how it's deployed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:47:58 +00:00
dhkatzandClaude Opus 5.5 e3770ceb57 Deploy with Komodo: main to staging, vX.Y.Z to prod
CI / test (push) Successful in 1m6s
CI / deploy (push) Successful in 1m15s
CI now builds the API and website images after the tests, pushes them to git.t5de.dev, and deploys them through
Komodo (.gitea/scripts/deploy.py): pushes to main as :main to hub.staging.t5de.dev, vX.Y.Z tags as :X.Y.Z to
hub.t5de.dev, with the version pinned in t5de/t5de-infra.

The API image installs with --linker hoisted: Bun's isolated layout keeps the workspace links (@t5de/db) in each
package's own node_modules, which the image didn't copy, so the API couldn't start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:16:05 -07:00
dhkatzandClaude Opus 5.5 97ce2be569 Deleting products: by their creators, and removal by moderation
CI / test (push) Successful in 1m4s
A creator deletes a product: it's no longer listed and nobody new can get it, while people who have it
keep it (its page, files and library entries stay, and rooms keep showing it), as old revisions stay
available. Moderation removes one everywhere, with a reason: its page and files answer 410, and it
leaves libraries and rooms. Both are on the product's page and in the audit log.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:10:24 -07:00
dhkatzandClaude Opus 5.5 07bbc11565 Website header: the T5DE logo, four links, and a profile menu
CI / test (push) Successful in 1m11s
The header shows T5DE's logo (the client's, also the tab icon) and Online, Rooms, Products and
Catalogs; the person's pages (Account, Library, Console for moderators) and signing out are in a menu
under their picture: their first linked IMVU account's avatar picture, which /me now gives (kept an
hour), or their initial. Creating a product moves to Products (New product, /products/new; /create
redirects there), as creating a room is on Rooms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:18:26 -07:00
dhkatzandClaude Opus 5.5 76c8e0342e Hosted rooms phase 2: moderators, bans, privacy, invites, images, favorites, ratings, recent rooms, occupancy watch
CI / test (push) Successful in 1m10s
Moderators (up to 20 IMVU accounts, set by ID or name) and the owner account boot
and ban over realtime (room.boot); bans are listed and lifted over REST. Private
rooms are unlisted and joined by the owner, moderators and holders of an accepted
invite (room.invite / room.invited / room.invite_reply / room.invite_answer).
Room images (PNG/JPEG by magic bytes, up to 512 KB) are stored in the database
and served with cache headers. Per-person favorites, ratings and recent rooms.
room.watch pushes room.occupancy; participants carry their IMVU name, and
GET .../participants lists them. Contract in docs/hosted-rooms.md, "Phase 2".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 06:07:41 -07:00
dhkatzandClaude Opus 5.5 43183ec7ee Hosted rooms' instance IDs name their owner: <owner>-<2100000000+n>
CI / test (push) Successful in 58s
The client takes a room's owner from the instance ID's first part; with 2100000000 there, the owner
couldn't decorate their hosted room (putting furniture on went to their home room instead).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:18:19 -07:00
dhkatzandClaude Opus 5.5 f65f36c1ea Decorations over IMVU rooms: the hub's side
CI / test (push) Successful in 1m13s
T5 products placed in ordinary IMVU rooms, kept per room instance
(room_decoration, migration 0006). GET /api/v1/decorations/:instance (and
?instances= for up to 20) gives what the viewer may see, with a hash;
PUT is for the room owner's T5DE person only, checks the products exist,
and tells every realtime session with a chat in that room ({"type":
"decor"}). Hosted rooms' IDs are refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:58:42 -07:00
dhkatzandClaude Opus 5.5 2a1fc94df6 Hosted rooms: the hub's side
CI / test (push) Successful in 1m5s
Rooms made on the website (hosted_room, migration 0005), the REST routes in
docs/hosted-rooms.md, and the realtime room.* protocol: joining with the
capacity, adult and linked-account rules, seats, numbered messages with
forged senders dropped and a rate limit, closing. The Rooms page lists them,
with a form to make one and the owner's edit and close.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:21:21 -07:00
dhkatzandClaude Opus 5.5 0c67232425 Badges: the proxy asks which IMVU accounts show the T5 badge
CI / test (push) Successful in 49s
POST /api/v1/badges {imvuIds} (at most 200) answers the linked accounts whose
badge is on, of people who aren't banned, in one query. It uses the existing
per-account showBadge (off by default, set from the account page).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:38:01 -07:00
dhkatzandClaude Opus 5.5 6668b55117 Products: carry the IMVU parent's image and categories
CI / test (push) Successful in 49s
Revisions with an IMVU parent store its public info (image, category path, gender)
from IMVU's product API, looked up after registering and in a periodic pass.
Product views gain parent, image and categories; GET /products takes category and
limit. The website shows the image on cards and product pages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:03:02 -07:00
dhkatzandClaude Opus 5.5 d57674d90c Products: images need https addresses; lowering a rating waits for review; library shows creators
CI / test (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:14:12 -07:00
dhkatzandClaude Opus 5.5 51d5bb5ff3 Website: products, library, catalogs and their console page
/products (search), product pages with revisions, getting and staying on a
revision, retracting; /products/:id/edit; /create and new revisions upload
from the browser straight to the catalog with a token from the hub;
/library; /catalogs (directory, registering with a once-shown key);
/console/catalogs to approve or reject. Every page now sends a nonce-based
CSP (proxy.ts), so product descriptions can't run scripts.

buttonVariants now merges classes, so outline links get their border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:06:08 -07:00
dhkatzandClaude Opus 5.5 b4f6fce9df Product registry: catalogs, revisions, product info and libraries
CI / test (push) Successful in 43s
The hub registers catalogs (hidden until a moderator approves them), hands out upload tokens, and records the
revisions catalogs register after validating uploads: numbered, never changed, signed by the hub (its own
Ed25519 key, published at /.well-known/t5de-keys.json). Creators edit product info (descriptions cleaned by an
allowlist) and can retract revisions. People get products into their library, optionally staying on a
revision; the proxy asks which revision to load and where. Contract in docs/products.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:53:52 -07:00
dhkatzandClaude Opus 5.5 7fea63fc4f Design: everywhere the T5 badge appears
CI / test (push) Successful in 37s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:41:43 -07:00
dhkatzandClaude Opus 5.5 143aeb3d84 Design: T5 products in the client's shop and inventory
CI / test (push) Successful in 40s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:38:23 -07:00
dhkatzandClaude Opus 5.5 6ba4d83317 Room channel and rooms list
CI / test (push) Successful in 6m38s
The realtime connection now carries the IMVU chats a client is in: the hub tells each proxy in a chat which
other T5DE users are there and what community products they wear (adult products only to adult accounts), on
joining and on every change. Rooms T5DE users enter are looked up on IMVU; those that are public, listed and
whose owner turned on "List my public rooms" appear on the new Rooms page, with the T5DE users in them who
show they're online. AP rooms are shown to adults only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:14:43 -07:00
dhkatzandClaude Opus 5.5 7db9fc0370 Vendor-neutral age verification, with IMVU as the first provider
CI / test (push) Successful in 35s
Age checks are recorded in one table (age_check) by provider, method and result, never with documents or
birth dates; one rule decides whether an IMVU account counts as adult (its person's check, or its own).
Providers implement a small interface (start, webhook or browser completion, re-check) and are chosen by
configuration, so phone wallets and Didit can be added, or swapped, as adapters. IMVU's answer per account
is re-checked when it expires (about daily). Existing links' results move into the new table.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:44:36 -07:00
dhkatzandClaude Opus 5.5 339ba8e2d6 Presence: count an account as soon as it's linked; accept an empty outfit
CI / test (push) Successful in 36s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:07:28 -07:00
144 changed files with 48535 additions and 259 deletions

No files matched your search

@@ -0,0 +1,3 @@
${PullRequestTitle} (#${PullRequestIndex})
${PullRequestDescription}
+37 -1
View File
@@ -1,9 +1,11 @@
# Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub. # Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub; then, for main and vX.Y.Z
# tags, builds its images and pushes them to git.t5de.dev. Komodo deploys them (dhkatz/infra); CI never deploys.
name: CI name: CI
on: on:
push: push:
branches: [main] branches: [main]
tags: ["v*"]
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -32,3 +34,37 @@ jobs:
- name: Build - name: Build
run: bun run build run: bun run build
# main → :latest-main and :main-<commit>; vX.Y.Z → :X.Y.Z, :X.Y and :latest. Built by the runner's Docker.
image:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { image: t5de-hub-api, dockerfile: apps/api/Dockerfile }
- { image: t5de-hub-web, dockerfile: apps/web/Dockerfile }
steps:
- uses: actions/checkout@v4
- name: Build and push
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
IMAGE: git.t5de.dev/t5de/${{ matrix.image }}
DOCKERFILE: ${{ matrix.dockerfile }}
run: |
case "$GITHUB_REF" in
refs/heads/main) version="main-${GITHUB_SHA::12}"; tags="latest-main $version" ;;
refs/tags/v*)
version="${GITHUB_REF#refs/tags/v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Not a vX.Y.Z tag: no image"; exit 0; }
tags="$version ${version%.*} latest" ;;
*) echo "Not main or a tag: no image"; exit 0 ;;
esac
echo "$REGISTRY_TOKEN" | docker login git.t5de.dev -u deploy --password-stdin
docker build -f "$DOCKERFILE" --label org.opencontainers.image.source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
--label org.opencontainers.image.revision="$GITHUB_SHA" --label org.opencontainers.image.version="$version" \
$(for t in $tags; do echo "-t $IMAGE:$t"; done) .
for t in $tags; do docker push -q "$IMAGE:$t"; done
docker logout git.t5de.dev
+30
View File
@@ -0,0 +1,30 @@
# A pull request's title becomes its commit on main (squash merges, .gitea/default_merge_message), so it must be a
# conventional commit: the release workflow versions releases and writes the changelog from them. Required to merge
# (branch protection, dhkatz/infra modules/app-repo).
name: Pull request
on:
pull_request:
types: [opened, edited, reopened, synchronize]
jobs:
title:
runs-on: ubuntu-latest
steps:
- name: Conventional commit title
env:
TITLE: ${{ github.event.pull_request.title }}
BRANCH: ${{ github.head_ref }}
AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
types="feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert"
conventional="^($types)(\([a-z0-9][a-z0-9./_-]*\))?!?: [^ ]"
if ! [[ "$TITLE" =~ $conventional ]]; then
echo "::error::The title becomes the commit on main, so it must be a conventional commit: type(scope): summary, e.g. \"feat(api): room bans\" or \"fix: crash on an empty catalog\" (! before the colon for a breaking change). Types: ${types//|/, }."
exit 1
fi
if [[ "$TITLE" =~ ^chore\(release\) ]] && [[ "$BRANCH" != release || "$AUTHOR" != release-bot ]]; then
echo "::error::chore(release) is release-bot's: merging its release pull request is what tags a release."
exit 1
fi
echo "OK: $TITLE"
+86
View File
@@ -0,0 +1,86 @@
# Releases. release-bot keeps one pull request open from the `release` branch, "chore(release): vX.Y.Z": the next
# version and its CHANGELOG.md entry, which git-cliff (cliff.toml) works out from the conventional commits on main
# since the last tag. Merging it makes release-bot tag vX.Y.Z on main and publish the release notes; the tag builds the
# image (ci.yml) and Komodo deploys it to prod (dhkatz/infra). Only release-bot may create v* tags. It acts with its
# own token (the org secret RELEASE_TOKEN), not this workflow's: Gitea starts no workflows for what that one does, so
# the release pull request's checks and the tag's build would never run.
name: Release
on:
push:
branches: [main]
concurrency:
group: release
jobs:
release:
runs-on: ubuntu-latest
env:
CLIFF_VERSION: 2.14.2
CLIFF_SHA512: 95b0c55ab3e351ca7c0285fbbde8f1e8c42906466a173e6884f5c7ca814523bda44cbd4ab26b0aa28cace4c02c5ee6b553e9742bd9d7df47a73b9ef58ea429a9
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Install git-cliff
run: |
archive="git-cliff-$CLIFF_VERSION-x86_64-unknown-linux-musl.tar.gz"
curl -fsSLO "https://github.com/orhun/git-cliff/releases/download/v$CLIFF_VERSION/$archive"
echo "$CLIFF_SHA512 $archive" | sha512sum -c -
mkdir -p "$HOME/.local/bin"
tar -xzf "$archive" --strip-components=1 -C "$HOME/.local/bin" "git-cliff-$CLIFF_VERSION/git-cliff"
rm "$archive"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Tag a merged release, or update the release pull request
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
host="${GITHUB_SERVER_URL#https://}"
git config user.name release-bot
git config user.email "release-bot@${host#git.}"
basic=$(printf 'release-bot:%s' "$TOKEN" | base64 -w0)
push() { git -c http.extraHeader="Authorization: Basic $basic" push "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git" "$@"; }
gitea() { curl -fsS -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$@"; }
# The release pull request was merged: tag its commit and publish the release.
if [[ "$(git log -1 --format=%s)" =~ ^chore\(release\):\ (v[0-9]+\.[0-9]+\.[0-9]+) ]]; then
tag="${BASH_REMATCH[1]}"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then echo "$tag exists already"; exit 0; fi
git tag "$tag"
push "refs/tags/$tag"
jq -n --arg tag "$tag" --arg body "$(git cliff --latest --strip all)" '{tag_name: $tag, name: $tag, body: $body}' |
gitea -o /dev/null -X POST "$api/releases" -d @-
echo "Released $tag"
exit 0
fi
# Otherwise: anything to release since the last tag? (Nothing printed when no commit counts.)
last=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)
next=$(git cliff --bumped-version 2>/dev/null || true)
next="v${next#v}"
if [ "$next" = v ] || [ "$next" = "$last" ]; then echo "Nothing to release since ${last:-the start}"; exit 0; fi
notes=$(git cliff --unreleased --bump --strip all)
git switch -C release
git cliff --bump --output CHANGELOG.md
git add CHANGELOG.md
git commit -q -m "chore(release): $next"
push --force HEAD:refs/heads/release
title="chore(release): $next"
body=$(printf 'Merging this releases **%s**: release-bot tags it on main, CI builds its image, and Komodo deploys it to prod. It updates itself as pull requests are merged.\n\n%s\n' "$next" "$notes")
payload=$(jq -n --arg title "$title" --arg body "$body" '{title: $title, body: $body}')
open=$(gitea "$api/pulls?state=open&limit=50" | jq -r '[.[] | select(.head.ref == "release")][0].number // empty')
if [ -n "$open" ]; then
gitea -o /dev/null -X PATCH "$api/pulls/$open" -d "$payload"
echo "Updated the release pull request #$open: $title"
else
jq '. + {head: "release", base: "main"}' <<<"$payload" | gitea -o /dev/null -X POST "$api/pulls" -d @-
echo "Opened the release pull request: $title"
fi
+44
View File
@@ -0,0 +1,44 @@
# Changelog
Written by release-bot's release pull requests, from the titles of the pull requests merged since the last release.
## 0.2.0 (2026-11-03)
### Features
- **web:** Download link to t5de.dev in the header (#2)
## 0.1.0 (2026-11-02)
### Badges
- The proxy asks which IMVU accounts show the T5 badge
### CI
- Test against a Postgres service (embedded Postgres won't run as root)
### Design
- T5 products in the client's shop and inventory
- Everywhere the T5 badge appears
### Hub
- Accounts, IMVU linking by tagline, proxy sign-in, presence, console
### Presence
- Count an account as soon as it's linked; accept an empty outfit
### Products
- Images need https addresses; lowering a rating waits for review; library shows creators
- Carry the IMVU parent's image and categories
### Website
- Next.js app for accounts, linking, who's online, device sign-in and the console
- Products, library, catalogs and their console page
+9 -1
View File
@@ -10,6 +10,13 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
in our own words are fine. in our own words are fine.
- Test with a throwaway IMVU account, never a real one. - Test with a throwaway IMVU account, never a real one.
- Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email. - Commits: author "dhkatz <dev@t5de.dev>"; never the user's real name or email.
- Changes reach main only through pull requests, squash-merged: the PR's title becomes the commit on main, so it must
be a conventional commit (`feat(api): …`, `fix(web): …`, `docs: …`; `!` before the colon for a breaking change),
checked by `.gitea/workflows/pull-request.yml`. Commits on the branch can be anything. Push a branch and open the
pull request on git.t5de.dev (web UI or API); the user merges.
- Releases: release-bot keeps a `chore(release): vX.Y.Z` pull request open (`release.yml`, git-cliff from those
titles); merging it tags vX.Y.Z, which builds the image and deploys prod. Never tag by hand (v* tags are
release-bot's).
- IMVU session keys never leave the user's machine; the hub never asks for them. - IMVU session keys never leave the user's machine; the hub never asks for them.
## Notes ## Notes
@@ -26,4 +33,5 @@ T5DE's hub (see README.md and docs/design.md). Related repositories, usually che
(`t5de-proxy...`). (`t5de-proxy...`).
- On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no - On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no
`bunx`: use `bun x`). `bunx`: use `bun x`).
- Calls to IMVU (`apps/api/src/imvu.ts`, its public user API) are rare and rate-limited: linking and age re-checks. - Calls to IMVU (`apps/api/src/imvu.ts`, its public API) are rare and rate-limited: linking, age re-checks, rooms,
and the IMVU products T5 products derive from (`parent_info`, looked up once per revision).
+11 -2
View File
@@ -8,15 +8,21 @@ catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.
## What works ## What works
- **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is - **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is
an admin; roles user, moderator, admin. an admin; roles user, moderator, admin. Registration open or closed (new accounts wait for a moderator's
approval): an admin's setting in the console, defaulting to the API's `REGISTRATION`.
- **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public - **Linking IMVU accounts**: the person puts a code in the account's tagline; the hub reads it from IMVU's public
API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person
(default 10). (default 10).
- **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub. - **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub, or
if its person verified their age with an ID document and a selfie (Didit, when configured: `AGE_PROVIDERS`, see
`apps/api/src/index.ts` and docs/design.md, "Age").
- **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the - **Proxy sign-in**: OAuth 2.0 device authorization (`t5de-proxy signin` shows a code to enter at `/device`); the
proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs. proxy then uses its session as a bearer token. JWTs with published keys (`/.well-known/jwks.json`) for catalogs.
- **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in - **Presence**: signed-in proxies keep a WebSocket (`/api/v1/realtime`) telling the hub which IMVU account is in
use and the community products it wears. `/online` shows those who opted in, live, with the opt-in T5DE badge. use and the community products it wears. `/online` shows those who opted in, live, with the opt-in T5DE badge.
- **Products** (website): browsing and search, product pages, uploads straight from the browser to a catalog,
editing, revisions (retract, stay on one), the library, and registering catalogs (`docs/products.md`). Pages send
a strict Content Security Policy (`apps/web/proxy.ts`).
- **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log. - **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log.
## Layout ## Layout
@@ -46,4 +52,7 @@ Needs Bun (1.4) and Node (for Next.js).
that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its that runs Bun or containers (Railway, Render, Fly.io, Heroku), with `DATABASE_URL`, `AUTH_SECRET`, `API_URL` (its
public address) and `WEB_URL` (the website's). public address) and `WEB_URL` (the website's).
A staging site shows a slim "Staging" bar: when it's served from a `staging.` address (`hub.staging.t5de.dev`),
or with the website's `HUB_ENVIRONMENT=staging` (`production` hides it).
T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`. T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`.
+3 -1
View File
@@ -5,7 +5,9 @@ COPY package.json bun.lock ./
COPY apps/api/package.json apps/api/ COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/ COPY apps/web/package.json apps/web/
COPY packages/db/package.json packages/db/ COPY packages/db/package.json packages/db/
RUN bun install --frozen-lockfile --production --filter @t5de/api # Hoisted, so everything (the workspace packages too) is in the node_modules copied below; Bun's default isolated
# layout keeps links in each package's own node_modules.
RUN bun install --frozen-lockfile --production --filter @t5de/api --linker hoisted
FROM oven/bun:1.4.4-slim FROM oven/bun:1.4.4-slim
WORKDIR /app WORKDIR /app
+4 -1
View File
@@ -14,10 +14,13 @@
"dependencies": { "dependencies": {
"@t5de/db": "workspace:*", "@t5de/db": "workspace:*",
"better-auth": "1.7.14", "better-auth": "1.7.14",
"elysia": "1.4.30" "elysia": "1.4.30",
"jose": "6.2.14",
"sanitize-html": "2.18.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.4.4", "@types/bun": "^1.4.4",
"@types/sanitize-html": "^2.16.2",
"typescript": "^7.0.2" "typescript": "^7.0.2"
} }
} }
+123
View File
@@ -0,0 +1,123 @@
// Age from an ID document and a selfie, by Didit's hosted check (https://docs.didit.me). Its workflow (set up in
// Didit's console) checks the document, liveness and that the face matches the document; the hub only keeps whether
// the person is over 18, worked out from the age Didit read off the document. Didit keeps the rest.
//
// A check: the hub creates a session (vendor_data is the person's hub ID, so Didit gives an unfinished session
// again rather than a new one), sends the person to its page, and Didit reports the outcome by a signed webhook.
import { createHmac, timingSafeEqual } from "node:crypto";
import type { AgeResult, Provider } from "./index";
export type DiditConfig = {
apiKey: string;
workflowId: string;
/** The webhook destination's secret_shared_key. */
webhookSecret: string;
/** Checks started a month at most (what the month's credit pays for). */
monthlyLimit?: number;
baseURL?: string;
fetch?: typeof fetch;
};
/** How far a webhook's X-Timestamp may be from now. */
const timestampTolerance = 300;
/** Statuses with an answer: a session approved, or one that will never be. The rest (in progress, in review) wait. */
const failedStatuses = new Set(["Declined", "Abandoned", "Expired"]);
type Decision = { id_verifications?: { age?: number | null; date_of_birth?: string | null }[] };
/** Age in whole years on a date, from an ISO birth date. */
export function ageOn(birth: string, today: Date): number | undefined {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(birth);
if (!m) return undefined;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
let age = today.getUTCFullYear() - y;
if (today.getUTCMonth() + 1 < mo || (today.getUTCMonth() + 1 === mo && today.getUTCDate() < d)) age--;
return age;
}
/** The age on the session's ID document, or undefined if it has none. */
function documentAge(decision: Decision | undefined): number | undefined {
const id = decision?.id_verifications?.[0];
if (typeof id?.age === "number") return id.age;
return id?.date_of_birth ? ageOn(id.date_of_birth, new Date()) : undefined;
}
/** Didit's V2 signature input: the body's JSON with keys sorted, compact. */
function canonical(v: unknown): unknown {
if (Array.isArray(v)) return v.map(canonical);
if (v && typeof v === "object")
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical((v as Record<string, unknown>)[k])]));
return v;
}
function hmacMatches(secret: string, input: string, signature: string | null): boolean {
if (!signature) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(input, "utf8").digest("hex"));
const given = Buffer.from(signature.trim().toLowerCase());
return expected.length === given.length && timingSafeEqual(expected, given);
}
export function diditAgeProvider(config: DiditConfig): Provider {
const base = (config.baseURL ?? "https://verification.didit.me").replace(/\/+$/, "");
const fetcher = config.fetch ?? fetch;
const call = async (method: string, path: string, body?: unknown) => {
const res = await fetcher(base + path, {
method,
headers: { "x-api-key": config.apiKey, ...(body !== undefined ? { "Content-Type": "application/json" } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`Didit ${method} ${path}: ${res.status} ${await res.text()}`);
return res.json();
};
return {
id: "didit",
label: "ID document and selfie",
vendor: "Didit",
scope: "person",
monthlyLimit: config.monthlyLimit,
async start(subject, { returnURL }) {
const s = (await call("POST", "/v3/session/", {
workflow_id: config.workflowId,
vendor_data: subject.userId,
callback: returnURL,
})) as { session_id: string; url: string };
return { kind: "redirect", url: s.url, ref: s.session_id };
},
async webhook(request) {
const raw = await request.text();
const timestamp = Number(request.headers.get("x-timestamp"));
if (!(Math.abs(Date.now() / 1000 - timestamp) <= timestampTolerance)) throw new Error("stale webhook");
const body = JSON.parse(raw) as {
webhook_type?: string;
session_id?: string;
status?: string;
decision?: Decision;
};
// The raw body's signature, else the V2 one (of the parsed body), each also with the previous secret's header
// during a rotation.
const v2 = JSON.stringify(canonical(body));
const signed = ["", "-previous"].some(
(suffix) =>
hmacMatches(config.webhookSecret, raw, request.headers.get(`x-signature${suffix}`)) ||
hmacMatches(config.webhookSecret, v2, request.headers.get(`x-signature-v2${suffix}`)),
);
if (!signed) throw new Error("bad signature");
if (body.webhook_type !== "status.updated" && body.webhook_type !== "data.updated") return null;
if (!body.session_id || !body.status) return null;
const ref = body.session_id;
if (failedStatuses.has(body.status)) return { ref, result: "failed" };
if (body.status !== "Approved") return null;
const decision =
body.decision ?? ((await call("GET", `/v3/session/${encodeURIComponent(ref)}/decision/`)) as Decision);
const age = documentAge(decision);
if (age === undefined) return { ref, result: "failed" };
const result: AgeResult = { overEighteen: age >= 18, method: "id_document", ref };
return { ref, result };
},
};
}
+36
View File
@@ -0,0 +1,36 @@
// Age from IMVU itself: an IMVU account that IMVU has age verified, or that has Access Pass, counts as adult. Read
// from IMVU's public API; IMVU can change it (verification revoked, Access Pass lapsed), so it's checked again
// about daily.
import type { ImvuClient } from "../imvu";
import type { AgeResult, Provider, Subject } from "./index";
const recheckAfter = 24 * 60 * 60 * 1000;
export function imvuAgeProvider(imvu: ImvuClient): Provider {
const check = async (subject: Subject): Promise<AgeResult> => {
if (subject.imvuId === undefined) throw new Error("IMVU checks an IMVU account");
const u = await imvu.byId(subject.imvuId);
return {
overEighteen: u.ageVerified || u.accessPass,
method: u.ageVerified ? "imvu_age_verified" : u.accessPass ? "imvu_access_pass" : "imvu_none",
expiresAt: new Date(Date.now() + recheckAfter),
};
};
return {
id: "imvu",
label: "IMVU's age verification or Access Pass",
vendor: "IMVU",
scope: "imvu_account",
start: async (subject) => ({ kind: "done", result: await check(subject) }),
recheck: check,
};
}
/** The IMVU result from a lookup already made (when linking). */
export function imvuResult(u: { ageVerified: boolean; accessPass: boolean }): AgeResult {
return {
overEighteen: u.ageVerified || u.accessPass,
method: u.ageVerified ? "imvu_age_verified" : u.accessPass ? "imvu_access_pass" : "imvu_none",
expiresAt: new Date(Date.now() + recheckAfter),
};
}
+260
View File
@@ -0,0 +1,260 @@
// Age verification, vendor-neutral: providers (IMVU, later phone wallets and Didit) plug in behind one interface,
// results go in one table (age_check), and one rule says whether an IMVU account counts as adult.
// See docs/design.md, "Age".
import { and, count, eq, gt, gte, inArray, isNull, lte, or, schema, type Database } from "@t5de/db";
const { ageCheck } = schema;
/** What's checked: a person (all their IMVU accounts), or one of their linked IMVU accounts. */
export type Subject = { userId: string; imvuId?: number };
/** A provider's answer. Only the outcome: never a birth date, document, photo or name. */
export type AgeResult = {
overEighteen: boolean;
/** How it was established, e.g. "imvu_age_verified", "id_document", "wallet_mdoc". */
method: string;
/** The provider's session or transaction ID, if it has one. */
ref?: string;
/** When the answer should be checked again (providers whose answer can change); none: it stands. */
expiresAt?: Date;
};
/** How a check begins. */
export type Started =
| { kind: "done"; result: AgeResult } // answered at once (IMVU)
| { kind: "redirect"; url: string; ref: string } // send the person to the provider's page (document checks)
| { kind: "browser"; request: unknown; ref: string }; // hand a request to the browser (Digital Credentials API)
export type Provider = {
id: string;
/** Shown to people: the way of checking, and who does it. */
label: string;
vendor: string;
/** Whether it checks a person, or an IMVU account. */
scope: "person" | "imvu_account";
/** At most this many checks started each calendar month (UTC), for a provider's free allowance; none: no cap. */
monthlyLimit?: number;
start(subject: Subject, options: { returnURL: string }): Promise<Started>;
/**
* A check's answer from the provider's webhook, or null if it has none yet (still in progress or in review). Throws
* if the request isn't really from the provider.
*/
webhook?(request: Request): Promise<{ ref: string; result: AgeResult | "failed" } | null>;
/** Finishes a check the browser completed (wallets): the browser's response for ref. */
complete?(ref: string, response: unknown): Promise<AgeResult | "failed">;
/** Checks a subject again, for answers that can change. */
recheck?(subject: Subject): Promise<AgeResult>;
};
export class AgeError extends Error {}
/** A check that can't be started now; the message is for the person. */
export class AgeRefused extends Error {}
/** Checks of a person a provider will start for them in attemptWindow (failed and abandoned ones count). */
export const attemptsPerPerson = 3;
const attemptWindow = 30 * 24 * 60 * 60 * 1000;
const randomID = () => Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString("base64url");
export class AgeService {
private providers = new Map<string, Provider>();
constructor(
private readonly db: Database,
providers: Provider[],
) {
for (const p of providers) this.providers.set(p.id, p);
}
/** The providers in use, for the website. */
list() {
return [...this.providers.values()].map((p) => ({ id: p.id, label: p.label, vendor: p.vendor, scope: p.scope }));
}
provider(id: string): Provider {
const p = this.providers.get(id);
if (!p) throw new AgeError(`no age provider ${id}`);
return p;
}
/** Records a finished check. An IMVU account's check replaces that provider's earlier check of it. */
async record(subject: Subject, provider: string, result: AgeResult) {
const now = new Date();
const values = {
userId: subject.userId,
imvuId: subject.imvuId ?? null,
provider,
method: result.method,
status: "complete",
overEighteen: result.overEighteen,
providerRef: result.ref ?? null,
checkedAt: now,
expiresAt: result.expiresAt ?? null,
};
if (subject.imvuId !== undefined) {
await this.db
.delete(ageCheck)
.where(and(eq(ageCheck.provider, provider), eq(ageCheck.imvuId, subject.imvuId)));
}
await this.db.insert(ageCheck).values({ id: randomID(), ...values });
}
/**
* Begins a check with a provider; checks answered at once are recorded now, the rest stay pending. A person whose
* check is unfinished goes back to it (providers give the same session again); new checks of a person are
* limited, per person and by the provider's monthly allowance.
*/
async start(subject: Subject, providerId: string, returnURL: string): Promise<Started> {
const p = this.provider(providerId);
const ofPerson = subject.imvuId === undefined;
const pending = ofPerson ? await this.pendingCheck(subject.userId, p.id) : undefined;
if (ofPerson) {
if (await this.personAdult(subject.userId)) throw new AgeRefused("Your age is already verified.");
if (!pending) await this.checkLimits(subject.userId, p);
}
const started = await p.start(subject, { returnURL });
if (started.kind === "done") {
await this.record(subject, p.id, started.result);
} else if (pending?.providerRef !== started.ref) {
// An unfinished check the provider replaced with a new one (it expired there) won't be answered.
if (pending) await this.db.update(ageCheck).set({ status: "failed", checkedAt: new Date() }).where(eq(ageCheck.id, pending.id));
await this.db.insert(ageCheck).values({
id: randomID(),
userId: subject.userId,
imvuId: subject.imvuId ?? null,
provider: p.id,
status: "pending",
providerRef: started.ref,
});
}
return started;
}
private async pendingCheck(userId: string, provider: string) {
const [row] = await this.db
.select()
.from(ageCheck)
.where(
and(eq(ageCheck.userId, userId), eq(ageCheck.provider, provider), isNull(ageCheck.imvuId), eq(ageCheck.status, "pending")),
);
return row;
}
private async personAdult(userId: string) {
const now = new Date();
return (await this.personChecks(userId)).some(
(c) => c.status === "complete" && c.overEighteen && (c.expiresAt === null || c.expiresAt > now),
);
}
private async checkLimits(userId: string, p: Provider) {
const started = async (...where: Parameters<typeof and>) =>
(await this.db.select({ n: count() }).from(ageCheck).where(and(eq(ageCheck.provider, p.id), isNull(ageCheck.imvuId), ...where)))[0]!.n;
const since = new Date(Date.now() - attemptWindow);
if ((await started(eq(ageCheck.userId, userId), gte(ageCheck.createdAt, since))) >= attemptsPerPerson)
throw new AgeRefused(`You've started ${attemptsPerPerson} checks in the last 30 days. Try again later.`);
if (p.monthlyLimit !== undefined) {
const now = new Date();
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
if ((await started(gte(ageCheck.createdAt, month))) >= p.monthlyLimit)
throw new AgeRefused("This month's ID checks are used up. Try again next month.");
}
}
/**
* Finishes a check. From the browser (wallets), only a pending check; from a provider's webhook (anyStatus), also
* one already answered, since the provider can change its answer (say, after a manual review). An adult answer
* taken back is revoked.
*/
async finish(providerId: string, ref: string, result: AgeResult | "failed", { anyStatus = false } = {}) {
const [row] = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.provider, providerId), eq(ageCheck.providerRef, ref)));
if (!row || (!anyStatus && row.status !== "pending")) throw new AgeError("no such pending check");
const done =
result === "failed"
? {
status: (row.status === "complete" && row.overEighteen) || row.status === "revoked" ? "revoked" : "failed",
overEighteen: false,
checkedAt: new Date(),
}
: {
status: "complete",
overEighteen: result.overEighteen,
method: result.method,
checkedAt: new Date(),
expiresAt: result.expiresAt ?? null,
};
const [updated] = await this.db.update(ageCheck).set(done).where(eq(ageCheck.id, row.id)).returning();
return updated!;
}
/** Removes an IMVU account's checks (it was unlinked or moved to another person). */
async forgetAccount(imvuId: number) {
await this.db.delete(ageCheck).where(eq(ageCheck.imvuId, imvuId));
}
/**
* The one rule: which of a person's IMVU accounts count as adult. An account does if the person has a current
* complete check saying over 18, or the account itself has one.
*/
async adultAccounts(userId: string, imvuIds: number[]): Promise<Set<number>> {
const now = new Date();
const checks = await this.db
.select({ imvuId: ageCheck.imvuId })
.from(ageCheck)
.where(
and(
eq(ageCheck.userId, userId),
eq(ageCheck.status, "complete"),
eq(ageCheck.overEighteen, true),
or(isNull(ageCheck.expiresAt), gt(ageCheck.expiresAt, now)),
imvuIds.length ? or(isNull(ageCheck.imvuId), inArray(ageCheck.imvuId, imvuIds)) : isNull(ageCheck.imvuId),
),
);
if (checks.some((c) => c.imvuId === null)) return new Set(imvuIds);
return new Set(checks.map((c) => c.imvuId!).filter((id) => imvuIds.includes(id)));
}
/** The latest complete check of each of these IMVU accounts by a provider (for showing how they stand). */
async accountChecks(provider: string, imvuIds: number[]) {
if (imvuIds.length === 0) return new Map<number, typeof ageCheck.$inferSelect>();
const rows = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.provider, provider), eq(ageCheck.status, "complete"), inArray(ageCheck.imvuId, imvuIds)));
return new Map(rows.map((r) => [r.imvuId!, r]));
}
/** The person's own checks (not of an IMVU account), newest first. */
async personChecks(userId: string) {
const rows = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.userId, userId), isNull(ageCheck.imvuId)));
return rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/** Checks again the IMVU-account checks that expired (at most limit), with providers that can re-check. */
async recheckDue(limit = 50): Promise<number> {
const due = await this.db
.select()
.from(ageCheck)
.where(and(eq(ageCheck.status, "complete"), lte(ageCheck.expiresAt, new Date())))
.limit(limit);
let n = 0;
for (const c of due) {
const p = this.providers.get(c.provider);
if (!p?.recheck) continue;
const subject = { userId: c.userId, imvuId: c.imvuId ?? undefined };
try {
await this.record(subject, p.id, await p.recheck(subject));
n++;
} catch {
// Try again next time; the old answer stays expired (not adult) meanwhile.
}
}
return n;
}
}
+2401 -58
View File
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -5,6 +5,7 @@ import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins"; import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins";
import { count, schema, type Database } from "@t5de/db"; import { count, schema, type Database } from "@t5de/db";
import { Settings } from "./settings";
export type AuthConfig = { export type AuthConfig = {
/** The API's own address, e.g. https://hub-api.t5de.dev. */ /** The API's own address, e.g. https://hub-api.t5de.dev. */
@@ -15,12 +16,15 @@ export type AuthConfig = {
secret: string; secret: string;
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */ /** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
rateLimit?: boolean; rateLimit?: boolean;
/** Whether new accounts wait for approval (default: the hub's settings, with no environment defaults). */
settings?: Settings;
}; };
/** The proxy's client ID in device sign-in. */ /** The proxy's client ID in device sign-in. */
export const proxyClientID = "t5de-proxy"; export const proxyClientID = "t5de-proxy";
export function createAuth(db: Database, config: AuthConfig) { export function createAuth(db: Database, config: AuthConfig) {
const settings = config.settings ?? new Settings(db);
return betterAuth({ return betterAuth({
baseURL: config.apiURL, baseURL: config.apiURL,
basePath: "/api/auth", basePath: "/api/auth",
@@ -31,6 +35,7 @@ export function createAuth(db: Database, config: AuthConfig) {
user: { user: {
additionalFields: { additionalFields: {
linkCap: { type: "number", defaultValue: 10, input: false }, linkCap: { type: "number", defaultValue: 10, input: false },
approved: { type: "boolean", defaultValue: true, input: false },
}, },
}, },
// Proxies stay signed in for months; a session is extended when it's used. // Proxies stay signed in for months; a session is extended when it's used.
@@ -40,10 +45,12 @@ export function createAuth(db: Database, config: AuthConfig) {
databaseHooks: { databaseHooks: {
user: { user: {
create: { create: {
// The first account runs the hub. // The first account runs the hub; with registration closed, the others wait for approval.
before: async (u) => { before: async (u) => {
const [row] = await db.select({ n: count() }).from(schema.user); const [row] = await db.select({ n: count() }).from(schema.user);
return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } }; const first = (row?.n ?? 0) === 0;
const approved = first || (await settings.registration()) === "open";
return { data: { ...u, role: first ? "admin" : "user", approved } };
}, },
}, },
}, },
+113
View File
@@ -0,0 +1,113 @@
// File blocks (docs/products.md): moderation blocks a file network-wide by its sha256, so a removed product can't
// come back under another name. Revisions with a blocked file can't be registered, products using it are removed,
// and the catalogs hosting it are told to delete it. The routes in app.ts check permissions, audit and notify.
import { and, desc, eq, inArray, isNull, lt, sql, schema, type Database } from "@t5de/db";
import { ProductError } from "./products";
const { catalogChange, fileBlock, hosting, product, productRevision, user } = schema;
export const isSha256 = (s: string) => /^[0-9a-f]{64}$/.test(s);
/** A product with a revision using a file. */
export type FileUse = {
id: string;
name: string;
creatorId: string;
creatorUsername: string | null;
removed: boolean;
revisions: number[];
};
export class Blocks {
constructor(private db: Database) {}
async get(sha256: string) {
const [b] = await this.db
.select({ block: fileBlock, by: user.username })
.from(fileBlock)
.leftJoin(user, eq(user.id, fileBlock.byId))
.where(eq(fileBlock.sha256, sha256));
return b;
}
/** Which of these files are blocked. */
async blocked(sha256s: string[]): Promise<string[]> {
if (sha256s.length === 0) return [];
const rows = await this.db.select({ sha256: fileBlock.sha256 }).from(fileBlock).where(inArray(fileBlock.sha256, sha256s));
return rows.map((r) => r.sha256);
}
/** The products with a revision using a file (what blocking it would remove), newest first. */
async usedBy(sha256: string): Promise<FileUse[]> {
const rows = await this.db
.select({ p: product, creatorUsername: user.username, number: productRevision.number })
.from(productRevision)
.innerJoin(product, eq(product.id, productRevision.productId))
.leftJoin(user, eq(user.id, product.creatorId))
.where(sql`${productRevision.files} @> ${JSON.stringify([{ sha256 }])}::jsonb`)
.orderBy(desc(product.createdAt), productRevision.number);
const out = new Map<string, FileUse>();
for (const r of rows) {
if (r.p.deletedAt && !r.p.removedAt) continue; // deleted by its creator: nothing to remove
const u = out.get(r.p.id) ?? {
id: r.p.id,
name: r.p.name,
creatorId: r.p.creatorId,
creatorUsername: r.creatorUsername,
removed: r.p.removedAt !== null,
revisions: [],
};
u.revisions.push(r.number);
out.set(r.p.id, u);
}
return [...out.values()];
}
/**
* Blocks a file: removes the products using it (those not removed already, which are returned) and tells the
* catalogs hosting it to delete it (a `block` change).
*/
async block(sha256: string, reason: string, byId: string): Promise<FileUse[]> {
if (!isSha256(sha256)) throw new ProductError(400, "invalid", "That isn't a file's sha256 (64 hex digits).");
const uses = await this.usedBy(sha256);
const ids = uses.map((u) => u.id);
const removed = uses.filter((u) => !u.removed);
await this.db.transaction(async (tx) => {
const [made] = await tx.insert(fileBlock).values({ sha256, reason, byId }).onConflictDoNothing().returning();
if (!made) throw new ProductError(409, "blocked", "That file is blocked already.");
if (removed.length)
await tx
.update(product)
.set({ removedAt: new Date(), removedReason: reason })
.where(and(inArray(product.id, removed.map((u) => u.id)), isNull(product.removedAt)));
if (ids.length) {
const hosts = await tx
.selectDistinct({ catalogId: hosting.catalogId })
.from(hosting)
.innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)))
.where(and(inArray(hosting.productId, ids), sql`${productRevision.files} @> ${JSON.stringify([{ sha256 }])}::jsonb`));
if (hosts.length) await tx.insert(catalogChange).values(hosts.map((h) => ({ catalogId: h.catalogId, data: { type: "block", sha256 } })));
}
});
return removed;
}
/** Unblocks a file. Products it removed stay removed (moderators restore them one by one); catalogs aren't told,
* since they deleted the file and get it again only with a new upload. */
async unblock(sha256: string) {
const [gone] = await this.db.delete(fileBlock).where(eq(fileBlock.sha256, sha256)).returning();
if (!gone) throw new ProductError(404, "not_found", "That file isn't blocked.");
return gone;
}
/** Blocks, newest first. */
async list(opts: { before?: Date; limit?: number } = {}) {
return this.db
.select({ block: fileBlock, by: user.username })
.from(fileBlock)
.leftJoin(user, eq(user.id, fileBlock.byId))
.where(opts.before ? lt(fileBlock.createdAt, opts.before) : undefined)
.orderBy(desc(fileBlock.createdAt))
.limit(opts.limit ?? 50);
}
}
+231
View File
@@ -0,0 +1,231 @@
// Cases (docs/console.md): one queue for everything someone has to decide. Reports, rating reviews, removal
// requests, catalogs' deletion requests and appeals. The routes in app.ts check who may open one and carry out a
// decision's effects; this keeps the cases, their threads, and who may see and decide them.
import { and, count, desc, eq, gt, inArray, lt, or, sql, schema, type AuditTags, type Database } from "@t5de/db";
import { ProductError } from "./products";
import { Permissions, type Permission, type Who } from "./permissions";
const { caseNote, catalog, hosting, moderationCase, product, user } = schema;
export const caseKinds = ["report", "rating-review", "removal-request", "catalog-deletion", "appeal"] as const;
export type CaseKind = (typeof caseKinds)[number];
export type Case = typeof moderationCase.$inferSelect;
/** The permission that decides each kind of case (moderators hold them hub-wide). */
const decidedWith: Record<CaseKind, Permission> = {
report: "case:resolve",
"rating-review": "product:rate-lower",
"removal-request": "product:remove",
"catalog-deletion": "catalog:delete-review",
appeal: "case:resolve",
};
/** The actions people can appeal, and whom they affect: a product's creator, a catalog's owner, or the person. */
export const appealable: Record<string, "creator" | "owner" | "person"> = {
"product.remove": "creator",
"product.edit": "creator",
"product.lock": "creator",
"product.revert": "creator",
"catalog.delist": "creator",
"catalog.suspend": "owner",
"catalog.reject": "owner",
"catalog.member_ban": "person",
"catalog.member_decline": "person",
};
/** Reports a person may open in an hour. */
export const reportsPerHour = 10;
export class Cases {
constructor(
private readonly db: Database,
private readonly perms: Permissions,
) {}
async get(id: number): Promise<Case> {
const [c] = await this.db.select().from(moderationCase).where(eq(moderationCase.id, id));
if (!c) throw new ProductError(404, "not_found", "No such case.");
return c;
}
/** The open case of a kind about a resource (one each), if any; with `openedBy`, only theirs. */
async findOpen(kind: CaseKind, tags: AuditTags, openedBy?: string): Promise<Case | undefined> {
const conds = [eq(moderationCase.kind, kind), eq(moderationCase.status, "open")];
for (const [k, v] of Object.entries(tags)) if (v) conds.push(sql`${moderationCase.tags}->>${k} = ${v}`);
if (openedBy) conds.push(eq(moderationCase.openedBy, openedBy));
const [c] = await this.db.select().from(moderationCase).where(and(...conds)).limit(1);
return c;
}
async open(kind: CaseKind, openedBy: string, tags: AuditTags, reason: string, data: Record<string, unknown> = {}) {
const [c] = await this.db
.insert(moderationCase)
.values({ kind, openedBy, tags, reason: reason.trim().slice(0, 2000), data })
.returning();
return c!;
}
/** A report: at most reportsPerHour an hour, and one open report per person and resource. */
async report(openedBy: string, tags: AuditTags, reason: string) {
if (!reason.trim()) throw new ProductError(400, "reason_needed", "Say what's wrong.");
const since = new Date(Date.now() - 60 * 60 * 1000);
const [recent] = await this.db
.select({ n: count() })
.from(moderationCase)
.where(and(eq(moderationCase.kind, "report"), eq(moderationCase.openedBy, openedBy), gt(moderationCase.createdAt, since)));
if ((recent?.n ?? 0) >= reportsPerHour)
throw new ProductError(429, "rate_limited", "That's a lot of reports. Try again in an hour.");
if (await this.findOpen("report", tags, openedBy))
throw new ProductError(409, "already_reported", "You reported that already; moderators will look at it.");
return this.open("report", openedBy, tags, reason);
}
/** The catalogs hosting a product (whose teams decide reports about it). */
private async hostsOf(productId: string): Promise<string[]> {
const rows = await this.db.selectDistinct({ id: hosting.catalogId }).from(hosting).where(eq(hosting.productId, productId));
return rows.map((r) => r.id);
}
/**
* Whether someone may decide a case: moderators by the kind's permission (an appeal by a different moderator than
* the one who acted, unless an admin, flagged in the audit log); reports about a product also its hosting catalogs'
* teams (case:resolve there).
*/
async canDecide(who: Who, c: Case): Promise<boolean> {
const perm = decidedWith[c.kind as CaseKind];
if (!perm) return false;
if (this.perms.hub(who).has(perm)) return !(c.kind === "appeal" && c.data.actorId === who.id && who.role !== "admin");
if (c.kind === "report" && c.tags.product)
for (const id of await this.hostsOf(c.tags.product))
if (await this.perms.can(who, "case:resolve", { type: "catalog", id })) return true;
return false;
}
/** Whether someone may see a case: its opener, its assignee, those deciding it, and (but for reports, which keep
* who reported private) those with case:read at what it's about. */
async canSee(who: Who, c: Case): Promise<boolean> {
if (c.openedBy === who.id || c.assignee === who.id) return true;
if (this.perms.hub(who).has("case:read") || (await this.canDecide(who, c))) return true;
if (c.kind === "report") return false;
if (c.tags.catalog && (await this.perms.can(who, "case:read", { type: "catalog", id: c.tags.catalog }))) return true;
if (c.tags.product && (await this.perms.can(who, "case:read", { type: "product", id: c.tags.product }))) return true;
return false;
}
/** The cases a person may see (as canSee), newest first. */
async list(who: Who, f: { status?: string; kind?: string; mine?: boolean; before?: number; limit?: number }) {
const conds = [];
if (f.mine) conds.push(eq(moderationCase.openedBy, who.id));
else if (!this.perms.hub(who).has("case:read")) {
const teams = await this.perms.teamCatalogs(who.id);
const own = await this.perms.ownProducts(who.id);
const visible = [eq(moderationCase.openedBy, who.id), eq(moderationCase.assignee, who.id)];
if (teams.length) {
visible.push(and(sql`${moderationCase.kind} <> 'report'`, inArray(sql`${moderationCase.tags}->>'catalog'`, teams))!);
visible.push(
and(
eq(moderationCase.kind, "report"),
sql`exists (select 1 from ${hosting} h where h.product_id = ${moderationCase.tags}->>'product' and h.catalog_id in ${teams})`,
)!,
);
}
if (own.length)
visible.push(and(sql`${moderationCase.kind} <> 'report'`, inArray(sql`${moderationCase.tags}->>'product'`, own))!);
conds.push(or(...visible)!);
}
if (f.status) conds.push(eq(moderationCase.status, f.status));
if (f.kind) conds.push(eq(moderationCase.kind, f.kind));
if (f.before) conds.push(lt(moderationCase.id, f.before));
return this.db
.select()
.from(moderationCase)
.where(conds.length ? and(...conds) : undefined)
.orderBy(desc(moderationCase.id))
.limit(Math.max(1, Math.min(f.limit ?? 50, 200)));
}
/** People's usernames and resources' names for cases (to show and link them). */
async names(cases: Case[]) {
const people = new Set<string>();
const products = new Set<string>();
const catalogs = new Set<string>();
for (const c of cases) {
for (const p of [c.openedBy, c.assignee, c.decidedBy, c.tags.person]) if (p) people.add(p);
if (c.tags.product) products.add(c.tags.product);
if (c.tags.catalog) catalogs.add(c.tags.catalog);
}
const [ps, prs, cs] = await Promise.all([
people.size
? this.db.select({ id: user.id, name: user.displayUsername, username: user.username }).from(user).where(inArray(user.id, [...people]))
: [],
products.size ? this.db.select({ id: product.id, name: product.name }).from(product).where(inArray(product.id, [...products])) : [],
catalogs.size ? this.db.select({ id: catalog.id, name: catalog.name }).from(catalog).where(inArray(catalog.id, [...catalogs])) : [],
]);
const names: Record<string, string> = {};
for (const p of ps) names[p.id] = p.name ?? p.username ?? "";
for (const x of [...prs, ...cs]) names[x.id] = x.name;
return names;
}
async decide(c: Case, by: string, status: "resolved" | "declined", resolution: string) {
if (c.status !== "open") throw new ProductError(409, "decided", "That case is decided already.");
const [updated] = await this.db
.update(moderationCase)
.set({ status, resolution: resolution.trim().slice(0, 2000), decidedBy: by, decidedAt: new Date() })
.where(and(eq(moderationCase.id, c.id), eq(moderationCase.status, "open")))
.returning();
if (!updated) throw new ProductError(409, "decided", "That case is decided already.");
return updated;
}
async assign(c: Case, assignee: string | null) {
const [updated] = await this.db.update(moderationCase).set({ assignee }).where(eq(moderationCase.id, c.id)).returning();
return updated!;
}
async notes(c: Case, internalToo: boolean) {
return this.db
.select({
id: caseNote.id,
by: user.displayUsername,
byId: caseNote.byId,
body: caseNote.body,
internal: caseNote.internal,
at: caseNote.createdAt,
})
.from(caseNote)
.leftJoin(user, eq(user.id, caseNote.byId))
.where(and(eq(caseNote.caseId, c.id), internalToo ? undefined : eq(caseNote.internal, false)))
.orderBy(caseNote.id);
}
async addNote(c: Case, byId: string, body: string, internal: boolean) {
body = body.trim().slice(0, 4000);
if (!body) throw new ProductError(400, "invalid", "Write something.");
await this.db.insert(caseNote).values({ caseId: c.id, byId, body, internal });
await this.db.update(moderationCase).set({ updatedAt: new Date() }).where(eq(moderationCase.id, c.id));
}
/** Open cases a person could decide (for the console's to-dos): every one for moderators, else reports about
* products their catalogs host. */
async openToDecide(who: Who): Promise<number> {
const open = eq(moderationCase.status, "open");
if (this.perms.hub(who).has("case:resolve")) {
const [row] = await this.db.select({ n: count() }).from(moderationCase).where(open);
return row?.n ?? 0;
}
const teams = await this.perms.teamCatalogs(who.id);
if (!teams.length) return 0;
const [row] = await this.db
.select({ n: count() })
.from(moderationCase)
.where(
and(
open,
eq(moderationCase.kind, "report"),
sql`exists (select 1 from ${hosting} h where h.product_id = ${moderationCase.tags}->>'product' and h.catalog_id in ${teams})`,
),
);
return row?.n ?? 0;
}
}
+400
View File
@@ -0,0 +1,400 @@
// Managing catalogs (docs/products.md, docs/console.md): settings, keys, teams, members, invites, what they host,
// suspension, and the changes feed catalogs poll. Permission checks are the routes'; this is what they do.
import { createHash } from "node:crypto";
import { and, asc, count, desc, eq, gt, inArray, isNull, or, sql, schema, type Database } from "@t5de/db";
import { ProductError } from "./products";
const { catalog, catalogChange, catalogInvite, catalogMember, hosting, product, productRevision, roleAssignment, user } =
schema;
export type Catalog = typeof catalog.$inferSelect;
export type MemberStatus = "requested" | "member" | "banned";
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url");
// Invite codes: 10 letters and digits without look-alikes.
const inviteCode = () => {
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
return [...crypto.getRandomValues(new Uint8Array(10))].map((b) => alphabet[b % alphabet.length]).join("");
};
const plain = (s: unknown, max: number) => {
const v = typeof s === "string" ? s.trim() : "";
// eslint-disable-next-line no-control-regex
if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined;
return v;
};
/** A catalog's address, checked and without a trailing slash (https, or localhost for development). */
export function catalogURL(input: string): string {
let url: URL;
try {
url = new URL(input);
} catch {
throw new ProductError(400, "invalid", "That address isn't a URL.");
}
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1")
throw new ProductError(400, "invalid", "Catalogs need an https address.");
return url.origin + url.pathname.replace(/\/+$/, "");
}
export type CatalogSettings = {
name?: string;
url?: string;
contact?: string;
visibility?: "public" | "private";
membership?: "open" | "approval" | "invite";
maxRating?: "general" | "adult";
uploads?: "anyone" | "members" | "team";
mirrors?: "auto" | "approve" | "off";
};
export class Catalogs {
constructor(
private readonly db: Database,
/** Whether a catalog answers at an address (its /health); checked when its address changes. */
private readonly healthy: (url: string) => Promise<boolean> = defaultHealthy,
) {}
async get(id: string): Promise<Catalog> {
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, id));
if (!c) throw new ProductError(404, "not_found", "No such catalog.");
return c;
}
/** Changes a catalog's settings; returns the catalog and the fields that changed (old and new values). */
async update(id: string, input: CatalogSettings) {
const c = await this.get(id);
const set: Partial<Catalog> = {};
if (input.name !== undefined) {
const name = plain(input.name, 64);
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
set.name = name;
}
if (input.contact !== undefined) {
const contact = plain(input.contact, 200);
if (!contact) throw new ProductError(400, "invalid", "A takedown contact is needed.");
set.contact = contact;
}
if (input.url !== undefined) {
const url = catalogURL(input.url);
if (url !== c.url) {
const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, url));
if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered.");
if (!(await this.healthy(url)))
throw new ProductError(400, "unreachable", "Nothing answered at that address's /health. Is the catalog running there?");
set.url = url;
}
}
for (const k of ["visibility", "membership", "maxRating", "uploads", "mirrors"] as const)
if (input[k] !== undefined) (set as Record<string, unknown>)[k] = input[k];
const changed: Record<string, { from: unknown; to: unknown }> = {};
for (const [k, v] of Object.entries(set)) {
const before = (c as Record<string, unknown>)[k];
if (before !== v) changed[k] = { from: before, to: v };
}
if (Object.keys(changed).length === 0) return { catalog: c, changed };
const [updated] = await this.db.update(catalog).set(set).where(eq(catalog.id, id)).returning();
return { catalog: updated!, changed };
}
/** A new key for the catalog; the old one stops working at once. */
async rotateKey(id: string): Promise<string> {
await this.get(id);
const key = randomKey();
await this.db.update(catalog).set({ keyHash: sha256(key) }).where(eq(catalog.id, id));
return key;
}
/** Suspends or reinstates an approved catalog; tells it through its changes feed. */
async setSuspended(id: string, suspended: boolean, reason: string) {
const c = await this.get(id);
if (suspended && c.status !== "approved")
throw new ProductError(409, "not_approved", "Only an approved catalog can be suspended.");
if (!suspended && c.status !== "suspended") throw new ProductError(409, "not_suspended", "That catalog isn't suspended.");
const status = suspended ? "suspended" : "approved";
const [updated] = await this.db
.update(catalog)
.set({ status, statusReason: suspended ? reason : "" })
.where(eq(catalog.id, id))
.returning();
await this.change(id, { type: "status", status });
return updated!;
}
/** Deletes a catalog (after a moderator accepted its deletion request): it stays on record as deleted, nothing
* is available from it, and it learns so from its changes feed. */
async markDeleted(id: string, reason: string) {
await this.get(id);
const [updated] = await this.db
.update(catalog)
.set({ status: "deleted", statusReason: reason })
.where(eq(catalog.id, id))
.returning();
await this.change(id, { type: "status", status: "deleted" });
return updated!;
}
// ---- teams ----
async addRole(catalogId: string, personId: string, role: string, by: string) {
const c = await this.get(catalogId);
if (c.ownerId === personId) throw new ProductError(409, "owner", "That's the catalog's owner.");
await this.db
.insert(roleAssignment)
.values({ userId: personId, role, scopeType: "catalog", scopeId: catalogId, grantedBy: by })
.onConflictDoNothing();
}
async removeRole(catalogId: string, personId: string, role: string) {
const gone = await this.db
.delete(roleAssignment)
.where(
and(
eq(roleAssignment.userId, personId),
eq(roleAssignment.role, role),
eq(roleAssignment.scopeType, "catalog"),
eq(roleAssignment.scopeId, catalogId),
),
)
.returning();
if (gone.length === 0) throw new ProductError(404, "not_found", "They don't have that role here.");
}
// ---- members ----
async membership(catalogId: string, personId: string): Promise<MemberStatus | undefined> {
const [m] = await this.db
.select({ status: catalogMember.status })
.from(catalogMember)
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)));
return m?.status as MemberStatus | undefined;
}
/**
* A person joins: an open catalog at once, an approval one as a request, an invite one only with an invite (which
* also lets them into approval catalogs at once). Returns their status.
*/
async join(catalogId: string, personId: string, invite?: string): Promise<MemberStatus> {
const c = await this.get(catalogId);
if (c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
const now = await this.membership(catalogId, personId);
if (now === "banned") throw new ProductError(403, "banned", "This catalog's team banned you from it.");
if (now === "member") return "member";
let status: MemberStatus;
let usedInvite: string | null = null;
if (invite) {
const updated = await this.db
.update(catalogInvite)
.set({ uses: sql`${catalogInvite.uses} + 1` })
.where(
and(
eq(catalogInvite.code, invite),
eq(catalogInvite.catalogId, catalogId),
isNull(catalogInvite.revokedAt),
or(isNull(catalogInvite.expiresAt), gt(catalogInvite.expiresAt, new Date())),
or(isNull(catalogInvite.maxUses), sql`${catalogInvite.uses} < ${catalogInvite.maxUses}`),
),
)
.returning();
if (updated.length === 0) throw new ProductError(410, "invite_invalid", "That invitation is used up, expired or revoked.");
status = "member";
usedInvite = invite;
} else if (c.membership === "open") status = "member";
else if (c.membership === "approval") status = "requested";
else throw new ProductError(403, "invite_only", "This catalog takes members by invitation only.");
await this.db
.insert(catalogMember)
.values({ catalogId, userId: personId, status, invite: usedInvite })
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, invite: usedInvite } });
return status;
}
/** A person leaves (or withdraws their request). A ban stays. */
async leave(catalogId: string, personId: string) {
await this.db
.delete(catalogMember)
.where(
and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId), sql`${catalogMember.status} <> 'banned'`),
);
}
async members(catalogId: string, status?: MemberStatus) {
const conds = [eq(catalogMember.catalogId, catalogId)];
if (status) conds.push(eq(catalogMember.status, status));
const rows = await this.db
.select({
personId: catalogMember.userId,
username: user.username,
name: user.displayUsername,
status: catalogMember.status,
invite: catalogMember.invite,
since: catalogMember.updatedAt,
})
.from(catalogMember)
.innerJoin(user, eq(user.id, catalogMember.userId))
.where(and(...conds))
.orderBy(desc(catalogMember.updatedAt))
.limit(500);
return rows.map((r) => ({
personId: r.personId,
username: r.name ?? r.username ?? "",
status: r.status as MemberStatus,
invited: r.invite !== null,
since: r.since.toISOString(),
}));
}
async memberCounts(catalogId: string) {
const rows = await this.db
.select({ status: catalogMember.status, n: count() })
.from(catalogMember)
.where(eq(catalogMember.catalogId, catalogId))
.groupBy(catalogMember.status);
const get = (s: string) => rows.find((r) => r.status === s)?.n ?? 0;
return { members: get("member"), requests: get("requested"), banned: get("banned") };
}
/** The team sets a person's membership: let them in, or ban them. */
async setMember(catalogId: string, personId: string, status: "member" | "banned", by: string) {
await this.get(catalogId);
await this.db
.insert(catalogMember)
.values({ catalogId, userId: personId, status, decidedBy: by })
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, decidedBy: by } });
}
async removeMember(catalogId: string, personId: string) {
const gone = await this.db
.delete(catalogMember)
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)))
.returning();
if (gone.length === 0) throw new ProductError(404, "not_found", "They aren't a member, and haven't asked to be.");
}
// ---- invites ----
async createInvite(catalogId: string, by: string, opts: { maxUses?: number; expiresInDays?: number }) {
await this.get(catalogId);
const [inv] = await this.db
.insert(catalogInvite)
.values({
code: inviteCode(),
catalogId,
createdBy: by,
maxUses: opts.maxUses ?? null,
expiresAt: opts.expiresInDays ? new Date(Date.now() + opts.expiresInDays * 86_400_000) : null,
})
.returning();
return inv!;
}
async invites(catalogId: string) {
return this.db
.select()
.from(catalogInvite)
.where(and(eq(catalogInvite.catalogId, catalogId), isNull(catalogInvite.revokedAt)))
.orderBy(desc(catalogInvite.createdAt));
}
async revokeInvite(catalogId: string, code: string) {
const r = await this.db
.update(catalogInvite)
.set({ revokedAt: new Date() })
.where(and(eq(catalogInvite.catalogId, catalogId), eq(catalogInvite.code, code), isNull(catalogInvite.revokedAt)))
.returning();
if (r.length === 0) throw new ProductError(404, "not_found", "No such invitation.");
}
// ---- what it hosts ----
/** The products a catalog hosts, newest first, with the revisions it hosts of each. */
async hosted(catalogId: string) {
const rows = await this.db
.select({
id: product.id,
name: product.name,
rating: product.rating,
creator: user.displayUsername,
creatorUsername: user.username,
removed: product.removedAt,
deleted: product.deletedAt,
revisions: sql<number[]>`array_agg(${hosting.number} order by ${hosting.number})`,
since: sql<string>`min(${hosting.createdAt})`,
})
.from(hosting)
.innerJoin(product, eq(product.id, hosting.productId))
.innerJoin(user, eq(user.id, product.creatorId))
.where(eq(hosting.catalogId, catalogId))
.groupBy(product.id, user.id)
.orderBy(desc(sql`min(${hosting.createdAt})`))
.limit(500);
return rows.map((r) => ({
id: r.id,
name: r.name,
rating: r.rating,
creator: r.creator ?? r.creatorUsername ?? "",
removed: r.removed !== null,
deleted: r.deleted !== null,
revisions: r.revisions,
since: new Date(r.since).toISOString(),
}));
}
async hostedCounts(catalogId: string) {
const [r] = await this.db
.select({
products: sql<number>`count(distinct ${hosting.productId})::int`,
revisions: sql<number>`count(*)::int`,
bytes: sql<number>`coalesce(sum((select sum((f->>'size')::bigint) from jsonb_array_elements(${productRevision.files}) f)), 0)::bigint`,
})
.from(hosting)
.innerJoin(
productRevision,
and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)),
)
.where(eq(hosting.catalogId, catalogId));
return { products: r?.products ?? 0, revisions: r?.revisions ?? 0, bytes: Number(r?.bytes ?? 0) };
}
/** The catalog stops hosting every revision of a product; its files go in the changes feed for deletion. */
async delist(catalogId: string, productId: string) {
const gone = await this.db
.delete(hosting)
.where(and(eq(hosting.catalogId, catalogId), eq(hosting.productId, productId)))
.returning();
if (gone.length === 0) throw new ProductError(404, "not_found", "This catalog doesn't host that product.");
const revs = await this.db
.select({ number: productRevision.number, files: productRevision.files })
.from(productRevision)
.where(and(eq(productRevision.productId, productId), inArray(productRevision.number, gone.map((g) => g.number))));
for (const r of revs) await this.change(catalogId, { type: "delete", product: productId, revision: r.number, files: r.files });
return gone.map((g) => g.number);
}
// ---- the changes feed ----
async change(catalogId: string, data: Record<string, unknown>) {
await this.db.insert(catalogChange).values({ catalogId, data });
}
/** Changes after a cursor, oldest first (at most 500 at a time). */
async changes(catalogId: string, after: number) {
const rows = await this.db
.select()
.from(catalogChange)
.where(and(eq(catalogChange.catalogId, catalogId), gt(catalogChange.id, after)))
.orderBy(asc(catalogChange.id))
.limit(500);
return { changes: rows.map((r) => r.data), cursor: rows.length ? rows[rows.length - 1]!.id : after };
}
}
async function defaultHealthy(url: string): Promise<boolean> {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
return res.ok;
} catch {
return false;
}
}
+109
View File
@@ -0,0 +1,109 @@
// Decorations over IMVU rooms (docs/products.md, "Decorations"): T5 products an IMVU room's owner placed in it,
// kept here because IMVU only keeps the room without them. The routes are in app.ts; the notice to proxies in the
// room is realtime.ts's decorated().
import { createHash } from "node:crypto";
import { and, eq, inArray, isNull, ne, sql, schema, type Database, type DecorationItem } from "@t5de/db";
import { hostedBase } from "./realtime";
const { roomDecoration, product, productRevision, imvuLink } = schema;
export const maxDecorations = 300;
export const maxDecorationPlacement = 200;
export const maxBatch = 20;
/** An IMVU room instance: "<owner's customer ID>-<n>". */
export const decorationInstance = /^[1-9][0-9]{0,11}-[1-9][0-9]{0,9}$/;
/** An error for the person; the routes turn it into a Problem. */
export class DecorationError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const decorationHash = (contents: DecorationItem[]) =>
createHash("sha256").update(JSON.stringify(contents)).digest("hex");
/** The room's owner (its IMVU account), after checking the instance ID; hosted rooms are refused. */
function ownerOf(instance: string): number {
if (!decorationInstance.test(instance)) throw new DecorationError(400, "invalid", "That isn't an IMVU room.");
const owner = Number(instance.slice(0, instance.indexOf("-")));
if (Number(instance.slice(instance.indexOf("-") + 1)) >= hostedBase)
throw new DecorationError(400, "hosted_room", "Hosted rooms keep their own scene.");
return owner;
}
export type DecorationView = { instance: string; contents: DecorationItem[]; hash: string };
export class Decorations {
constructor(private readonly db: Database) {}
/**
* Of these product IDs, those a viewer may see: registered (a revision exists), with a revision that isn't
* retracted, and general unless the viewer counts as adult.
*/
private async visible(ids: string[], adult: boolean): Promise<Set<string>> {
if (ids.length === 0) return new Set();
const rows = await this.db
.select({ id: product.id })
.from(product)
.where(
and(
inArray(product.id, ids),
ne(product.latest, 0),
isNull(product.removedAt),
adult ? undefined : eq(product.rating, "general"),
sql`exists (select 1 from ${productRevision} where ${productRevision.productId} = ${product.id} and not ${productRevision.retracted})`,
),
);
return new Set(rows.map((r) => r.id));
}
/** Rooms' decorations as a viewer sees them (in the order asked; rooms without any have an empty list). */
async get(instances: string[], adult: boolean): Promise<DecorationView[]> {
for (const i of instances) ownerOf(i);
const rows = instances.length
? await this.db.select().from(roomDecoration).where(inArray(roomDecoration.instanceId, instances))
: [];
const stored = new Map(rows.map((r) => [r.instanceId, r.contents]));
const ok = await this.visible([...new Set(rows.flatMap((r) => r.contents.map((it) => it[1])))], adult);
return instances.map((instance) => {
const contents = (stored.get(instance) ?? []).filter((it) => ok.has(it[1]));
return { instance, contents, hash: decorationHash(contents) };
});
}
/** Sets a room's decorations (an empty list removes them): only one of the room owner's T5DE people may. */
async set(personId: string, instance: string, contents: DecorationItem[]): Promise<void> {
const owner = ownerOf(instance);
const [link] = await this.db
.select()
.from(imvuLink)
.where(and(eq(imvuLink.imvuId, owner), eq(imvuLink.userId, personId)));
if (!link) throw new DecorationError(403, "forbidden", "Only the room's owner can decorate it.");
if (contents.length > maxDecorations)
throw new DecorationError(400, "invalid", `A room holds up to ${maxDecorations} decorations.`);
if (new Set(contents.map((it) => it[0])).size !== contents.length)
throw new DecorationError(400, "invalid", "Each furniture instance ID can be used once.");
const ids = [...new Set(contents.map((it) => it[1]))];
if (ids.length) {
const known = await this.db
.select({ id: product.id })
.from(product)
.where(and(inArray(product.id, ids), ne(product.latest, 0), isNull(product.removedAt)));
if (known.length !== ids.length) throw new DecorationError(400, "unknown_product", "Some of those products don't exist.");
}
if (contents.length === 0) {
await this.db.delete(roomDecoration).where(eq(roomDecoration.instanceId, instance));
return;
}
await this.db
.insert(roomDecoration)
.values({ instanceId: instance, ownerImvuId: owner, contents })
.onConflictDoUpdate({ target: roomDecoration.instanceId, set: { contents, updatedAt: new Date() } });
}
}
+437
View File
@@ -0,0 +1,437 @@
// T5DE-hosted rooms (docs/hosted-rooms.md): made on the website, joined through T5DE's proxy. The routes are in
// app.ts and who's in a room is in realtime.ts; this keeps the rooms themselves, and (phase 2) their moderators, bans,
// images, and people's favorites, ratings and visits.
//
// A room is shown (listed, joinable) while its owner IMVU account is still linked to the person who made it and they
// aren't banned; unlinking hides it until it's linked again. Closing keeps the row, so a number is never reused.
import { createHash } from "node:crypto";
import {
and,
avg,
count,
desc,
eq,
gt,
inArray,
isNull,
notInArray,
or,
sql,
schema,
type Database,
type RoomModerator,
type SceneItem,
} from "@t5de/db";
import { hostedBase, maxHostedN, type HostedRoomInfo } from "./realtime";
const { hostedRoom, hostedRoomBan, hostedRoomFavorite, hostedRoomImage, hostedRoomRating, hostedRoomVisit, imvuLink, user } =
schema;
export type HostedRoom = typeof hostedRoom.$inferSelect;
export type Rating = "general" | "adult";
export type Privacy = "public" | "private";
/** An error for the person; the routes turn it into a Problem. */
export class HostedError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
export const maxRoomsPerPerson = 10;
export const maxSceneItems = 500;
export const maxPlacement = 200;
export const maxModerators = 20;
export const maxImageBytes = 512 * 1024;
const visitsKept = 50;
const recentShown = 20;
/** The scene's hash, which the client caches scenes by: of the room shell and its contents. */
export const sceneHash = (roomPid: number, contents: SceneItem[]) =>
createHash("sha256").update(JSON.stringify([roomPid, contents])).digest("hex");
// "<owner's IMVU ID>-<hostedBase + n>": the client takes a room's owner from the first part (so the owner can
// decorate it), and IMVU's own room numbers are far below hostedBase.
export const instanceId = (owner: number, n: number) => `${owner}-${hostedBase + n}`;
export const chatId = (n: number) => hostedBase + n;
// eslint-disable-next-line no-control-regex
const controlChars = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
function cleanName(s: string): string {
const v = s.replace(controlChars, "").replace(/\s+/g, " ").trim();
if (!v || v.length > 64) throw new HostedError(400, "invalid", "A name is 1 to 64 characters.");
return v;
}
function cleanDescription(s: string): string {
const v = s.replace(/\r\n?/g, "\n").replace(controlChars, "").trim();
if (v.length > 512) throw new HostedError(400, "invalid", "A description is up to 512 characters.");
return v;
}
/** An image's content type by its magic bytes: PNG or JPEG only. */
export function imageType(b: Uint8Array): "image/png" | "image/jpeg" | undefined {
const png = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (b.length > 24 && png.every((v, i) => b[i] === v)) return "image/png";
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
return undefined;
}
export type NewRoom = {
name: string;
description?: string;
roomPid: number;
rating: Rating;
maxUsers?: number;
imvuId: number;
privacy?: Privacy;
};
export type RoomChanges = Partial<Pick<NewRoom, "name" | "description" | "roomPid" | "rating" | "maxUsers" | "privacy">> & {
/** Already resolved to accounts (app.ts resolves names). */
moderators?: RoomModerator[];
};
type Row = { room: HostedRoom; ownerName: string };
/** Who's looking: the person and their linked IMVU accounts (for private rooms they moderate). */
export type Viewer = { id: string; imvuIds: number[] };
export const isRoomModerator = (r: HostedRoom, imvuIds: Iterable<number>) => {
const ids = new Set(imvuIds);
return r.moderators.some((m) => ids.has(m.imvuId));
};
export class HostedRooms {
constructor(private readonly db: Database) {}
// Rooms that are shown: the owner account still linked to the owner, the owner not banned.
private shown() {
return this.db
.select({ room: hostedRoom, ownerName: imvuLink.username })
.from(hostedRoom)
.innerJoin(imvuLink, and(eq(imvuLink.imvuId, hostedRoom.ownerImvuId), eq(imvuLink.userId, hostedRoom.ownerId)))
.innerJoin(user, eq(user.id, hostedRoom.ownerId))
.$dynamic();
}
private visible = sql`coalesce(${user.banned}, false) = false`;
/** Public rooms, and private ones the viewer owns or moderates. */
private listable(viewer?: Viewer) {
if (!viewer) return eq(hostedRoom.privacy, "public");
const mods = viewer.imvuIds.length
? sql`exists (select 1 from jsonb_array_elements(${hostedRoom.moderators}) m where (m->>'imvuId')::bigint in (${sql.join(
viewer.imvuIds.map((id) => sql`${id}`),
sql`, `,
)}))`
: undefined;
return or(eq(hostedRoom.privacy, "public"), eq(hostedRoom.ownerId, viewer.id), mods);
}
/**
* Open rooms the viewer may see: listed ones (public, and private ones they own or moderate), newest first; or
* (mine) their own; (favorites) their favorites, latest first; (recent) the last 20 they joined, latest first.
*/
async list({
adult,
viewer,
mine,
favorites,
recent,
}: {
adult: boolean;
viewer?: Viewer;
mine?: boolean;
favorites?: boolean;
recent?: boolean;
}): Promise<Row[]> {
const open = and(this.visible, eq(hostedRoom.closed, false), adult || mine ? undefined : eq(hostedRoom.rating, "general"));
if (viewer && favorites) {
return this.shown()
.innerJoin(hostedRoomFavorite, and(eq(hostedRoomFavorite.n, hostedRoom.n), eq(hostedRoomFavorite.userId, viewer.id)))
.where(open)
.orderBy(desc(hostedRoomFavorite.createdAt), desc(hostedRoom.n))
.limit(200);
}
if (viewer && recent) {
return this.shown()
.innerJoin(hostedRoomVisit, and(eq(hostedRoomVisit.n, hostedRoom.n), eq(hostedRoomVisit.userId, viewer.id)))
.where(open)
.orderBy(desc(hostedRoomVisit.at))
.limit(recentShown);
}
return this.shown()
.where(and(open, mine && viewer ? eq(hostedRoom.ownerId, viewer.id) : this.listable(viewer)))
.orderBy(desc(hostedRoom.createdAt), desc(hostedRoom.n))
.limit(200);
}
/** A room as shown to a viewer; closed rooms are 410, hidden and adult ones (for others) 404. */
async get(n: number, adult: boolean, viewerId?: string): Promise<Row> {
if (!Number.isInteger(n) || n < 1 || n > maxHostedN) throw new HostedError(404, "not_found", "No such room.");
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
if (!r) throw new HostedError(404, "not_found", "No such room.");
if (r.room.closed) throw new HostedError(410, "closed", "This room is closed.");
if (r.room.rating === "adult" && !adult && r.room.ownerId !== viewerId)
throw new HostedError(404, "not_found", "No such room.");
return r;
}
/** What realtime needs to let someone in (undefined: no such room, or it's hidden). */
async info(n: number): Promise<HostedRoomInfo | undefined> {
const [r] = await this.shown().where(and(this.visible, eq(hostedRoom.n, n)));
return r ? roomInfo(r) : undefined;
}
async create(ownerId: string, adultPerson: boolean, body: NewRoom) {
const name = cleanName(body.name);
const description = cleanDescription(body.description ?? "");
if (body.rating === "adult" && !adultPerson)
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
const [link] = await this.db
.select()
.from(imvuLink)
.where(and(eq(imvuLink.imvuId, body.imvuId), eq(imvuLink.userId, ownerId)));
if (!link) throw new HostedError(400, "not_linked", "That IMVU account isn't linked to you.");
return this.db.transaction(async (tx) => {
const [{ n } = { n: 0 }] = await tx
.select({ n: count() })
.from(hostedRoom)
.where(and(eq(hostedRoom.ownerId, ownerId), eq(hostedRoom.closed, false)));
if (n >= maxRoomsPerPerson)
throw new HostedError(403, "room_cap", `You can have up to ${maxRoomsPerPerson} rooms. Close one first.`);
const [r] = await tx
.insert(hostedRoom)
.values({
ownerId,
ownerImvuId: link.imvuId,
name,
description,
roomPid: body.roomPid,
rating: body.rating,
maxUsers: body.maxUsers ?? 10,
privacy: body.privacy ?? "public",
scene: [],
sceneHash: sceneHash(body.roomPid, []),
})
.returning();
return { room: r!, ownerName: link.username };
});
}
/** The owner's room, open (for changes). */
async own(ownerId: string, n: number) {
const r = await this.get(n, true, ownerId);
if (r.room.ownerId !== ownerId) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
return r;
}
async update(ownerId: string, adultPerson: boolean, n: number, changes: RoomChanges) {
const { room: r, ownerName } = await this.own(ownerId, n);
if (changes.rating === "adult" && r.rating !== "adult" && !adultPerson)
throw new HostedError(403, "not_adult", "Adult rooms need an age-verified IMVU account.");
const roomPid = changes.roomPid ?? r.roomPid;
let moderators: RoomModerator[] | undefined;
if (changes.moderators) {
const seen = new Set<number>([r.ownerImvuId]); // the owner isn't listed as a moderator of their own room
moderators = changes.moderators.filter((m) => !seen.has(m.imvuId) && seen.add(m.imvuId));
if (moderators.length > maxModerators)
throw new HostedError(400, "invalid", `A room has up to ${maxModerators} moderators.`);
}
const [updated] = await this.db
.update(hostedRoom)
.set({
...(changes.name !== undefined ? { name: cleanName(changes.name) } : {}),
...(changes.description !== undefined ? { description: cleanDescription(changes.description) } : {}),
...(changes.rating !== undefined ? { rating: changes.rating } : {}),
...(changes.maxUsers !== undefined ? { maxUsers: changes.maxUsers } : {}),
...(changes.privacy !== undefined ? { privacy: changes.privacy } : {}),
...(moderators !== undefined ? { moderators } : {}),
...(roomPid !== r.roomPid ? { roomPid, sceneHash: sceneHash(roomPid, r.scene) } : {}),
})
.where(eq(hostedRoom.n, n))
.returning();
return { room: updated!, ownerName };
}
async setScene(ownerId: string, n: number, contents: SceneItem[], roomPid?: number) {
const { room: r, ownerName } = await this.own(ownerId, n);
const pid = roomPid ?? r.roomPid;
const [updated] = await this.db
.update(hostedRoom)
.set({ scene: contents, roomPid: pid, sceneHash: sceneHash(pid, contents) })
.where(eq(hostedRoom.n, n))
.returning();
return { room: updated!, ownerName };
}
/** Closes a room: by its owner, or (moderator) anyone's. */
async close(actorId: string, moderator: boolean, n: number) {
const { room: r } = await this.get(n, true, actorId);
if (r.ownerId !== actorId && !moderator) throw new HostedError(403, "forbidden", "Only the room's owner can do this.");
await this.db.update(hostedRoom).set({ closed: true }).where(eq(hostedRoom.n, n));
return r;
}
// ---- Bans ----
/** A room the viewer owns or moderates (for its bans). */
async managed(viewer: Viewer, n: number) {
const r = await this.get(n, true, viewer.id);
if (r.room.ownerId !== viewer.id && !isRoomModerator(r.room, viewer.imvuIds))
throw new HostedError(403, "forbidden", "Only the room's owner and moderators can do this.");
return r;
}
async ban(n: number, imvuId: number, byImvuId: number, minutes: number) {
const until = minutes > 0 ? new Date(Date.now() + minutes * 60_000) : null;
await this.db
.insert(hostedRoomBan)
.values({ n, imvuId, byImvuId, until })
.onConflictDoUpdate({ target: [hostedRoomBan.n, hostedRoomBan.imvuId], set: { byImvuId, until, createdAt: new Date() } });
}
private inForce = () => or(isNull(hostedRoomBan.until), gt(hostedRoomBan.until, new Date()));
async banned(n: number, imvuId: number): Promise<boolean> {
const [b] = await this.db
.select({ n: hostedRoomBan.n })
.from(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId), this.inForce()));
return b !== undefined;
}
/** A room's bans in force, latest first. */
async bans(n: number) {
return this.db
.select()
.from(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), this.inForce()))
.orderBy(desc(hostedRoomBan.createdAt));
}
/** Lifts a ban (owner only); false if there was none. */
async unban(ownerId: string, n: number, imvuId: number): Promise<boolean> {
await this.own(ownerId, n);
const gone = await this.db
.delete(hostedRoomBan)
.where(and(eq(hostedRoomBan.n, n), eq(hostedRoomBan.imvuId, imvuId)))
.returning();
return gone.length > 0;
}
// ---- Images ----
async setImage(ownerId: string, n: number, data: Buffer) {
const { ownerName } = await this.own(ownerId, n);
if (data.length > maxImageBytes) throw new HostedError(400, "invalid", "An image is up to 512 KB.");
const contentType = imageType(data);
if (!contentType) throw new HostedError(400, "invalid", "An image is a PNG or JPEG.");
const hash = createHash("sha256").update(data).digest("hex");
const room = await this.db.transaction(async (tx) => {
await tx
.insert(hostedRoomImage)
.values({ n, contentType, data, hash })
.onConflictDoUpdate({ target: hostedRoomImage.n, set: { contentType, data, hash, updatedAt: new Date() } });
const [r] = await tx.update(hostedRoom).set({ imageHash: hash }).where(eq(hostedRoom.n, n)).returning();
return r!;
});
return { room, ownerName };
}
async removeImage(ownerId: string, n: number) {
const { ownerName } = await this.own(ownerId, n);
await this.db.delete(hostedRoomImage).where(eq(hostedRoomImage.n, n));
const [room] = await this.db.update(hostedRoom).set({ imageHash: null }).where(eq(hostedRoom.n, n)).returning();
return { room: room!, ownerName };
}
async image(n: number) {
const [i] = await this.db.select().from(hostedRoomImage).where(eq(hostedRoomImage.n, n));
return i;
}
// ---- Favorites, ratings, visits ----
async favorite(userId: string, n: number, on: boolean) {
if (on) await this.db.insert(hostedRoomFavorite).values({ userId, n }).onConflictDoNothing();
else await this.db.delete(hostedRoomFavorite).where(and(eq(hostedRoomFavorite.userId, userId), eq(hostedRoomFavorite.n, n)));
}
/** Which of these rooms are the person's favorites. */
async favoriteSet(userId: string, ns: number[]): Promise<Set<number>> {
if (ns.length === 0) return new Set();
const rows = await this.db
.select({ n: hostedRoomFavorite.n })
.from(hostedRoomFavorite)
.where(and(eq(hostedRoomFavorite.userId, userId), inArray(hostedRoomFavorite.n, ns)));
return new Set(rows.map((r) => r.n));
}
/** Sets (1-5) or removes (null) the person's rating. */
async rate(userId: string, n: number, score: number | null) {
if (score === null) {
await this.db.delete(hostedRoomRating).where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n)));
return;
}
await this.db
.insert(hostedRoomRating)
.values({ userId, n, score })
.onConflictDoUpdate({ target: [hostedRoomRating.userId, hostedRoomRating.n], set: { score, updatedAt: new Date() } });
}
async myRating(userId: string, n: number): Promise<number> {
const [r] = await this.db
.select({ score: hostedRoomRating.score })
.from(hostedRoomRating)
.where(and(eq(hostedRoomRating.userId, userId), eq(hostedRoomRating.n, n)));
return r?.score ?? 0;
}
/** Each room's average rating (0 if none, two decimals) and how many rated it. */
async scores(ns: number[]): Promise<Map<number, { score: number; ratings: number }>> {
if (ns.length === 0) return new Map();
const rows = await this.db
.select({ n: hostedRoomRating.n, score: avg(hostedRoomRating.score), ratings: count() })
.from(hostedRoomRating)
.where(inArray(hostedRoomRating.n, ns))
.groupBy(hostedRoomRating.n);
return new Map(rows.map((r) => [r.n, { score: Math.round(Number(r.score ?? 0) * 100) / 100, ratings: r.ratings }]));
}
/** Records a visit (a join), keeping the person's latest 50. */
async visit(userId: string, n: number, imvuId: number) {
const at = new Date();
await this.db
.insert(hostedRoomVisit)
.values({ userId, n, imvuId, at })
.onConflictDoUpdate({ target: [hostedRoomVisit.userId, hostedRoomVisit.n], set: { imvuId, at } });
const keep = this.db
.select({ n: hostedRoomVisit.n })
.from(hostedRoomVisit)
.where(eq(hostedRoomVisit.userId, userId))
.orderBy(desc(hostedRoomVisit.at))
.limit(visitsKept);
await this.db.delete(hostedRoomVisit).where(and(eq(hostedRoomVisit.userId, userId), notInArray(hostedRoomVisit.n, keep)));
}
}
/** What realtime needs of a room. */
export function roomInfo({ room: r, ownerName }: Row): HostedRoomInfo {
return {
closed: r.closed,
adult: r.rating === "adult",
maxUsers: r.maxUsers,
privacy: r.privacy === "private" ? "private" : "public",
ownerImvuId: r.ownerImvuId,
moderators: r.moderators.map((m) => m.imvuId),
name: r.name,
ownerName,
};
}
+51
View File
@@ -0,0 +1,51 @@
// Product descriptions: HTML, as on IMVU, made safe by an allowlist (docs/design.md, "Product info"). Cleaned when
// saved; whatever shows them cleans again.
import sanitize from "sanitize-html";
export const maxDescription = 20_000;
const color = [/^#[0-9a-f]{3,8}$/i, /^[a-z]{3,20}$/i, /^rgba?\(\s*[\d.\s,%]+\)$/i];
const size = [/^\d{1,3}(\.\d+)?(px|pt|em|rem|%)$/, /^(xx-small|x-small|small|medium|large|x-large|xx-large)$/];
const options: sanitize.IOptions = {
allowedTags: [
"b", "i", "u", "s", "em", "strong", "small", "big", "sub", "sup", "font", "span", "div", "p", "br", "hr",
"center", "ul", "ol", "li", "table", "thead", "tbody", "tr", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6",
"a", "img",
],
allowedAttributes: {
a: ["href", "rel", "target"], // rel and target are always set by the transform below
img: ["src", "alt", "width", "height"],
font: ["color", "size"],
td: ["colspan", "rowspan"],
th: ["colspan", "rowspan"],
"*": ["style"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: { img: ["https"] },
allowProtocolRelative: false,
allowedStyles: {
"*": {
color,
"background-color": color,
"font-size": size,
"font-weight": [/^(normal|bold|bolder|lighter|[1-9]00)$/],
"font-style": [/^(normal|italic|oblique)$/],
"text-align": [/^(left|right|center|justify)$/],
"text-decoration": [/^(none|underline|line-through|overline)$/],
},
},
transformTags: {
a: (tagName, attribs) => ({
tagName,
attribs: { ...attribs, rel: "nofollow ugc noopener noreferrer", target: "_blank" },
}),
},
// Images need an absolute https address (scheme rules don't apply to relative ones); others are dropped.
exclusiveFilter: (frame) => frame.tag === "img" && !/^https:\/\//i.test(frame.attribs.src ?? ""),
disallowedTagsMode: "discard",
};
export function cleanHTML(html: string): string {
return sanitize(html, options).trim();
}
+114 -7
View File
@@ -1,5 +1,6 @@
// IMVU's public user API: who an account is, its tagline (for linking) and whether it's age verified or has Access // IMVU's public API: who an account is, its tagline (for linking) and whether it's age verified or has Access Pass;
// Pass. The hub calls it rarely (linking, and age checks) and depends on it for nothing else. // rooms (for the rooms list); and products (the IMVU products T5 products derive from). The hub calls it rarely
// (linking, age checks, rooms T5DE users enter, uploads) and depends on it for nothing else.
export type ImvuUser = { export type ImvuUser = {
id: number; id: number;
@@ -7,6 +8,29 @@ export type ImvuUser = {
tagline: string; tagline: string;
ageVerified: boolean; ageVerified: boolean;
accessPass: boolean; accessPass: boolean;
picture: string | null; // the avatar's picture (https, on *.imvu.com)
};
export type ImvuRoom = {
instanceId: string; // "<owner>-<n>"
ownerId: number;
name: string;
description: string;
imageURL: string;
privacy: string; // "public", ...
type: string; // "listed", "unlisted", ...
contentRating: string; // "GA", "AP"
accessPass: boolean;
occupancy: number;
capacity: number;
expired: boolean;
};
/** An IMVU product's public info, as T5 products derived from it show it. */
export type ImvuProduct = {
image: string | null; // https, on *.imvu.com
categories: { id: number; name: string }[]; // outermost first, e.g. Clothing / Clothing for Female / Tops
gender: string; // "female", "male", or "" (either)
}; };
export class NoSuchImvuUser extends Error { export class NoSuchImvuUser extends Error {
@@ -31,11 +55,36 @@ export class ImvuClient {
byUsername(username: string): Promise<ImvuUser> { byUsername(username: string): Promise<ImvuUser> {
const name = username.trim(); const name = username.trim();
if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser()); if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser());
return this.get(`/user?username=${encodeURIComponent(name)}`); return this.get(`/user?username=${encodeURIComponent(name)}`).then(parse);
} }
byId(id: number): Promise<ImvuUser> { byId(id: number): Promise<ImvuUser> {
return this.get(`/user/user-${id}`); return this.get(`/user/user-${id}`).then(parse);
}
/** A room by its instance ID ("<owner>-<n>"); undefined if there's no such room. */
async room(instanceId: string): Promise<ImvuRoom | undefined> {
if (!/^[0-9]{1,12}-[0-9]{1,12}$/.test(instanceId)) return undefined;
try {
return parseRoom(await this.get(`/room/room-${instanceId}`), instanceId);
} catch (e) {
if (e instanceof NoSuchImvuUser) return undefined;
throw e;
}
}
/**
* An IMVU product's public info; null if IMVU shows no such product publicly (it answers 401 for some, such as
* Access Pass ones). Throws ImvuUnavailable if IMVU can't be reached.
*/
async product(id: number): Promise<ImvuProduct | null> {
if (!Number.isSafeInteger(id) || id <= 0) return null;
try {
return parseProduct(await this.get(`/product/product-${id}`, [401, 403]), id);
} catch (e) {
if (e instanceof NoSuchImvuUser) return null;
throw e;
}
} }
private async wait() { private async wait() {
@@ -45,7 +94,8 @@ export class ImvuClient {
if (at > now) await Bun.sleep(at - now); if (at > now) await Bun.sleep(at - now);
} }
private async get(path: string): Promise<ImvuUser> { /** GETs an IMVU API path; 404 (and the statuses in `missing`) throw NoSuchImvuUser. */
private async get(path: string, missing: number[] = []): Promise<unknown> {
await this.wait(); await this.wait();
let response: Response; let response: Response;
try { try {
@@ -56,12 +106,68 @@ export class ImvuClient {
} catch (e) { } catch (e) {
throw new ImvuUnavailable(String(e)); throw new ImvuUnavailable(String(e));
} }
if (response.status === 404) throw new NoSuchImvuUser(); if (response.status === 404 || missing.includes(response.status)) throw new NoSuchImvuUser();
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`); if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
return parse(await response.json()); return response.json();
} }
} }
export function parseRoom(answer: unknown, instanceId: string): ImvuRoom | undefined {
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
const entry = Object.entries(den).find(([key]) => key.endsWith(`/room/room-${instanceId}`));
const d = entry?.[1].data;
if (!d) return undefined;
const str = (v: unknown) => (typeof v === "string" ? v : "");
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
let image = str(d.image_url);
if (image.startsWith("//")) image = "https:" + image;
if (!image.startsWith("https://")) image = "";
return {
instanceId,
ownerId: num(d.customers_id) || Number(instanceId.split("-")[0]),
name: str(d.name).slice(0, 200),
description: str(d.description).slice(0, 2000),
imageURL: image,
privacy: str(d.privacy),
type: str(d.type),
contentRating: str(d.content_rating),
accessPass: d.is_ap === true,
occupancy: num(d.occupancy),
capacity: num(d.capacity),
expired: d.is_expired === true,
};
}
/** Whether an image address is one of IMVU's (https, on imvu.com or a subdomain). */
export function imvuImage(v: unknown): string | null {
if (typeof v !== "string" || v.length > 1000) return null;
let url: URL;
try {
url = new URL(v.startsWith("//") ? "https:" + v : v);
} catch {
return null;
}
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
if (url.hostname !== "imvu.com" && !url.hostname.endsWith(".imvu.com")) return null;
return url.href;
}
export function parseProduct(answer: unknown, id: number): ImvuProduct | null {
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
const entry = Object.entries(den).find(([key]) => key.endsWith(`/product/product-${id}`));
const d = entry?.[1].data;
if (!d) return null;
const path = Array.isArray(d.category_path) ? d.category_path : [];
const categories = path.slice(0, 10).flatMap((c: unknown) => {
const { id, name } = (c ?? {}) as { id?: unknown; name?: unknown };
const n = Number(id);
if (!Number.isSafeInteger(n) || n <= 0 || typeof name !== "string" || !name.trim()) return [];
return [{ id: n, name: name.trim().slice(0, 100) }];
});
const gender = d.gender === "female" || d.gender === "male" ? d.gender : "";
return { image: imvuImage(d.product_image), categories, gender };
}
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry. // IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
export function parse(answer: unknown): ImvuUser { export function parse(answer: unknown): ImvuUser {
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {}; const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
@@ -77,5 +183,6 @@ export function parse(answer: unknown): ImvuUser {
tagline: typeof data.tagline === "string" ? data.tagline : "", tagline: typeof data.tagline === "string" ? data.tagline : "",
ageVerified: data.is_ageverified === true, ageVerified: data.is_ageverified === true,
accessPass: data.is_ap === true, accessPass: data.is_ap === true,
picture: imvuImage(data.thumbnail_url),
}; };
} }
+61 -2
View File
@@ -4,9 +4,19 @@
// API_URL the API's address as the website and proxies reach it (default http://localhost:4101) // API_URL the API's address as the website and proxies reach it (default http://localhost:4101)
// WEB_URL the website's address (default http://localhost:4100) // WEB_URL the website's address (default http://localhost:4100)
// PORT default 4101 // PORT default 4101
// REGISTRATION open or closed (new accounts wait for a moderator's approval), until an admin sets it in the console
// AGE_PROVIDERS ways to verify age, comma-separated (default imvu): imvu, didit
// DIDIT_API_KEY, DIDIT_WORKFLOW_ID, DIDIT_WEBHOOK_SECRET
// Didit's ID document check (docs/design.md, "Age"); its webhook is API_URL/api/v1/age/webhook/didit
// DIDIT_MONTHLY_LIMIT checks started a month at most (default 30: Didit's free plan has $10 of credit a month, and a
// check with ID document, liveness and face match costs $0.30)
import { openDatabase } from "@t5de/db"; import { openDatabase } from "@t5de/db";
import { AgeService, type Provider } from "./age";
import { diditAgeProvider } from "./age/didit";
import { imvuAgeProvider } from "./age/imvu";
import { createApp } from "./app"; import { createApp } from "./app";
import { createAuth } from "./auth"; import { createAuth } from "./auth";
import { Settings } from "./settings";
import { ImvuClient } from "./imvu"; import { ImvuClient } from "./imvu";
const env = process.env; const env = process.env;
@@ -21,7 +31,56 @@ const apiURL = env.API_URL ?? `http://localhost:${port}`;
const webURL = env.WEB_URL ?? "http://localhost:4100"; const webURL = env.WEB_URL ?? "http://localhost:4100";
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db"); const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
const auth = createAuth(db, { apiURL, webURL, secret }); const settings = new Settings(db, { registration: env.REGISTRATION });
const { app } = createApp({ db, auth, imvu: new ImvuClient() }); const auth = createAuth(db, { apiURL, webURL, secret, settings });
const imvu = new ImvuClient();
function required(name: string): string {
const v = env[name];
if (!v) {
console.error(`${name} must be set`);
process.exit(1);
}
return v;
}
const ageProviders: Record<string, () => Provider> = {
imvu: () => imvuAgeProvider(imvu),
didit: () =>
diditAgeProvider({
apiKey: required("DIDIT_API_KEY"),
workflowId: required("DIDIT_WORKFLOW_ID"),
webhookSecret: required("DIDIT_WEBHOOK_SECRET"),
monthlyLimit: Number(env.DIDIT_MONTHLY_LIMIT ?? 30),
}),
};
const providers = (env.AGE_PROVIDERS ?? "imvu").split(",").map((id) => id.trim()).filter(Boolean);
if (!providers.includes("imvu")) providers.unshift("imvu"); // linking records IMVU's answer
for (const id of providers) {
if (!ageProviders[id]) {
console.error(`AGE_PROVIDERS: no provider ${id}`);
process.exit(1);
}
}
const ageService = new AgeService(db, providers.map((id) => ageProviders[id]!()));
const { app, age, rooms, products } = createApp({ db, auth, imvu, age: ageService, apiURL, webURL, settings });
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" }); app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
// Age answers that can change (IMVU's) are checked again when they expire, a few at a time; revisions missing their
// parent's info (IMVU was unavailable, or they predate it) get it, starting now; drafts never published go after 30
// days.
const parentInfo = () => products.fillParentInfo(20).catch((e) => console.error("parent info:", e));
const purgeDrafts = () =>
products
.purgeDrafts()
.then((n) => n > 0 && console.log(`deleted ${n} drafts never published`))
.catch((e) => console.error("drafts:", e));
parentInfo();
purgeDrafts();
setInterval(() => {
age.recheckDue(50).catch((e) => console.error("age re-checks:", e));
rooms.recheckDue(20).catch((e) => console.error("room re-checks:", e));
parentInfo();
purgeDrafts();
}, 10 * 60 * 1000);
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`); console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
+72
View File
@@ -0,0 +1,72 @@
// The hub's signing key (Ed25519) for the tokens catalogs and proxies check: upload, access and revision tokens
// (docs/products.md). Made on first use and kept in the database; the public half is at
// /.well-known/t5de-keys.json.
import { desc, schema, type Database } from "@t5de/db";
import { exportJWK, generateKeyPair, importJWK, jwtVerify, SignJWT, type JWK, type JWTPayload } from "jose";
const { signingKey } = schema;
type Key = { kid: string; publicJWK: JWK; privateKey: CryptoKey };
export class Signer {
private key?: Promise<Key>;
constructor(
private readonly db: Database,
private readonly issuer: string,
) {}
private load(): Promise<Key> {
this.key ??= (async () => {
const [row] = await this.db.select().from(signingKey).orderBy(desc(signingKey.createdAt)).limit(1);
if (row) {
return {
kid: row.id,
publicJWK: row.publicKey,
privateKey: (await importJWK(row.privateKey, "EdDSA")) as CryptoKey,
};
}
const pair = await generateKeyPair("EdDSA", { crv: "Ed25519", extractable: true });
const publicJWK = await exportJWK(pair.publicKey);
const kid = Buffer.from(crypto.getRandomValues(new Uint8Array(9))).toString("base64url");
await this.db.insert(signingKey).values({
id: kid,
publicKey: publicJWK as Record<string, string>,
privateKey: (await exportJWK(pair.privateKey)) as Record<string, string>,
});
return { kid, publicJWK, privateKey: pair.privateKey };
})();
return this.key;
}
/** A token of type typ with these claims; expiresIn in seconds (none: no expiry). */
async sign(typ: string, claims: Record<string, unknown>, expiresIn?: number): Promise<string> {
const k = await this.load();
let jwt = new SignJWT({ ...claims, typ })
.setProtectedHeader({ alg: "EdDSA", kid: k.kid, typ: "JWT" })
.setIssuer(this.issuer)
.setIssuedAt();
if (expiresIn) jwt = jwt.setExpirationTime(Math.floor(Date.now() / 1000) + expiresIn);
return jwt.sign(k.privateKey);
}
/** A token's claims, if the hub signed it, it's of type typ, and it hasn't expired; else undefined. */
async verify(token: string, typ: string): Promise<JWTPayload | undefined> {
const k = await this.load();
try {
const { payload } = await jwtVerify(token, await importJWK(k.publicJWK, "EdDSA"), {
issuer: this.issuer,
algorithms: ["EdDSA"],
});
return payload.typ === typ ? payload : undefined;
} catch {
return undefined;
}
}
/** The public keys, as a JWKS. */
async jwks() {
const k = await this.load();
return { keys: [{ ...k.publicJWK, kid: k.kid, alg: "EdDSA", use: "sig" }] };
}
}
+59
View File
@@ -0,0 +1,59 @@
// Notifications (docs/console.md): something for a person to know, shown on the website and sent to their proxies
// over the realtime connection ({"type": "notification"}), so a client can show them later.
import { and, count, desc, eq, inArray, isNull, lt, schema, type AuditTags, type Database } from "@t5de/db";
const { notification } = schema;
/**
* The kinds of notification, and what their data holds:
* - product_edited: someone other than its creator changed a product's info `{product, name, kind, fields, reason, by}`
* - case_decided: a case they opened was decided `{case, kind, status, resolution}`
* - case_note: someone replied on a case they opened or are assigned `{case, kind, by}`
* - product_removed: moderation removed their product `{product, name, reason}`
* - product_restored: moderation restored it `{product, name, reason}`
* - membership_decided: a catalog decided their membership `{catalog, name, status}` (member, declined, banned)
*/
export type NotificationType = "product_edited" | "product_removed" | "product_restored" | "case_decided" | "case_note" | "membership_decided";
export class Notifications {
constructor(
private readonly db: Database,
private readonly push: (userId: string, message: object) => void = () => {},
) {}
/** Tells a person something (never the one who did it: pass `actorId` to leave them out). */
async add(userId: string | null | undefined, type: NotificationType, data: Record<string, unknown>, tags: AuditTags = {}, actorId?: string) {
if (!userId || userId === actorId) return;
await this.db.insert(notification).values({ userId, type, data, tags });
this.push(userId, { type: "notification" });
}
/** A person's notifications, newest first; `before` an ID for the next page. */
async list(userId: string, before?: number, limit = 50) {
const rows = await this.db
.select()
.from(notification)
.where(and(eq(notification.userId, userId), before ? lt(notification.id, before) : undefined))
.orderBy(desc(notification.id))
.limit(Math.max(1, Math.min(limit, 100)));
return rows;
}
async unread(userId: string): Promise<number> {
const [row] = await this.db
.select({ n: count() })
.from(notification)
.where(and(eq(notification.userId, userId), isNull(notification.readAt)));
return row?.n ?? 0;
}
/** Marks some (or all) of a person's notifications read. */
async markRead(userId: string, ids?: number[]) {
await this.db
.update(notification)
.set({ readAt: new Date() })
.where(
and(eq(notification.userId, userId), isNull(notification.readAt), ids ? inArray(notification.id, ids) : undefined),
);
}
}
+268
View File
@@ -0,0 +1,268 @@
// Permissions (docs/console.md): code checks `resource:action` permissions, never roles. A role is a bundle of
// permissions given at a scope; hub-wide roles are user.role, owners are derived (a catalog's owner, a product's
// creator), and other roles are role_assignment rows.
import { and, eq, schema, type Database, type TokenGrant } from "@t5de/db";
const { catalog, product, roleAssignment, user } = schema;
export const permissions = [
"product:edit",
"product:edit-locked",
"product:lock",
"product:rate-lower",
"product:publish",
"product:delete",
"product:restore",
"product:remove",
"product:unremove",
"product:transfer",
"revision:upload",
"revision:retract",
"catalog:approve",
"catalog:edit",
"catalog:rotate-key",
"catalog:suspend",
"catalog:delete-request",
"catalog:delete-review",
"catalog:transfer",
"team:manage",
"member:approve",
"member:invite",
"member:remove",
"member:ban",
"hosting:choose",
"hosting:delist",
"import:grant",
"import:grant-on-behalf",
"import:accept",
"import:run",
"import:pause",
"import:withdraw",
"case:read",
"case:resolve",
"case:assign",
"audit:read",
"audit:read-sensitive",
"stats:read",
"person:approve",
"person:ban",
"person:link-cap",
"person:notes",
"person:role",
"person:view-as",
"room:edit",
"room:close",
"room:kick",
"room:ban",
"room:mute",
"settings:edit",
] as const;
export type Permission = (typeof permissions)[number];
/** Where a permission applies. The hub covers everything. */
export type Scope = { type: "hub" } | { type: "catalog"; id: string } | { type: "product"; id: string };
export const hubScope: Scope = { type: "hub" };
const all = (prefix: string) => permissions.filter((p) => p.startsWith(prefix + ":"));
const without = (list: readonly Permission[], ...drop: Permission[]) => list.filter((p) => !drop.includes(p));
const catalogOwner: Permission[] = [
"catalog:edit",
"catalog:rotate-key",
"catalog:delete-request",
"catalog:transfer",
"team:manage",
...all("member"),
"hosting:delist",
"import:grant",
"import:accept",
"import:run",
"import:pause",
"import:withdraw",
"case:read",
"case:resolve",
"audit:read",
"stats:read",
];
/** The built-in roles: name, the scope they're given at, and their permissions. */
export const roles = {
admin: { name: "Admin", scope: "hub", permissions: [...permissions] as Permission[] },
moderator: {
name: "Moderator",
scope: "hub",
permissions: [
...without(all("product"), "product:transfer"),
"revision:retract",
"catalog:approve",
"catalog:suspend",
"catalog:delete-review",
"hosting:delist",
"import:grant-on-behalf",
"import:withdraw",
"import:pause",
...all("case"),
"person:approve",
"person:ban",
"person:link-cap",
"person:notes",
"audit:read",
"stats:read",
...all("room"),
] as Permission[],
},
"catalog-owner": { name: "Owner", scope: "catalog", permissions: catalogOwner },
"catalog-operator": {
name: "Operator",
scope: "catalog",
permissions: without(catalogOwner, "catalog:delete-request", "catalog:transfer", "team:manage"),
},
creator: {
name: "Creator",
scope: "product",
permissions: [
"product:edit",
"product:publish",
"product:delete",
"product:restore",
"product:transfer",
"revision:upload",
"revision:retract",
"hosting:choose",
"case:read",
"audit:read",
"stats:read",
] as Permission[],
},
} as const satisfies Record<string, { name: string; scope: string; permissions: Permission[] }>;
export type RoleId = keyof typeof roles;
/** Roles that can be given at a catalog (the owner is derived, never given). */
export const assignableCatalogRoles = ["catalog-operator"] as const;
/** A person, as the check needs them. Signed in with an API token, only what it grants too (the intersection). */
export type Who = { id: string; role: string; token?: { id: string; name: string; grants: TokenGrant[] } };
/** Whether a token (if any) grants a permission at a scope: granted there, or hub-wide. */
export function granted(who: Who | undefined, permission: Permission, scope: Scope): boolean {
if (!who?.token) return true;
return who.token.grants.some(
(g) =>
g.permission === permission &&
(g.scope.type === "hub" || (scope.type !== "hub" && g.scope.type === scope.type && g.scope.id === scope.id)),
);
}
/** Thrown when a permission is missing; the API answers 403 with what was missing. */
export class Forbidden extends Error {
constructor(
readonly permission: Permission,
readonly scope: Scope,
) {
super("You don't have permission to do that.");
}
}
export class Permissions {
constructor(private readonly db: Database) {}
/** A person's hub-wide permissions (their user.role). */
hub(who: Who | undefined): Set<Permission> {
const held = who?.role === "admin" ? roles.admin.permissions : who?.role === "moderator" ? roles.moderator.permissions : [];
return new Set(held.filter((p) => granted(who, p, hubScope)));
}
/** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */
async rolesAt(who: Who | undefined, scope: Scope): Promise<RoleId[]> {
if (!who) return [];
const out: RoleId[] = [];
if (who.role === "admin") out.push("admin");
else if (who.role === "moderator") out.push("moderator");
if (scope.type === "catalog") {
const [c] = await this.db.select({ ownerId: catalog.ownerId }).from(catalog).where(eq(catalog.id, scope.id));
if (c?.ownerId === who.id) out.push("catalog-owner");
const given = await this.db
.select({ role: roleAssignment.role })
.from(roleAssignment)
.where(
and(eq(roleAssignment.userId, who.id), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, scope.id)),
);
for (const g of given) if (g.role in roles) out.push(g.role as RoleId);
} else if (scope.type === "product") {
const [p] = await this.db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, scope.id));
if (p?.creatorId === who.id) out.push("creator");
}
return out;
}
/** Every permission a person holds at a scope. */
async at(who: Who | undefined, scope: Scope): Promise<Set<Permission>> {
const out = new Set<Permission>();
for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) if (granted(who, p, scope)) out.add(p);
return out;
}
async can(who: Who | undefined, permission: Permission, scope: Scope): Promise<boolean> {
if (this.hub(who).has(permission)) return true;
if (scope.type === "hub") return false;
return (await this.at(who, scope)).has(permission);
}
/** Throws Forbidden unless the person holds the permission at the scope. */
async require(who: Who | undefined, permission: Permission, scope: Scope): Promise<void> {
if (!(await this.can(who, permission, scope))) throw new Forbidden(permission, scope);
}
/** May `who` give (or take away) `role` at `scope`? Only if they hold every permission in it there. */
async mayGive(who: Who | undefined, role: RoleId, scope: Scope): Promise<boolean> {
const held = await this.at(who, scope);
return roles[role].permissions.every((p) => held.has(p));
}
/** The catalogs a person is on the team of (owner or a role there). */
async teamCatalogs(personId: string): Promise<string[]> {
const owned = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.ownerId, personId));
const given = await this.db
.select({ id: roleAssignment.scopeId })
.from(roleAssignment)
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog")));
return [...new Set([...owned.map((c) => c.id), ...given.map((g) => g.id)])];
}
/** The products a person created. */
async ownProducts(personId: string): Promise<string[]> {
return (await this.db.select({ id: product.id }).from(product).where(eq(product.creatorId, personId))).map((p) => p.id);
}
/** A catalog's team: the owner and everyone given a role there, with their names. */
async team(catalogId: string) {
const [c] = await this.db
.select({ ownerId: catalog.ownerId, name: user.displayUsername, username: user.username })
.from(catalog)
.innerJoin(user, eq(user.id, catalog.ownerId))
.where(eq(catalog.id, catalogId));
const given = await this.db
.select({
personId: roleAssignment.userId,
role: roleAssignment.role,
name: user.displayUsername,
username: user.username,
since: roleAssignment.createdAt,
})
.from(roleAssignment)
.innerJoin(user, eq(user.id, roleAssignment.userId))
.where(and(eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, catalogId)));
return {
owner: c ? { personId: c.ownerId, username: c.name ?? c.username ?? "" } : undefined,
roles: given.map((g) => ({
personId: g.personId,
role: g.role,
username: g.name ?? g.username ?? "",
since: g.since.toISOString(),
})),
};
}
}
+895
View File
@@ -0,0 +1,895 @@
// The product registry, catalogs and libraries (docs/products.md). The routes are in app.ts; this is what they do.
import { createHash, timingSafeEqual } from "node:crypto";
import {
and,
desc,
eq,
gt,
ilike,
inArray,
isNull,
ne,
or,
sql,
schema,
type Database,
type FileEntry,
type ParentInfo,
} from "@t5de/db";
import { cleanHTML, maxDescription } from "./html";
import type { ImvuClient } from "./imvu";
import type { Signer } from "./keys";
const { catalog, catalogChange, catalogMember, fileBlock, product, productInfoChange, productRevision, hosting, library, roleAssignment, user } =
schema;
export type Catalog = typeof catalog.$inferSelect;
export type Product = typeof product.$inferSelect;
export type Revision = typeof productRevision.$inferSelect;
/** Info fields people edit (and moderators lock). */
export const infoFields = ["name", "description", "rating", "tags", "listed", "category"] as const;
export type InfoField = (typeof infoFields)[number];
/** `category`: an IMVU category ID from categoryChoices, or null for its parent's. */
export type InfoInput = {
name?: string;
description?: string;
rating?: string;
tags?: string[];
listed?: boolean;
category?: number | null;
};
/** An IMVU category path, outermost first. */
export type CategoryPath = { id: number; name: string }[];
/** What the person editing may do beyond product:edit (from the permission check). */
export type EditRights = { creator: boolean; editLocked: boolean; rateLower: boolean };
/** Who's looking: drafts are only for their creator and moderators. */
export type ProductViewer = { id: string; moderator: boolean };
/** Drafts never published are deleted after this long. */
export const draftDays = 30;
/** An error for the person; the routes turn it into a Problem. */
export class ProductError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
/** An upload with a blocked file (docs/products.md): refused, and the route audits who tried. */
export class BlockedUpload extends ProductError {
constructor(
readonly personId: string,
readonly catalogId: string,
readonly files: FileEntry[],
) {
super(409, "blocked", "This upload has a file T5DE blocks.");
}
}
const base32 = "abcdefghijklmnopqrstuvwxyz234567";
export const newProductID = () =>
[...crypto.getRandomValues(new Uint8Array(10))].map((b) => base32[b & 31]).join("");
const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url");
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
const nameRule = (s: unknown, max: number) => {
const v = typeof s === "string" ? s.trim() : "";
// eslint-disable-next-line no-control-regex
if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined;
return v;
};
/** Tags as people type them: lowercase words (letters, digits, spaces, dashes), at most 10, each up to 24. */
export function checkTags(tags: unknown): string[] | undefined {
if (!Array.isArray(tags) || tags.length > 10) return undefined;
const out: string[] = [];
for (const t of tags) {
const v = typeof t === "string" ? t.trim().toLowerCase().replace(/\s+/g, " ") : "";
if (!v) continue;
if (v.length > 24 || !/^[\p{L}\p{N}][\p{L}\p{N} -]*$/u.test(v)) return undefined;
if (!out.includes(v)) out.push(v);
}
return out;
}
/** A file list as catalogs send it: checked and sorted by name. */
export function checkFiles(files: unknown): FileEntry[] | undefined {
if (!Array.isArray(files) || files.length === 0 || files.length > 512) return undefined;
const out: FileEntry[] = [];
const names = new Set<string>();
for (const f of files) {
const name = typeof f?.name === "string" ? f.name : "";
const hash = typeof f?.sha256 === "string" ? f.sha256 : "";
const size = Number(f?.size);
if (!name || name.length > 200 || names.has(name) || /[/\\]/.test(name)) return undefined;
if (!/^[0-9a-f]{64}$/.test(hash) || !Number.isSafeInteger(size) || size < 0) return undefined;
names.add(name);
out.push({ name, sha256: hash, size });
}
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
// Field by field: stored lists come back from JSONB with their keys in another order.
const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
a.length === b.length && a.every((f, i) => f.name === b[i]!.name && f.sha256 === b[i]!.sha256 && f.size === b[i]!.size);
// A parent's info another revision looked up lately is reused rather than asking IMVU again.
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
/**
* Whether a product is available to a person (docs/products.md, "Member"): it's published, and they made it, or a
* catalog hosting it is approved, hasn't banned them, and is open or has them as a member or on its team.
*/
export const availableTo = (personId: string) => sql`(${product.status} = 'published' and (
${product.creatorId} = ${personId} or exists (
select 1 from ${hosting} h
join ${catalog} c on c.id = h.catalog_id
left join ${catalogMember} m on m.catalog_id = c.id and m.user_id = ${personId}
where h.product_id = ${product.id} and c.status = 'approved' and coalesce(m.status, '') <> 'banned'
and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId}
or exists (select 1 from ${roleAssignment} r
where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id))
)))`;
/** Each product's latest revision (joined on product.latest, for lists). */
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
export class Products {
constructor(
private readonly db: Database,
private readonly signer: Signer,
private readonly imvu?: ImvuClient,
) {}
// ---- catalogs ----
async registerCatalog(
ownerId: string,
input: { name: string; url: string; contact: string; visibility: string; membership: string },
) {
const name = nameRule(input.name, 64);
const contact = nameRule(input.contact, 200);
let url: URL;
try {
url = new URL(input.url);
} catch {
throw new ProductError(400, "invalid", "That address isn't a URL.");
}
if (!name || !contact) throw new ProductError(400, "invalid", "A name and a takedown contact are needed.");
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1")
throw new ProductError(400, "invalid", "Catalogs need an https address.");
if (!["public", "private"].includes(input.visibility) || !["open", "approval", "invite"].includes(input.membership))
throw new ProductError(400, "invalid", "Choose a visibility and a membership.");
const clean = url.origin + url.pathname.replace(/\/+$/, "");
const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, clean));
if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered.");
const key = randomKey();
const [c] = await this.db
.insert(catalog)
.values({
id: newProductID(),
ownerId,
name,
url: clean,
contact,
visibility: input.visibility,
membership: input.membership,
keyHash: sha256(key),
})
.returning();
return { catalog: c!, key };
}
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved (or suspended, when asked)
* and the key is right. */
async catalogFor(header: string | null, suspendedToo = false): Promise<Catalog | undefined> {
const m = header?.match(/^Catalog ([a-z2-7]{10}):(\S+)$/);
if (!m) return undefined;
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, m[1]!));
if (!c || !(c.status === "approved" || (suspendedToo && c.status === "suspended"))) return undefined;
return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined;
}
async catalogs(viewerId?: string) {
const rows = await this.db
.select()
.from(catalog)
.where(
or(
and(eq(catalog.status, "approved"), eq(catalog.visibility, "public")),
viewerId ? eq(catalog.ownerId, viewerId) : sql`false`,
),
)
.orderBy(catalog.name);
return rows;
}
async allCatalogs() {
return this.db.select().from(catalog).orderBy(desc(catalog.createdAt));
}
async setCatalogStatus(id: string, status: "approved" | "rejected") {
const [c] = await this.db.update(catalog).set({ status }).where(eq(catalog.id, id)).returning();
if (!c) throw new ProductError(404, "not_found", "No such catalog.");
return c;
}
// ---- uploads ----
/** An upload token for a person to send a product (or a new revision of theirs) to a catalog. */
async uploadToken(personId: string, catalogId: string, productId?: string) {
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, catalogId));
if (!c || c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
if (productId) await this.own(personId, productId);
if (c.uploads !== "anyone" && c.ownerId !== personId) {
const [team] = await this.db
.select({ role: roleAssignment.role })
.from(roleAssignment)
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, c.id)));
const [m] = await this.db
.select({ status: catalogMember.status })
.from(catalogMember)
.where(and(eq(catalogMember.catalogId, c.id), eq(catalogMember.userId, personId)));
const ok = team !== undefined || (c.uploads === "members" && m?.status === "member");
if (!ok)
throw new ProductError(
403,
"forbidden",
c.uploads === "members" ? "Only this catalog's members can upload to it." : "Only this catalog's team can upload to it.",
);
}
const token = await this.signer.sign(
"t5de-upload",
{ sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) },
30 * 60,
);
return { token, url: `${c.url}/api/v1/uploads` };
}
/** A catalog registers an uploaded revision: a new product, or a new revision of one. */
async registerRevision(
c: Catalog,
body: { uploadToken: string; files: unknown; rating: string; parent: number; name: string; notes: string },
) {
const claims = await this.signer.verify(body.uploadToken, "t5de-upload");
if (!claims || claims.catalog !== c.id || typeof claims.sub !== "string")
throw new ProductError(401, "bad_token", "The upload token isn't valid for this catalog.");
const files = checkFiles(body.files);
if (!files) throw new ProductError(400, "invalid", "The file list isn't valid.");
if (body.rating !== "general" && body.rating !== "adult")
throw new ProductError(400, "invalid", "The rating must be general or adult.");
if (body.rating === "adult" && c.maxRating === "general")
throw new ProductError(400, "rating_ceiling", "This catalog hosts general-audience products only.");
const parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0;
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
const personId = claims.sub;
const blocked = new Set(
(
await this.db
.select({ sha256: fileBlock.sha256 })
.from(fileBlock)
.where(inArray(fileBlock.sha256, [...new Set(files.map((f) => f.sha256))]))
).map((b) => b.sha256),
);
if (blocked.size) throw new BlockedUpload(personId, c.id, files.filter((f) => blocked.has(f.sha256)));
const made = await this.db.transaction(async (tx) => {
let p: Product;
if (typeof claims.product === "string") {
const [found] = await tx.select().from(product).where(eq(product.id, claims.product)).for("update");
if (!found || found.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
if (!notes) throw new ProductError(400, "invalid", "Say what changed in this revision.");
const [latest] = await tx
.select()
.from(productRevision)
.where(and(eq(productRevision.productId, found.id), eq(productRevision.number, found.latest)));
if (latest && sameFiles(latest.files, files))
throw new ProductError(409, "unchanged", "These files are the same as the latest revision's.");
p = found;
} else {
const name = nameRule(body.name, 64) ?? "Untitled";
const [made] = await tx
.insert(product)
.values({ id: newProductID(), creatorId: personId, name, rating: body.rating, status: "draft" })
.returning();
p = made!;
}
const number = p.latest + 1;
const token = await this.signer.sign("t5de-revision", {
product: p.id,
revision: number,
rating: body.rating,
files,
parent,
});
await tx.insert(productRevision).values({
productId: p.id,
number,
files,
rating: body.rating,
parent,
notes,
token,
uploadedBy: personId,
});
// A product is adult if any revision is (or its creator says so).
const rating = p.rating === "adult" || body.rating === "adult" ? "adult" : "general";
await tx.update(product).set({ latest: number, rating }).where(eq(product.id, p.id));
await tx.insert(hosting).values({ catalogId: c.id, productId: p.id, number });
return { product: p.id, revision: number, draft: p.status === "draft" };
});
// The parent's image and categories: best effort, without holding up the upload (fillParentInfo retries).
if (parent > 0) this.lookUpParent(made.product, made.revision, parent).catch(() => {});
return made;
}
// ---- parents (the IMVU products revisions derive from) ----
/** Looks up and stores a revision's parent info. Throws if IMVU can't be reached (the info stays null). */
async lookUpParent(productId: string, number: number, parent: number): Promise<ParentInfo | undefined> {
if (!this.imvu || parent <= 0) return undefined;
const since = new Date(Date.now() - parentInfoFresh).toISOString();
const [known] = await this.db
.select({ info: productRevision.parentInfo })
.from(productRevision)
.where(
and(
eq(productRevision.parent, parent),
sql`(${productRevision.parentInfo}->>'checkedAt')::timestamptz > ${since}::timestamptz`,
),
)
.limit(1);
let info = known?.info ?? undefined;
if (!info) {
const p = await this.imvu.product(parent);
info = {
found: p !== null,
image: p?.image ?? null,
categories: p?.categories ?? [],
gender: p?.gender ?? "",
checkedAt: new Date().toISOString(),
};
}
await this.db
.update(productRevision)
.set({ parentInfo: info })
.where(and(eq(productRevision.productId, productId), eq(productRevision.number, number)));
return info;
}
/** Looks up parent info for revisions that have a parent and no info yet, a few at a time. */
async fillParentInfo(limit = 20): Promise<number> {
const due = await this.db
.select({ productId: productRevision.productId, number: productRevision.number, parent: productRevision.parent })
.from(productRevision)
.where(and(gt(productRevision.parent, 0), isNull(productRevision.parentInfo)))
.orderBy(desc(productRevision.createdAt))
.limit(limit);
let n = 0;
for (const r of due) {
try {
if (await this.lookUpParent(r.productId, r.number, r.parent)) n++;
} catch {
// IMVU unavailable: try again next time.
}
}
return n;
}
// ---- products ----
private async own(personId: string, productId: string) {
const [p] = await this.db.select().from(product).where(eq(product.id, productId));
if (!p) throw new ProductError(404, "not_found", "No such product.");
if (p.creatorId !== personId) throw new ProductError(403, "forbidden", "That product isn't yours.");
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (p.deletedAt) throw new ProductError(410, "deleted", "You deleted this product.");
return p;
}
/** Its creator deletes a product: it's no longer listed or added to libraries (those who have it keep it). */
async delete(personId: string, id: string) {
await this.own(personId, id);
await this.db.update(product).set({ deletedAt: new Date() }).where(eq(product.id, id));
}
/** Moderation removes a product: gone everywhere. */
async removeByModeration(id: string, reason: string) {
const [p] = await this.db
.update(product)
.set({ removedAt: new Date(), removedReason: reason })
.where(and(eq(product.id, id), isNull(product.removedAt)))
.returning();
if (!p) throw new ProductError(404, "not_found", "No such product, or it's removed already.");
return p;
}
/** Undoes a moderation removal (an upheld appeal, a mistake): its page, files and library entries are back, since
* removing only hides them. */
async unremove(id: string) {
const [p] = await this.db
.update(product)
.set({ removedAt: null, removedReason: null })
.where(and(eq(product.id, id), sql`${product.removedAt} is not null`))
.returning();
if (!p) throw new ProductError(404, "not_found", "No such removed product.");
return p;
}
/**
* Published products, newest first; adult ones only if adult; unlisted ones only for their creator. `category`:
* only those whose latest revision's parent is in that IMVU category (anywhere in its path); `tag`: those with
* the tag; `catalog`: those the catalog hosts. Each comes with its latest revision's parent and parent info.
*/
async list(opts: {
q?: string;
creator?: string;
category?: number;
tag?: string;
catalog?: string;
adult: boolean;
viewerId?: string;
limit?: number;
}) {
const conds = [
ne(product.latest, 0),
eq(product.status, "published"),
isNull(product.deletedAt),
isNull(product.removedAt),
opts.viewerId ? or(eq(product.listed, true), eq(product.creatorId, opts.viewerId))! : eq(product.listed, true),
];
if (!opts.adult) conds.push(eq(product.rating, "general"));
if (opts.q) conds.push(ilike(product.name, `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`));
if (opts.creator) conds.push(eq(user.username, opts.creator.toLowerCase()));
if (opts.category !== undefined)
conds.push(
sql`coalesce(${product.category}, ${productRevision.parentInfo}->'categories') @> ${JSON.stringify([{ id: opts.category }])}::jsonb`,
);
const tag = opts.tag !== undefined ? checkTags([opts.tag])?.[0] : undefined;
if (opts.tag !== undefined) conds.push(tag ? sql`${product.tags} @> ${JSON.stringify([tag])}::jsonb` : sql`false`);
if (opts.catalog)
conds.push(sql`exists (select 1 from ${hosting} h where h.product_id = ${product.id} and h.catalog_id = ${opts.catalog})`);
const limit = Math.max(1, Math.min(Math.trunc(opts.limit ?? 60) || 60, 200));
return this.db
.select({
product,
creator: user.displayUsername,
creatorUsername: user.username,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
})
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(...conds))
.orderBy(desc(product.createdAt))
.limit(limit);
}
/** A product's latest revision's parent and parent info (for views of one product). */
async latestParent(p: Product) {
const [r] = await this.db
.select({ parent: productRevision.parent, parentInfo: productRevision.parentInfo })
.from(productRevision)
.where(and(eq(productRevision.productId, p.id), eq(productRevision.number, p.latest)));
return r;
}
async get(id: string, adult: boolean, viewer?: ProductViewer) {
const [row] = await this.db
.select({ product, creator: user.displayUsername, creatorUsername: user.username })
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.where(eq(product.id, id));
if (!row || row.product.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (row.product.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
// Its creator and moderators always see it: drafts, and adult products without an adult account.
const responsible = viewer?.moderator || viewer?.id === row.product.creatorId;
if (row.product.status === "draft" && !responsible) throw new ProductError(404, "not_found", "No such product.");
if (row.product.rating === "adult" && !adult && !responsible) throw new ProductError(404, "not_found", "No such product.");
const revisions = await this.db
.select()
.from(productRevision)
.where(eq(productRevision.productId, id))
.orderBy(desc(productRevision.number));
const hosts = await this.db
.select({ number: hosting.number, name: catalog.name, url: catalog.url })
.from(hosting)
.innerJoin(catalog, eq(catalog.id, hosting.catalogId))
.where(and(eq(hosting.productId, id), eq(catalog.status, "approved")));
return { ...row, revisions, hosts };
}
// ---- product info: edits, history, locks, publishing ----
/** A product for changing its info: not removed; deleted ones only by others than the creator (moderators). */
private async editable(id: string, rights: EditRights) {
const [p] = await this.db.select().from(product).where(eq(product.id, id));
if (!p || p.latest === 0) throw new ProductError(404, "not_found", "No such product.");
if (p.removedAt) throw new ProductError(410, "removed", "This product was removed by moderation.");
if (p.deletedAt && rights.creator) throw new ProductError(410, "deleted", "You deleted this product.");
return p;
}
/**
* Changes a product's info (the route checked product:edit). Only fields that change are kept in its history.
* Someone other than the creator must give a reason; locked fields need editLocked; lowering the rating needs
* rateLower (and files rated general). Returns the product, and the history entry if anything changed.
*/
async updateInfo(
byId: string,
id: string,
input: InfoInput,
reason: string,
rights: EditRights,
revertOf?: number,
): Promise<{ product: Product; entry?: number; fields: InfoField[] }> {
const p = await this.editable(id, rights);
reason = reason.trim().slice(0, 500);
if (!rights.creator && !reason)
throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason with the change.");
const next: Partial<Pick<Product, InfoField>> = {};
if (input.name !== undefined) {
const name = nameRule(input.name, 64);
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
next.name = name;
}
if (input.description !== undefined) {
if (input.description.length > maxDescription)
throw new ProductError(400, "invalid", "That description is too long.");
next.description = cleanHTML(input.description);
}
if (input.rating !== undefined) {
if (input.rating !== "general" && input.rating !== "adult")
throw new ProductError(400, "invalid", "The rating must be general or adult.");
next.rating = input.rating;
}
if (input.tags !== undefined) {
const tags = checkTags(input.tags);
if (!tags)
throw new ProductError(400, "invalid", "Up to 10 tags, each up to 24 letters, digits, spaces or dashes.");
next.tags = tags;
}
if (input.listed !== undefined) next.listed = input.listed;
if (input.category !== undefined) {
if (input.category === null) next.category = null;
else {
const path = (await this.categoryChoices(p)).find((c) => c.at(-1)!.id === input.category);
if (!path)
throw new ProductError(400, "invalid", "Choose one of the categories offered for this product.");
next.category = path;
}
}
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const changes: Record<string, { from: unknown; to: unknown }> = {};
for (const f of infoFields)
if (next[f] !== undefined && !same(next[f], p[f])) changes[f] = { from: p[f], to: next[f] };
const fields = Object.keys(changes) as InfoField[];
if (fields.length === 0) return { product: p, fields };
const locked = fields.filter((f) => p.locked.includes(f));
if (locked.length && !rights.editLocked)
throw new ProductError(403, "locked", `A moderator locked this product's ${locked.join(", ")}.`);
if (changes.rating?.to === "general") {
if (!rights.rateLower)
throw new ProductError(400, "review_needed", "Lowering a rating needs a moderator's review.");
await this.checkFilesGeneral(id);
}
return this.db.transaction(async (tx) => {
const [updated] = await tx.update(product).set(next).where(eq(product.id, id)).returning();
const [entry] = await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: revertOf ? "revert" : "edit", changes, reason, revertOf })
.returning({ id: productInfoChange.id });
return { product: updated!, entry: entry!.id, fields };
});
}
/** Refuses (400 files_adult) if a revision of the product that isn't retracted is rated adult. */
async checkFilesGeneral(id: string) {
const [adultFiles] = await this.db
.select({ n: productRevision.number })
.from(productRevision)
.where(and(eq(productRevision.productId, id), eq(productRevision.rating, "adult"), eq(productRevision.retracted, false)))
.limit(1);
if (adultFiles)
throw new ProductError(400, "files_adult", `Revision ${adultFiles.n}'s files are rated adult; retract it first.`);
}
/** A draft becomes published: listed (unless unlisted), and people can get it. */
async publish(byId: string, id: string) {
const p = await this.editable(id, { creator: true, editLocked: false, rateLower: false });
if (p.status !== "draft") throw new ProductError(409, "published", "It's published already.");
if (!nameRule(p.name, 64)) throw new ProductError(400, "invalid", "Give it a name first.");
return this.db.transaction(async (tx) => {
const [updated] = await tx
.update(product)
.set({ status: "published", publishedAt: new Date() })
.where(eq(product.id, id))
.returning();
await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: "publish", changes: { status: { from: "draft", to: "published" } } });
return updated!;
});
}
/** Moderators lock info fields (the route checked product:lock); the reason is kept in the history. */
async setLocks(byId: string, id: string, fields: string[], reason: string) {
const p = await this.editable(id, { creator: false, editLocked: true, rateLower: true });
const want = infoFields.filter((f) => fields.includes(f));
if (want.length !== new Set(fields).size)
throw new ProductError(400, "invalid", `Only these can be locked: ${infoFields.join(", ")}.`);
reason = reason.trim().slice(0, 500);
if (!reason) throw new ProductError(400, "reason_needed", "Say why: the creator sees the reason.");
if (JSON.stringify(want) === JSON.stringify(infoFields.filter((f) => p.locked.includes(f)))) return p;
return this.db.transaction(async (tx) => {
const [updated] = await tx.update(product).set({ locked: want }).where(eq(product.id, id)).returning();
await tx
.insert(productInfoChange)
.values({ productId: id, byId, kind: "lock", changes: { locked: { from: p.locked, to: want } }, reason });
return updated!;
});
}
/**
* The categories a product may be filed under (docs/products.md): every IMVU category path the hub has seen on a
* revision's parent, and each step of those paths, under the same top-level category as its own parent's (any, if
* its parent's is unknown). Keeping the top level keeps it the same kind of product (clothing, furniture, rooms).
*/
async categoryChoices(p: Product): Promise<CategoryPath[]> {
const parent = (await this.latestParent(p))?.parentInfo?.categories ?? [];
const seen = await this.db
.selectDistinct({ path: sql<CategoryPath>`${productRevision.parentInfo}->'categories'` })
.from(productRevision)
.where(sql`jsonb_array_length(coalesce(${productRevision.parentInfo}->'categories', '[]'::jsonb)) > 0`);
const byId = new Map<number, CategoryPath>();
for (const path of [parent, ...seen.map((s) => s.path)])
for (let i = 0; i < path.length; i++)
if (!byId.has(path[i]!.id)) byId.set(path[i]!.id, path.slice(0, i + 1).map((c) => ({ id: c.id, name: c.name })));
const top = parent[0]?.id;
const label = (c: CategoryPath) => c.map((x) => x.name).join(" / ");
return [...byId.values()].filter((c) => top === undefined || c[0]!.id === top).sort((a, b) => label(a).localeCompare(label(b)));
}
/** A product's info history, newest first, with who made each change. */
async history(id: string) {
return this.db
.select({
id: productInfoChange.id,
at: productInfoChange.createdAt,
kind: productInfoChange.kind,
changes: productInfoChange.changes,
reason: productInfoChange.reason,
revertOf: productInfoChange.revertOf,
byId: productInfoChange.byId,
by: user.displayUsername,
})
.from(productInfoChange)
.leftJoin(user, eq(user.id, productInfoChange.byId))
.where(eq(productInfoChange.productId, id))
.orderBy(desc(productInfoChange.id));
}
/** Puts back what a history entry changed, as a new entry (the same rules as an edit). */
async revert(byId: string, id: string, entryId: number, reason: string, rights: EditRights) {
const [e] = await this.db
.select()
.from(productInfoChange)
.where(and(eq(productInfoChange.id, entryId), eq(productInfoChange.productId, id)));
if (!e || (e.kind !== "edit" && e.kind !== "revert")) throw new ProductError(404, "not_found", "No such change to undo.");
const input: Record<string, unknown> = {};
for (const f of infoFields) if (e.changes[f]) input[f] = e.changes[f].from;
// Categories are kept as paths; an edit takes the category's ID.
if (Array.isArray(input.category)) input.category = (input.category as CategoryPath).at(-1)?.id ?? null;
const done = await this.updateInfo(byId, id, input as InfoInput, reason, rights, e.id);
if (done.fields.length === 0) throw new ProductError(409, "unchanged", "Its info is already as it was before that change.");
return done;
}
/** Deletes drafts never published within draftDays; their catalogs are told to delete the files. */
async purgeDrafts(now = new Date()): Promise<number> {
const before = new Date(now.getTime() - draftDays * 24 * 60 * 60 * 1000);
const stale = await this.db
.select({ id: product.id })
.from(product)
.where(and(eq(product.status, "draft"), sql`${product.createdAt} < ${before.toISOString()}::timestamptz`));
for (const { id } of stale) {
await this.db.transaction(async (tx) => {
const hosted = await tx
.select({ catalogId: hosting.catalogId, number: hosting.number, files: productRevision.files })
.from(hosting)
.innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)))
.where(eq(hosting.productId, id));
for (const h of hosted)
await tx
.insert(catalogChange)
.values({ catalogId: h.catalogId, data: { type: "delete", product: id, revision: h.number, files: h.files } });
await tx.delete(hosting).where(eq(hosting.productId, id));
await tx.delete(product).where(eq(product.id, id));
});
}
return stale.length;
}
/**
* Products for the console: a person's own, or with `all` (moderators) everyone's; drafts, unlisted, deleted and
* removed ones included. `status`: draft, published, unlisted, deleted, removed or locked.
*/
async manage(opts: { creatorId?: string; q?: string; status?: string; limit?: number }) {
const conds = [ne(product.latest, 0)];
if (opts.creatorId) conds.push(eq(product.creatorId, opts.creatorId));
if (opts.q) {
const like = `%${opts.q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
conds.push(or(ilike(product.name, like), eq(product.id, opts.q), ilike(user.username, like))!);
}
const live = and(isNull(product.deletedAt), isNull(product.removedAt))!;
switch (opts.status) {
case "draft":
conds.push(eq(product.status, "draft"), live);
break;
case "published":
conds.push(eq(product.status, "published"), eq(product.listed, true), live);
break;
case "unlisted":
conds.push(eq(product.status, "published"), eq(product.listed, false), live);
break;
case "deleted":
conds.push(sql`${product.deletedAt} is not null`, isNull(product.removedAt));
break;
case "removed":
conds.push(sql`${product.removedAt} is not null`);
break;
case "locked":
conds.push(sql`jsonb_array_length(${product.locked}) > 0`);
break;
}
return this.db
.select({
product,
creator: user.displayUsername,
creatorUsername: user.username,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
libraries: sql<number>`(select count(*) from ${library} l where l.product_id = ${product.id})::int`,
})
.from(product)
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(...conds))
.orderBy(desc(product.updatedAt))
.limit(Math.max(1, Math.min(opts.limit ?? 100, 200)));
}
/** How many drafts a person has (for the console's to-dos). */
async draftCount(creatorId: string) {
const [row] = await this.db
.select({ n: sql<number>`count(*)::int` })
.from(product)
.where(and(eq(product.creatorId, creatorId), eq(product.status, "draft"), isNull(product.deletedAt), ne(product.latest, 0)));
return row?.n ?? 0;
}
async retract(personId: string, id: string, number: number) {
await this.own(personId, id);
const [r] = await this.db
.update(productRevision)
.set({ retracted: true })
.where(and(eq(productRevision.productId, id), eq(productRevision.number, number)))
.returning();
if (!r) throw new ProductError(404, "not_found", "No such revision.");
return r;
}
// ---- libraries ----
async library(personId: string) {
return this.db
.select({
product,
stayOn: library.stayOn,
addedAt: library.addedAt,
creator: user.displayUsername,
parent: productRevision.parent,
parentInfo: productRevision.parentInfo,
})
.from(library)
.innerJoin(product, eq(product.id, library.productId))
.innerJoin(user, eq(user.id, product.creatorId))
.leftJoin(productRevision, latestJoin)
.where(and(eq(library.userId, personId), isNull(product.removedAt), availableTo(personId)))
.orderBy(desc(library.addedAt));
}
async inLibrary(personId: string, productId: string) {
const [row] = await this.db
.select({ stayOn: library.stayOn })
.from(library)
.where(and(eq(library.userId, personId), eq(library.productId, productId)));
return row;
}
async add(personId: string, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
// Seeing an adult product is open to its creator and moderators; getting it isn't.
if (p.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
if (p.product.status !== "published") throw new ProductError(409, "draft", "This product isn't published yet.");
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
throw new ProductError(410, "deleted", "Its creator deleted this product.");
if (!(await this.available(personId, productId)))
throw new ProductError(403, "members_only", "Join a catalog hosting this product to get it.");
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
}
async available(personId: string, productId: string): Promise<boolean> {
const [row] = await this.db
.select({ id: product.id })
.from(product)
.where(and(eq(product.id, productId), availableTo(personId)));
return row !== undefined;
}
/** The approved catalogs hosting a product, with their membership rule (for "join to get it"). */
async hostingCatalogs(productId: string) {
return this.db
.selectDistinct({ id: catalog.id, name: catalog.name, membership: catalog.membership, visibility: catalog.visibility })
.from(hosting)
.innerJoin(catalog, eq(catalog.id, hosting.catalogId))
.where(and(eq(hosting.productId, productId), eq(catalog.status, "approved")));
}
async remove(personId: string, productId: string) {
await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId)));
}
async stayOn(personId: string, productId: string, number: number | null) {
if (number !== null) {
const [r] = await this.db
.select()
.from(productRevision)
.where(and(eq(productRevision.productId, productId), eq(productRevision.number, number)));
if (!r || r.retracted) throw new ProductError(404, "not_found", "No such revision.");
}
const [row] = await this.db
.update(library)
.set({ stayOn: number })
.where(and(eq(library.userId, personId), eq(library.productId, productId)))
.returning();
if (!row) throw new ProductError(404, "not_found", "That product isn't in your library.");
}
/**
* The revision a person uses of a product: the one they stay on, else the latest; a retracted one gives way to
* the nearest earlier one that isn't (or later, if none).
*/
async revisionFor(personId: string | undefined, productId: string, adult: boolean, viewer?: ProductViewer) {
const p = await this.get(productId, adult, viewer);
if (p.product.rating === "adult" && !adult) throw new ProductError(404, "not_found", "No such product.");
const pin = personId ? (await this.inLibrary(personId, productId))?.stayOn : undefined;
const want = pin ?? p.product.latest;
const usable = p.revisions.filter((r) => !r.retracted);
if (usable.length === 0) throw new ProductError(404, "not_found", "That product has no usable revision.");
const r =
usable.find((r) => r.number === want) ??
usable.find((r) => r.number < want) ??
usable[usable.length - 1]!;
const hosts = p.hosts.filter((h) => h.number === r.number).map((h) => h.url);
return { product: p.product, creator: p.creator, revision: r, catalogs: hosts };
}
/** People's display names for creators, by ID (for the console). */
async creators(ids: string[]) {
if (!ids.length) return [];
return this.db.select({ id: user.id, username: user.displayUsername }).from(user).where(inArray(user.id, ids));
}
}
+645 -22
View File
@@ -1,33 +1,204 @@
// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the // The live connection with signed-in proxies: which IMVU account each client is using (presence), the community
// community products it wears. Later, room channels. // products it wears, the IMVU chats it's in (the room channel: T5DE users in the same chat see each other's
// community products), and T5DE-hosted rooms (the hub is their chat server; docs/hosted-rooms.md).
// //
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as // Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
// a bearer token): // a bearer token):
// proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account // proxy -> hub {"type": "session", "imvu_id": 123} the client signed in as this IMVU account
// proxy -> hub {"type": "outfit", "items": [{"id", "name"}]} the community products it wears now // proxy -> hub {"type": "outfit", "items": [{"id", "name", "adult"}]} the community products it wears now
// proxy -> hub {"type": "join", "chat": "1379947638", "room": "1-2"} the client is in this IMVU chat (room: the
// IMVU room instance, for a room's chat)
// proxy -> hub {"type": "leave", "chat": "1379947638"}
// hub -> proxy {"type": "chat", "chat": "...", "avatars": [{"imvu_id", "items"}]}
// the other T5DE users in that chat and what they wear; sent on join and on every change. Adult
// products only go to clients whose IMVU account counts as adult.
// hub -> proxy {"type": "decor", "room": "1-2"}
// that IMVU room's decorations changed (GET /api/v1/decorations/1-2 again); sent to every session
// with a chat in that room
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."} // hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
//
// Hosted rooms (n: the room's number; its chat ID is 2100000000 + n):
// proxy -> hub {"type": "room.join", "room": n} joining again (a reconnect) answers the same
// proxy -> hub {"type": "room.leave", "room": n}
// proxy -> hub {"type": "room.seat", "room": n, "seat": 3} 0-99
// proxy -> hub {"type": "room.send", "room": n, "mount": "messages" | "control", "message": "..."}
// to everyone else in the room (up to 64 KB); a "messages" JSON whose userId isn't the sender's IMVU
// account is dropped, and so is anything over the rate (bursts of 20, then 10 a second)
// hub -> proxy {"type": "room.joined", "room": n, "chat": chatId, "participants": [{"imvu_id", "seat", "name"}],
// "sequence": s} (the latest message number)
// hub -> proxy {"type": "room.error", "room": n, "reason": "full" | "closed" | "adult" | "not_found" |
// "not_linked" | "too_many", "message": "..."}
// hub -> proxy {"type": "room.message", "room": n, "from": imvuId, "mount": "...", "message": "...", "sequence": s}
// hub -> proxy {"type": "room.participants", "room": n, "participants": [...]} on every join, leave or seat
// hub -> proxy {"type": "room.closed", "room": n} the room closed, or this session is out of it (its IMVU account
// was unlinked or changed, or the room became adult)
//
// Phase 2 (docs/hosted-rooms.md, "Phase 2", has every detail):
// proxy -> hub {"type": "room.join", "room": n, "invite"?: id} invite: an accepted invite, for a private room
// proxy -> hub {"type": "room.boot", "room": n, "imvu_id": X, "ban"?: bool, "minutes"?: m} owner or moderator
// hub -> proxy {"type": "room.boot_result", "room": n, "imvu_id": X, "ok": bool, "ban"?: bool, "reason"?, "message"?}
// hub -> proxy {"type": "room.closed", "room": n, "reason": "booted"} to the booted account's sessions
// proxy -> hub {"type": "room.invite", "room": n, "imvu_id": X}
// hub -> proxy {"type": "room.invite_result", "room": n, "imvu_id": X, "ok": bool, "invite"?: id, "reason"?, "message"?}
// hub -> proxy {"type": "room.invited", "room": n, "invite": id, "from": imvuId, "room_name", "owner_name"} (to X)
// proxy -> hub {"type": "room.invite_reply", "invite": id, "accept": bool, "note"?: "..."} (from X)
// hub -> proxy {"type": "room.invite_answer", "invite": id, "to": X, "accept": bool, "note"?, "offline"?: true}
// proxy -> hub {"type": "room.watch", "rooms": [n, ...]} replaces the watched set (up to 100)
// hub -> proxy {"type": "room.occupancy", "room": n, "participants": [{"imvu_id", "seat", "name"}]}
export type Item = { id: string; name: string }; export type Item = { id: string; name: string; adult: boolean };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] }; export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[]; rooms: string[] };
type Session = { userId: string; imvuId: number; since: Date; outfit: Item[]; close: () => void }; /** What realtime needs to know of a hosted room to let someone in. */
export type HostedRoomInfo = {
closed: boolean;
adult: boolean;
maxUsers: number;
privacy: "public" | "private";
ownerImvuId: number;
moderators: number[];
name: string;
ownerName: string;
};
export type Owners = (imvuId: number) => Promise<string | undefined>; /** A boot (or ban) that was allowed: app.ts stores the ban and writes the audit log. */
export type Boot = { room: number; imvuId: number; by: number; byUserId: string; ban: boolean; minutes: number };
/** Hosted rooms' chat IDs and instance IDs start here (docs/hosted-rooms.md). */
export const hostedBase = 2_100_000_000;
/** The highest room number: chat IDs must stay XML-RPC integers. */
export const maxHostedN = 2_147_483_647 - hostedBase;
type Participant = { imvu_id: number; seat: number; name: string };
// requested: the IMVU account the client said it's using, even before it's linked (it counts once it is).
// chats: IMVU chat ID -> room instance ("" for chats that aren't a room's). lastSent: per chat, what it was last told.
// hosted: the hosted rooms it's in. tokens, refilled: its allowance of room messages. watching: the hosted rooms it
// watches the occupancy of (watchGen: which room.watch that was). invites, invitesRefilled: its allowance of invites.
type Session = {
userId: string;
imvuId: number;
requested: number;
adult: boolean;
since: Date;
outfit: Item[];
chats: Map<string, string>;
lastSent: Map<string, string>;
hosted: Set<number>;
tokens: number;
refilled: number;
watching: Set<number>;
watchGen: number;
invites: number;
invitesRefilled: number;
send: (message: object) => void;
close: () => void;
};
// A hosted room in use: who's in it (session ID -> IMVU account and seat) and its latest message number. Kept while
// the hub runs, so the numbers keep counting up when a room empties and fills again; dropped when it closes.
// admitted: IMVU accounts let into it (a private room) with an invite, until they leave or are booted.
type Hosted = { members: Map<string, { imvuId: number; seat: number; name: string }>; sequence: number; admitted: Set<number> };
// An invitation to a hosted room: single use, for 10 minutes from when it was made. Kept in memory only.
type Invite = { id: string; room: number; from: number; to: number; expires: number; answered: boolean; accepted: boolean };
export type RealtimeDeps = {
/** The person an IMVU account is linked to. */
owner: (imvuId: number) => Promise<string | undefined>;
/** Whether a person's IMVU account counts as adult (the age rule). */
adult?: (userId: string, imvuId: number) => Promise<boolean>;
/** A client entered an IMVU room (to learn about rooms for the rooms list). */
room?: (instanceId: string) => void;
/** A hosted room (undefined: there's none by that number). */
hostedRoom?: (n: number) => Promise<HostedRoomInfo | undefined>;
/** Whether an IMVU account is banned from a hosted room now. */
hostedBanned?: (n: number, imvuId: number) => Promise<boolean>;
/** Someone was booted (and maybe banned): store the ban, log it. */
hostedBoot?: (boot: Boot) => Promise<void>;
/** A person joined a hosted room as that IMVU account (for their recent rooms). */
hostedVisit?: (userId: string, n: number, imvuId: number) => void;
/** The IMVU username of a linked account, from its verified link ("" if unknown). */
accountName?: (imvuId: number) => Promise<string>;
/**
* Of these hub product IDs, the ones a person may wear (available to them: docs/products.md, "Member"). Outfit
* items with other hub IDs aren't relayed; items that aren't hub IDs (local packs) are.
*/
wearable?: (userId: string, productIds: string[]) => Promise<Set<string>>;
};
const hubProductID = /^[a-z2-7]{10}$/;
const maxChats = 16;
const chatPattern = /^[0-9]{1,20}$/;
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
const maxHostedPerSession = 5;
const maxRoomMessage = 64 * 1024; // bytes, as UTF-8
const sendBurst = 20;
const sendPerSecond = 10;
const maxWatched = 100;
const inviteLifetime = 10 * 60 * 1000;
const inviteBurst = 10;
const invitesPerMinute = 10;
const maxNote = 200;
const maxBanMinutes = 10 * 366 * 24 * 60;
const roomErrors = {
not_linked: "Sign in to T5DE with a linked IMVU account first.",
not_found: "No such room.",
closed: "This room is closed.",
adult: "This room is for adults.",
full: "This room is full.",
too_many: "You're in too many rooms.",
banned: "You're banned from this room.",
private: "This room is private.",
} as const;
// Reasons in room.boot_result and room.invite_result.
const actionErrors = {
not_linked: roomErrors.not_linked,
not_found: roomErrors.not_found,
closed: roomErrors.closed,
forbidden: "You can't do that in this room.",
not_in_room: "Join the room first.",
banned: "They're banned from this room.",
adult: "This room is for adults.",
rate_limited: "Too many invitations. Try again in a minute.",
} as const;
export class Realtime { export class Realtime {
private sessions = new Map<string, Session>(); private sessions = new Map<string, Session>();
private hosted = new Map<number, Hosted>();
private watchers = new Map<number, Set<string>>();
private invites = new Map<string, Invite>();
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private readonly deps: Required<RealtimeDeps>;
constructor(private readonly owner: Owners) {} constructor(deps: RealtimeDeps | RealtimeDeps["owner"]) {
const d = typeof deps === "function" ? { owner: deps } : deps;
this.deps = {
adult: async () => false,
room: () => {},
hostedRoom: async () => undefined,
hostedBanned: async () => false,
hostedBoot: async () => {},
hostedVisit: () => {},
accountName: async () => "",
wearable: async (_, ids) => new Set(ids),
...d,
};
}
/** Calls fn on every change of presence or outfits; returns a function that stops it. */ /** Calls fn on every change of presence, outfits or rooms; returns a function that stops it. */
subscribe(fn: () => void): () => void { subscribe(fn: () => void): () => void {
this.listeners.add(fn); this.listeners.add(fn);
return () => this.listeners.delete(fn); return () => this.listeners.delete(fn);
} }
private changed() { private changed(chats: Iterable<string> = []) {
for (const chat of new Set(chats)) this.sync(chat);
for (const fn of this.listeners) fn(); for (const fn of this.listeners) fn();
} }
@@ -38,7 +209,8 @@ export class Realtime {
if (!s.imvuId) continue; if (!s.imvuId) continue;
const prev = byId.get(s.imvuId); const prev = byId.get(s.imvuId);
if (prev && prev.since > s.since) continue; if (prev && prev.since > s.since) continue;
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] }); const rooms = [...s.chats.values()].filter(Boolean);
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit], rooms });
} }
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime()); return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
} }
@@ -47,14 +219,80 @@ export class Realtime {
return this.sessions.size; return this.sessions.size;
} }
open(id: string, userId: string, close: () => void) { open(id: string, userId: string, close: () => void, send: (message: object) => void = () => {}) {
this.sessions.set(id, { userId, imvuId: 0, since: new Date(), outfit: [], close }); this.sessions.set(id, {
userId,
imvuId: 0,
requested: 0,
adult: false,
since: new Date(),
outfit: [],
chats: new Map(),
lastSent: new Map(),
hosted: new Set(),
tokens: sendBurst,
refilled: Date.now(),
watching: new Set(),
watchGen: 0,
invites: inviteBurst,
invitesRefilled: Date.now(),
send,
close,
});
} }
close(id: string) { close(id: string) {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (s) {
this.leaveAllHosted(id, s, false);
this.unwatch(id, s);
}
this.sessions.delete(id); this.sessions.delete(id);
if (s?.imvuId) this.changed(); if (s && (s.imvuId || s.chats.size)) this.changed(s.chats.keys());
}
/** A person just linked an IMVU account: their clients already using it count now. */
async linked(userId: string, imvuId: number) {
const chats: string[] = [];
let changed = false;
for (const s of this.sessions.values()) {
if (s.userId === userId && s.requested === imvuId && s.imvuId !== imvuId) {
s.imvuId = imvuId;
s.since = new Date();
s.adult = await this.deps.adult(userId, imvuId);
chats.push(...s.chats.keys());
changed = true;
}
}
if (changed) this.changed(chats);
}
/** An IMVU account was unlinked (or moved): it no longer counts for that person's clients. */
unlinked(userId: string, imvuId: number) {
const chats: string[] = [];
let changed = false;
for (const [id, s] of this.sessions) {
if (s.userId === userId && s.imvuId === imvuId) {
this.leaveAllHosted(id, s, true);
s.imvuId = 0;
s.adult = false;
s.outfit = [];
chats.push(...s.chats.keys());
changed = true;
}
}
if (changed) this.changed(chats);
}
/** Sends a message to all of a person's connections. */
notify(userId: string, message: object) {
for (const s of this.sessions.values()) if (s.userId === userId) s.send(message);
}
/** An IMVU room's decorations changed: every session in one of its chats is told, so its proxy fetches them. */
decorated(instance: string) {
const message = { type: "decor", room: instance };
for (const s of this.sessions.values()) if ([...s.chats.values()].includes(instance)) s.send(message);
} }
/** Closes a person's connections (banned, or signed out of their computers). */ /** Closes a person's connections (banned, or signed out of their computers). */
@@ -62,35 +300,420 @@ export class Realtime {
for (const s of this.sessions.values()) if (s.userId === userId) s.close(); for (const s of this.sessions.values()) if (s.userId === userId) s.close();
} }
/** Tells everyone in a chat about the other T5DE users there (only those it wasn't told already). */
private sync(chat: string) {
const members = [...this.sessions.values()].filter((s) => s.chats.has(chat));
for (const viewer of members) {
const avatars = new Map<number, Item[]>();
for (const other of members) {
if (!other.imvuId || other.imvuId === viewer.imvuId) continue;
avatars.set(
other.imvuId,
other.outfit.filter((it) => !it.adult || viewer.adult),
);
}
const message = {
type: "chat",
chat,
avatars: [...avatars].map(([imvuId, items]) => ({ imvu_id: imvuId, items })),
};
const text = JSON.stringify(message);
if (viewer.lastSent.get(chat) === text) continue;
viewer.lastSent.set(chat, text);
viewer.send(message);
}
}
// ---- Hosted rooms ----
/** How many people are in a hosted room now (IMVU accounts, each once). */
occupancy(n: number): number {
return new Set([...(this.hosted.get(n)?.members.values() ?? [])].map((m) => m.imvuId)).size;
}
/** A hosted room's participants: each IMVU account once, with the seat it last took and its IMVU name. */
participants(n: number): Participant[] {
const seats = new Map<number, { seat: number; name: string }>();
for (const m of this.hosted.get(n)?.members.values() ?? []) seats.set(m.imvuId, { seat: m.seat, name: m.name });
return [...seats].map(([imvuId, m]) => ({ imvu_id: imvuId, seat: m.seat, name: m.name }));
}
private toRoom(n: number, message: object, except?: string) {
for (const id of this.hosted.get(n)?.members.keys() ?? []) if (id !== except) this.sessions.get(id)?.send(message);
}
private participantsChanged(n: number, except?: string) {
const participants = this.participants(n);
this.toRoom(n, { type: "room.participants", room: n, participants }, except);
const occupancy = { type: "room.occupancy", room: n, participants };
for (const w of this.watchers.get(n) ?? []) this.sessions.get(w)?.send(occupancy);
}
/** Takes a session out of a hosted room; tell: say so (room.closed, with the reason if any), when it didn't ask to
* leave. */
private leaveHosted(id: string, s: Session, n: number, tell: boolean, reason?: string) {
s.hosted.delete(n);
if (!this.hosted.get(n)?.members.delete(id)) return;
if (tell) s.send({ type: "room.closed", room: n, ...(reason ? { reason } : {}) });
this.participantsChanged(n);
}
private toAccount(imvuId: number, message: object) {
for (const s of this.sessions.values()) if (s.imvuId === imvuId) s.send(message);
}
private unwatch(id: string, s: Session) {
for (const n of s.watching) {
const w = this.watchers.get(n);
w?.delete(id);
if (w?.size === 0) this.watchers.delete(n);
}
s.watching.clear();
}
private leaveAllHosted(id: string, s: Session, tell: boolean) {
for (const n of [...s.hosted]) this.leaveHosted(id, s, n, tell);
}
/** A hosted room closed: everyone in it is told, and its state is dropped. */
hostedClosed(n: number) {
const watching = this.watchers.get(n);
if (watching) {
this.watchers.delete(n);
for (const w of watching) {
const s = this.sessions.get(w);
s?.watching.delete(n);
s?.send({ type: "room.occupancy", room: n, participants: [] });
}
}
for (const [id, inv] of this.invites) if (inv.room === n) this.invites.delete(id);
const room = this.hosted.get(n);
if (!room) return;
this.hosted.delete(n);
for (const id of room.members.keys()) {
const s = this.sessions.get(id);
s?.hosted.delete(n);
s?.send({ type: "room.closed", room: n });
}
}
/** A hosted room's settings changed: if it became adult, those who aren't leave. (A lower maxUsers only stops
* more from coming in.) */
hostedChanged(n: number, info: HostedRoomInfo) {
if (info.closed) return this.hostedClosed(n);
if (!info.adult) return;
for (const id of [...(this.hosted.get(n)?.members.keys() ?? [])]) {
const s = this.sessions.get(id);
if (s && !s.adult) this.leaveHosted(id, s, n, true);
}
}
/** Whether an account is the room's owner account, or one of its moderators. */
private static staff(info: HostedRoomInfo, imvuId: number) {
return { owner: imvuId > 0 && imvuId === info.ownerImvuId, moderator: imvuId > 0 && info.moderators.includes(imvuId) };
}
private async joinHosted(id: string, s: Session, n: number, invite: unknown): Promise<object> {
const refuse = (reason: keyof typeof roomErrors) => ({ type: "room.error", room: n, reason, message: roomErrors[reason] });
if (!s.imvuId) return refuse("not_linked");
const imvuId = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return refuse("not_found");
if (info.closed) return refuse("closed");
const adult = await this.deps.adult(s.userId, imvuId);
const name = await this.deps.accountName(imvuId);
const banned = await this.deps.hostedBanned(n, imvuId);
// The session may have closed or changed accounts while waiting.
if (!this.sessions.has(id) || s.imvuId !== imvuId) return refuse("not_linked");
s.adult = adult;
if (info.adult && !s.adult) return refuse("adult");
if (banned) return refuse("banned");
let room = this.hosted.get(n);
if (!room?.members.has(id)) {
// A private room: its owner account, its moderators, accounts let in already, and an accepted invite's holder.
let admit: Invite | undefined;
const staff = Realtime.staff(info, imvuId);
if (info.privacy === "private" && !staff.owner && !staff.moderator && !room?.admitted.has(imvuId)) {
const inv = typeof invite === "string" ? this.invites.get(invite) : undefined;
if (!inv || inv.room !== n || inv.to !== imvuId || !inv.accepted || inv.expires <= Date.now()) return refuse("private");
admit = inv;
}
if (s.hosted.size >= maxHostedPerSession) return refuse("too_many");
const here = new Set([...(room?.members.values() ?? [])].map((m) => m.imvuId));
if (!here.has(imvuId) && here.size >= info.maxUsers) return refuse("full");
if (!room) this.hosted.set(n, (room = { members: new Map(), sequence: 0, admitted: new Set() }));
if (admit) {
this.invites.delete(admit.id); // single use
room.admitted.add(imvuId);
}
room.members.set(id, { imvuId, seat: 0, name });
s.hosted.add(n);
this.participantsChanged(n, id); // the newcomer's list comes in room.joined
if (!here.has(imvuId)) this.deps.hostedVisit(s.userId, n, imvuId);
}
return { type: "room.joined", room: n, chat: hostedBase + n, participants: this.participants(n), sequence: room.sequence };
}
/** room.boot: the owner account or a moderator sends an account out of the room, and maybe bans it. */
private async bootHosted(id: string, s: Session, n: number, m: { imvu_id?: unknown; ban?: unknown; minutes?: unknown }) {
const target = m.imvu_id;
const ban = m.ban === undefined ? false : m.ban;
const minutes = m.minutes === undefined ? 0 : m.minutes;
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
if (typeof ban !== "boolean") return { type: "error", message: "bad ban" };
if (typeof minutes !== "number" || !Number.isInteger(minutes) || minutes < 0 || minutes > maxBanMinutes)
return { type: "error", message: "bad minutes" };
const result = (reason?: keyof typeof actionErrors) => ({
type: "room.boot_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, ban }),
});
if (!s.imvuId) return result("not_linked");
const actor = s.imvuId;
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
if (!this.sessions.has(id) || s.imvuId !== actor) return result("not_linked");
const me = Realtime.staff(info, actor);
const them = Realtime.staff(info, target);
// Nobody boots the owner; moderators don't boot moderators; nobody boots themselves.
if ((!me.owner && !me.moderator) || them.owner || (them.moderator && !me.owner) || target === actor)
return result("forbidden");
await this.deps.hostedBoot({ room: n, imvuId: target, by: actor, byUserId: s.userId, ban, minutes });
const room = this.hosted.get(n);
room?.admitted.delete(target);
for (const [sid, member] of [...(room?.members ?? [])]) {
const other = this.sessions.get(sid);
if (member.imvuId === target && other) this.leaveHosted(sid, other, n, true, "booted");
}
return result();
}
/** Whether a session may send another invitation now (10, then 10 a minute). */
private allowInvite(s: Session): boolean {
const now = Date.now();
s.invites = Math.min(inviteBurst, s.invites + ((now - s.invitesRefilled) / 60_000) * invitesPerMinute);
s.invitesRefilled = now;
if (s.invites < 1) return false;
s.invites -= 1;
return true;
}
/** room.invite: someone in a public room, or the owner or a moderator of a private one, invites an account. */
private async inviteHosted(id: string, s: Session, n: number, target: unknown): Promise<object | undefined> {
if (typeof target !== "number" || !Number.isSafeInteger(target) || target < 1) return { type: "error", message: "bad imvu_id" };
const result = (reason?: keyof typeof actionErrors, invite?: string) => ({
type: "room.invite_result",
room: n,
imvu_id: target,
...(reason ? { ok: false, reason, message: actionErrors[reason] } : { ok: true, invite }),
});
if (!s.imvuId) return result("not_linked");
const from = s.imvuId;
if (target === from) return { type: "error", message: "bad imvu_id" };
const info = await this.deps.hostedRoom(n);
if (!info) return result("not_found");
if (info.closed) return result("closed");
const banned = await this.deps.hostedBanned(n, target);
if (!this.sessions.has(id) || s.imvuId !== from) return result("not_linked");
const me = Realtime.staff(info, from);
if (info.privacy === "private" ? !me.owner && !me.moderator : !this.hosted.get(n)?.members.has(id))
return result(info.privacy === "private" ? "forbidden" : "not_in_room");
if (banned) return result("banned");
const targets = [...this.sessions.values()].filter((t) => t.imvuId === target);
if (info.adult && targets.length > 0 && !targets.some((t) => t.adult)) return result("adult");
if (!this.allowInvite(s)) return result("rate_limited");
const now = Date.now();
for (const [k, inv] of this.invites) if (inv.expires <= now) this.invites.delete(k);
const inviteId = Buffer.from(crypto.getRandomValues(new Uint8Array(12))).toString("base64url");
s.send(result(undefined, inviteId));
if (targets.length === 0) {
s.send({ type: "room.invite_answer", invite: inviteId, to: target, accept: false, offline: true });
return;
}
this.invites.set(inviteId, { id: inviteId, room: n, from, to: target, expires: now + inviteLifetime, answered: false, accepted: false });
const message = { type: "room.invited", room: n, invite: inviteId, from, room_name: info.name, owner_name: info.ownerName };
for (const t of targets) if (!info.adult || t.adult) t.send(message);
}
/** room.invite_reply: the invited account answers (the first answer counts). */
private inviteReply(s: Session, m: { invite?: unknown; accept?: unknown; note?: unknown }): object | undefined {
const inv = typeof m.invite === "string" ? this.invites.get(m.invite) : undefined;
if (!inv || inv.expires <= Date.now() || !s.imvuId || s.imvuId !== inv.to) return { type: "error", message: "no such invite" };
if (typeof m.accept !== "boolean") return { type: "error", message: "bad accept" };
if (m.note !== undefined && typeof m.note !== "string") return { type: "error", message: "bad note" };
if (inv.answered) return { type: "error", message: "already answered" };
inv.answered = true;
inv.accepted = m.accept;
if (!m.accept) this.invites.delete(inv.id);
// eslint-disable-next-line no-control-regex
const note = (m.note ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, maxNote);
this.toAccount(inv.from, { type: "room.invite_answer", invite: inv.id, to: inv.to, accept: m.accept, ...(note ? { note } : {}) });
}
/** room.watch: replaces the rooms whose occupancy this session is sent. Rooms it may not see are left out. */
private async watch(id: string, s: Session, rooms: unknown): Promise<object | undefined> {
if (
!Array.isArray(rooms) ||
rooms.length > maxWatched ||
!rooms.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= maxHostedN)
)
return { type: "error", message: "bad rooms" };
const gen = ++s.watchGen;
this.unwatch(id, s);
const ns = [...new Set(rooms as number[])];
const infos = await Promise.all(ns.map((n) => this.deps.hostedRoom(n)));
if (!this.sessions.has(id) || s.watchGen !== gen) return; // closed, or a newer room.watch came
for (const [i, n] of ns.entries()) {
const info = infos[i];
if (!info || info.closed || (info.adult && !s.adult)) continue;
const me = Realtime.staff(info, s.imvuId);
if (info.privacy === "private" && !me.owner && !me.moderator) continue;
s.watching.add(n);
let w = this.watchers.get(n);
if (!w) this.watchers.set(n, (w = new Set()));
w.add(id);
s.send({ type: "room.occupancy", room: n, participants: this.participants(n) });
}
}
/** Whether a session may send another room message now (a bucket of tokens, refilled over time). */
private allowSend(s: Session): boolean {
const now = Date.now();
s.tokens = Math.min(sendBurst, s.tokens + ((now - s.refilled) / 1000) * sendPerSecond);
s.refilled = now;
if (s.tokens < 1) return false;
s.tokens -= 1;
return true;
}
private sendHosted(id: string, s: Session, n: number, mount: unknown, message: unknown): object | undefined {
const room = this.hosted.get(n);
const me = room?.members.get(id);
if (!room || !me) return { type: "error", message: "not in that room" };
if (mount !== "messages" && mount !== "control") return { type: "error", message: "bad mount" };
if (typeof message !== "string" || Buffer.byteLength(message) > maxRoomMessage)
return { type: "error", message: "bad message" };
if (mount === "messages") {
// The client flags a message whose userId isn't its sender as forged; the hub doesn't pass those on at all.
const parsed = safeJSON(message) as { userId?: unknown } | null | undefined;
if (!parsed || typeof parsed !== "object" || String(parsed.userId) !== String(me.imvuId)) return;
}
if (!this.allowSend(s)) return;
room.sequence++;
this.toRoom(n, { type: "room.message", room: n, from: me.imvuId, mount, message, sequence: room.sequence }, id);
}
/** Handles a message from a proxy; returns the reply to send, if any. */ /** Handles a message from a proxy; returns the reply to send, if any. */
async message(id: string, raw: unknown): Promise<object | undefined> { async message(id: string, raw: unknown): Promise<object | undefined> {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (!s) return; if (!s) return;
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { type?: unknown; imvu_id?: unknown; items?: unknown }; const m = (typeof raw === "string" ? safeJSON(raw) : raw) as {
type?: unknown;
imvu_id?: unknown;
items?: unknown;
chat?: unknown;
room?: unknown;
seat?: unknown;
mount?: unknown;
message?: unknown;
invite?: unknown;
ban?: unknown;
minutes?: unknown;
accept?: unknown;
note?: unknown;
rooms?: unknown;
};
if (!m || typeof m !== "object") return { type: "error", message: "not a message" }; if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
if (m.type === "session") { if (m.type === "session") {
const imvuId = Number(m.imvu_id); const imvuId = Number(m.imvu_id);
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined; s.requested = Number.isSafeInteger(imvuId) && imvuId > 0 ? imvuId : 0;
const owner = s.requested ? await this.deps.owner(imvuId) : undefined;
// Hosted rooms were joined as the session's account: another account (or none) leaves them.
if (owner !== s.userId || imvuId !== s.imvuId) this.leaveAllHosted(id, s, true);
if (owner !== s.userId) { if (owner !== s.userId) {
// Not this person's account: no presence, no outfit for it. // Not this person's account: no presence, no outfit for it.
s.imvuId = 0; s.imvuId = 0;
s.adult = false;
s.outfit = []; s.outfit = [];
this.changed(); this.changed(s.chats.keys());
return { type: "error", message: "not_linked" }; return { type: "error", message: "not_linked" };
} }
s.imvuId = imvuId; s.imvuId = imvuId;
s.since = new Date(); s.since = new Date();
s.outfit = []; s.outfit = [];
this.changed(); s.adult = await this.deps.adult(s.userId, imvuId);
this.changed(s.chats.keys());
return; return;
} }
if (m.type === "outfit") { if (m.type === "outfit") {
if (!Array.isArray(m.items) || m.items.length > 64) return { type: "error", message: "too many items" }; const items = m.items ?? [];
s.outfit = m.items if (!Array.isArray(items)) return { type: "error", message: "items must be a list" };
if (items.length > 64) return { type: "error", message: "too many items" };
let outfit = items
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string") .filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128) .filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
.map((it) => ({ id: it.id, name: it.name })); .map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
this.changed(); const hubIds = outfit.filter((it) => hubProductID.test(it.id)).map((it) => it.id);
if (hubIds.length > 0) {
const ok = await this.deps.wearable(s.userId, hubIds);
outfit = outfit.filter((it) => !hubProductID.test(it.id) || ok.has(it.id));
}
s.outfit = outfit;
this.changed(s.chats.keys());
return;
}
if (m.type === "join" || m.type === "leave") {
const chat = typeof m.chat === "string" ? m.chat : String(m.chat ?? "");
if (!chatPattern.test(chat)) return { type: "error", message: "bad chat" };
if (m.type === "leave") {
if (!s.chats.delete(chat)) return;
s.lastSent.delete(chat);
this.changed([chat]);
return;
}
const room = typeof m.room === "string" && roomPattern.test(m.room) ? m.room : "";
if (!s.chats.has(chat) && s.chats.size >= maxChats) return { type: "error", message: "too many chats" };
s.chats.set(chat, room);
s.lastSent.delete(chat); // tell it again, even if nothing changed
if (s.imvuId) s.adult = await this.deps.adult(s.userId, s.imvuId);
if (room) this.deps.room(room);
this.changed([chat]);
return;
}
if (m.type === "room.watch") return this.watch(id, s, m.rooms);
if (m.type === "room.invite_reply") return this.inviteReply(s, m);
if (
m.type === "room.join" ||
m.type === "room.leave" ||
m.type === "room.seat" ||
m.type === "room.send" ||
m.type === "room.boot" ||
m.type === "room.invite"
) {
const n = m.room;
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > maxHostedN) return { type: "error", message: "bad room" };
if (m.type === "room.join") return this.joinHosted(id, s, n, m.invite);
if (m.type === "room.boot") return this.bootHosted(id, s, n, m);
if (m.type === "room.invite") return this.inviteHosted(id, s, n, m.imvu_id);
if (m.type === "room.leave") {
this.leaveHosted(id, s, n, false);
// Leaving on purpose ends an invitee's admission, once none of the account's sessions is in the room.
const room = this.hosted.get(n);
if (room && ![...room.members.values()].some((x) => x.imvuId === s.imvuId)) room.admitted.delete(s.imvuId);
return;
}
if (m.type === "room.send") return this.sendHosted(id, s, n, m.mount, m.message);
const seat = m.seat;
if (typeof seat !== "number" || !Number.isInteger(seat) || seat < 0 || seat > 99) return { type: "error", message: "bad seat" };
const member = this.hosted.get(n)?.members.get(id);
if (!member) return { type: "error", message: "not in that room" };
if (member.seat !== seat) {
member.seat = seat;
this.participantsChanged(n);
}
return; return;
} }
return { type: "error", message: "unknown message type" }; return { type: "error", message: "unknown message type" };
+111
View File
@@ -0,0 +1,111 @@
// The rooms list: IMVU rooms owned by linked accounts that chose to list them. The hub learns of a room when a T5DE
// user enters it, and asks IMVU about it (owner, public and listed or not); it's checked again while it's in use,
// so a room made unlisted or closed drops off the list.
import { and, desc, eq, gt, lt, schema, type Database } from "@t5de/db";
import type { ImvuClient } from "./imvu";
const { room, imvuLink } = schema;
export type Room = typeof room.$inferSelect;
const recheckAfter = 60 * 60 * 1000;
// Rooms nobody entered for this long aren't checked again or listed.
const forgetAfter = 30 * 24 * 60 * 60 * 1000;
/** Whether IMVU says anyone may find and enter the room. */
export const isListable = (r: Pick<Room, "privacy" | "type" | "expired">) =>
r.privacy === "public" && r.type === "listed" && !r.expired;
/** Whether the room is for adults (Access Pass rooms). */
export const isAdultRoom = (r: Pick<Room, "contentRating" | "accessPass">) => r.accessPass || r.contentRating === "AP";
export class Rooms {
private checking = new Set<string>();
constructor(
private readonly db: Database,
private readonly imvu: ImvuClient,
) {}
/** A T5DE user entered a room: remember it if its owner is linked, asking IMVU about it if it's due. */
async seen(instanceId: string) {
const ownerId = Number(instanceId.split("-")[0]);
const [owner] = await this.db.select({ id: imvuLink.imvuId }).from(imvuLink).where(eq(imvuLink.imvuId, ownerId));
if (!owner) return;
const [known] = await this.db.select().from(room).where(eq(room.instanceId, instanceId));
if (known && Date.now() - known.checkedAt.getTime() < recheckAfter) {
await this.db.update(room).set({ seenAt: new Date() }).where(eq(room.instanceId, instanceId));
return;
}
await this.check(instanceId);
}
/** Asks IMVU about a room again, and records what it says (or forgets the room if it's gone). */
async check(instanceId: string) {
if (this.checking.has(instanceId)) return;
this.checking.add(instanceId);
try {
const r = await this.imvu.room(instanceId);
if (!r || String(r.ownerId) !== instanceId.split("-")[0]) {
await this.db.delete(room).where(eq(room.instanceId, instanceId));
return;
}
const values = {
ownerImvuId: r.ownerId,
name: r.name,
description: r.description,
imageURL: r.imageURL,
privacy: r.privacy,
type: r.type,
contentRating: r.contentRating,
accessPass: r.accessPass,
occupancy: r.occupancy,
capacity: r.capacity,
expired: r.expired,
checkedAt: new Date(),
seenAt: new Date(),
};
await this.db
.insert(room)
.values({ instanceId, ...values })
.onConflictDoUpdate({ target: room.instanceId, set: values });
} finally {
this.checking.delete(instanceId);
}
}
/** Checks again rooms in use whose answer is old (at most limit). */
async recheckDue(limit = 20): Promise<number> {
const due = await this.db
.select({ instanceId: room.instanceId })
.from(room)
.where(and(lt(room.checkedAt, new Date(Date.now() - recheckAfter)), gt(room.seenAt, new Date(Date.now() - forgetAfter))))
.limit(limit);
let n = 0;
for (const r of due) {
try {
await this.check(r.instanceId);
n++;
} catch {
// IMVU unavailable: try again next time.
}
}
return n;
}
/** The rooms to list: listable, owner chose to list, entered lately; with the owner's IMVU name. */
async listed() {
const rows = await this.db
.select({ room, owner: imvuLink.username })
.from(room)
.innerJoin(imvuLink, eq(imvuLink.imvuId, room.ownerImvuId))
.where(and(eq(imvuLink.listRooms, true), gt(room.seenAt, new Date(Date.now() - forgetAfter))))
.orderBy(desc(room.seenAt));
return rows.filter((r) => isListable(r.room));
}
/** Forgets an IMVU account's rooms (it was unlinked). */
async forgetOwner(imvuId: number) {
await this.db.delete(room).where(eq(room.ownerImvuId, imvuId));
}
}
+29
View File
@@ -0,0 +1,29 @@
// The hub's settings that admins change from the console, kept in hub_setting. A setting never changed there has its
// default from the environment (so a new staging hub can start with registration closed).
import { eq, schema, type Database } from "@t5de/db";
const { hubSetting } = schema;
/** Open: new accounts can use the hub at once. Closed: they can sign in, and wait for a moderator's approval. */
export type Registration = "open" | "closed";
export const registrations: readonly Registration[] = ["open", "closed"];
export class Settings {
constructor(
private readonly db: Database,
private readonly defaults: { registration?: string } = {},
) {}
async registration(): Promise<Registration> {
const [row] = await this.db.select().from(hubSetting).where(eq(hubSetting.key, "registration"));
const value = row?.value ?? this.defaults.registration;
return value === "closed" ? "closed" : "open";
}
async setRegistration(value: Registration) {
await this.db
.insert(hubSetting)
.values({ key: "registration", value })
.onConflictDoUpdate({ target: hubSetting.key, set: { value, updatedAt: new Date() } });
}
}
+94
View File
@@ -0,0 +1,94 @@
// Personal API tokens (docs/console.md): for scripts. Each grants some of its owner's permissions at chosen scopes;
// what it may do is always the intersection with what the owner holds now. Only the hash is kept, and the token is
// shown once. Making, listing and revoking tokens takes a website session (a token can't make tokens).
import { createHash, randomBytes } from "node:crypto";
import { and, desc, eq, gt, isNull, schema, sql, type Database, type TokenGrant } from "@t5de/db";
import { ProductError } from "./products";
import { permissions, type Permission, type Permissions, type Scope } from "./permissions";
const { apiToken } = schema;
export const tokenPrefix = "t5de_pat_";
/** Active tokens a person may have at once. */
export const maxTokens = 20;
/** Grants a token may carry. */
export const maxGrants = 50;
/** The longest a token may last. */
export const maxTokenDays = 365;
const hashOf = (raw: string) => createHash("sha256").update(raw).digest("hex");
export type ApiToken = typeof apiToken.$inferSelect;
export class Tokens {
constructor(
private db: Database,
private perms: Permissions,
) {}
/** Makes a token for a person; every grant must be something they hold now. Returns the token (shown once). */
async create(who: { id: string; role: string }, name: string, days: number, grants: TokenGrant[]) {
const label = name.trim().replace(/\s+/g, " ");
if (!label || label.length > 64) throw new ProductError(400, "invalid", "Name it (at most 64 characters).");
if (!Number.isInteger(days) || days < 1 || days > maxTokenDays)
throw new ProductError(400, "invalid", `It may last 1 to ${maxTokenDays} days.`);
if (grants.length === 0 || grants.length > maxGrants)
throw new ProductError(400, "invalid", `Give it 1 to ${maxGrants} permissions.`);
const seen = new Set<string>();
const clean: TokenGrant[] = [];
for (const g of grants) {
if (!(permissions as readonly string[]).includes(g.permission))
throw new ProductError(400, "invalid", `No such permission: ${g.permission}.`);
const scope: Scope = g.scope.type === "hub" ? { type: "hub" } : { type: g.scope.type, id: g.scope.id };
if (!(await this.perms.can(who, g.permission as Permission, scope)))
throw new ProductError(400, "not_held", `You don't hold ${g.permission} there, so a token can't either.`);
const key = JSON.stringify([g.permission, scope]);
if (seen.has(key)) continue;
seen.add(key);
clean.push({ permission: g.permission, scope });
}
const [{ n } = { n: 0 }] = await this.db
.select({ n: sql<number>`count(*)::int` })
.from(apiToken)
.where(and(eq(apiToken.userId, who.id), isNull(apiToken.revokedAt), gt(apiToken.expiresAt, new Date())));
if (n >= maxTokens) throw new ProductError(409, "too_many", `You have ${maxTokens} tokens. Revoke one first.`);
const raw = tokenPrefix + randomBytes(32).toString("base64url");
const [made] = await this.db
.insert(apiToken)
.values({
id: randomBytes(8).toString("hex"),
userId: who.id,
name: label,
hash: hashOf(raw),
grants: clean,
expiresAt: new Date(Date.now() + days * 86_400_000),
})
.returning();
return { token: raw, row: made! };
}
/** A person's tokens, newest first (revoked and expired ones included, for the record). */
async list(personId: string) {
return this.db.select().from(apiToken).where(eq(apiToken.userId, personId)).orderBy(desc(apiToken.createdAt));
}
async revoke(personId: string, id: string) {
const [t] = await this.db
.update(apiToken)
.set({ revokedAt: new Date() })
.where(and(eq(apiToken.id, id), eq(apiToken.userId, personId), isNull(apiToken.revokedAt)))
.returning();
if (!t) throw new ProductError(404, "not_found", "No such token, or it's revoked already.");
return t;
}
/** The token a raw value names, if it's still good; notes its use (at most once a minute). */
async verify(raw: string): Promise<ApiToken | undefined> {
if (!raw.startsWith(tokenPrefix) || raw.length > 100) return undefined;
const [t] = await this.db.select().from(apiToken).where(eq(apiToken.hash, hashOf(raw)));
if (!t || t.revokedAt || t.expiresAt <= new Date()) return undefined;
if (!t.lastUsedAt || Date.now() - t.lastUsedAt.getTime() > 60_000)
await this.db.update(apiToken).set({ lastUsedAt: new Date() }).where(eq(apiToken.id, t.id));
return t;
}
}
+275
View File
@@ -0,0 +1,275 @@
// Age verification: the one rule, providers that check a person (a fake document-check vendor with signed webhooks,
// and Didit's adapter against a fake Didit), limits on checks, and IMVU's answers changing.
import { createHmac } from "node:crypto";
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { AgeService, attemptsPerPerson, type Provider } from "../src/age";
import { ageOn, diditAgeProvider } from "../src/age/didit";
import { imvuAgeProvider } from "../src/age/imvu";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
// A document-check vendor: sends the person to its page, then reports by a webhook signed with a shared secret.
const webhookSecret = "vendor-secret";
const fakeVendor: Provider = {
id: "vendor",
label: "ID document",
vendor: "Vendor",
scope: "person",
start: async () => {
const ref = `session-${crypto.randomUUID()}`;
return { kind: "redirect", url: `https://vendor.test/check/${ref}`, ref };
},
webhook: async (request) => {
if (request.headers.get("x-signature") !== webhookSecret) throw new Error("bad signature");
const body = (await request.json()) as { session: string; approved: boolean; over18: boolean };
return { ref: body.session, result: body.approved ? { overEighteen: body.over18, method: "id_document" } : "failed" };
},
};
// Didit: sessions per person (an unfinished one is given again), and each session's decision.
const diditSecret = "didit-secret";
const diditSessions = new Map<string, { id: string; vendor: string; finished: boolean; decision?: unknown }>();
const fakeDidit: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
if (new Headers(init?.headers).get("x-api-key") !== "didit-key") return new Response("no key", { status: 401 });
if (init?.method === "POST" && url.pathname === "/v3/session/") {
const body = JSON.parse(String(init.body));
expect(body.workflow_id).toBe("workflow");
let s = [...diditSessions.values()].find((s) => s.vendor === body.vendor_data && !s.finished);
if (!s) {
s = { id: crypto.randomUUID(), vendor: body.vendor_data, finished: false };
diditSessions.set(s.id, s);
}
return Response.json({ session_id: s.id, url: `https://verify.didit.test/session/${s.id}`, status: "Not Started" }, { status: 201 });
}
const m = /^\/v3\/session\/([^/]+)\/decision\/$/.exec(url.pathname);
if (m && diditSessions.get(m[1]!)) return Response.json(diditSessions.get(m[1]!)!.decision ?? {});
return new Response("not found", { status: 404 });
}) as typeof fetch;
const didit = diditAgeProvider({ apiKey: "didit-key", workflowId: "workflow", webhookSecret: diditSecret, baseURL: "https://didit.test", fetch: fakeDidit });
/** A Didit webhook request, signed as Didit signs it (the raw body, or V2), or forged. */
function diditHook(body: Record<string, unknown>, sign: "raw" | "v2" | "forged" = "raw", timestamp = Math.floor(Date.now() / 1000)) {
const raw = JSON.stringify(body);
const hmac = (s: string, key = diditSecret) => createHmac("sha256", key).update(s).digest("hex");
const sortKeys = (v: unknown): unknown =>
Array.isArray(v)
? v.map(sortKeys)
: v && typeof v === "object"
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]))
: v;
const headers: Record<string, string> = { "X-Timestamp": String(timestamp), "Content-Type": "application/json" };
if (sign === "raw") headers["X-Signature"] = hmac(raw);
if (sign === "v2") headers["X-Signature-V2"] = hmac(JSON.stringify(sortKeys(body)));
if (sign === "forged") headers["X-Signature"] = hmac(raw, "wrong");
return fetch(`${base}/api/v1/age/webhook/didit`, { method: "POST", headers, body: raw });
}
let db: Database;
let close: () => Promise<void>;
let base: string;
let age: AgeService;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const imvu = new ImvuClient("http://imvu.test", fakeIMVU);
age = new AgeService(db, [imvuAgeProvider(imvu), fakeVendor, didit]);
const hub = createApp({ db, auth, imvu, age, webURL });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
async function call(cookie: { v: string }, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const set = res.headers.getSetCookie();
if (set.length) cookie.v = set.map((c) => c.split(";")[0]).join("; ");
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
async function personWithAccounts(name: string, ids: { id: number; ageVerified?: boolean; accessPass?: boolean }[]) {
const cookie = { v: "" };
await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name });
for (const a of ids) {
accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass, picture: null });
const started = await call(cookie, "POST", "/api/v1/account/links", { username: `${name}${a.id}` });
accounts.get(a.id)!.tagline = started.json.challenge.code;
expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
}
return cookie;
}
const userOf = async (name: string) => (await db.select().from(schema.user)).find((u) => u.username === name)!.id;
const startedThisMonth = async () => {
const now = new Date();
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
return (await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.provider, "didit"))).filter((r) => r.createdAt >= month).length;
};
const adultOf = async (cookie: { v: string }) =>
Object.fromEntries(((await call(cookie, "GET", "/api/v1/account")).json.links as any[]).map((l) => [l.imvuId, l.adult]));
test("IMVU's answer is per account, and re-checked when it expires", async () => {
const a = await personWithAccounts("ann", [{ id: 1, ageVerified: true }, { id: 2 }, { id: 3, accessPass: true }]);
expect(await adultOf(a)).toEqual({ 1: true, 2: false, 3: true });
// IMVU revokes account 1's verification; once its answer expires, the re-check sees it.
accounts.get(1)!.ageVerified = false;
await db.update(schema.ageCheck).set({ expiresAt: new Date(Date.now() - 1000) });
expect(await age.recheckDue()).toBeGreaterThanOrEqual(3);
expect(await adultOf(a)).toEqual({ 1: false, 2: false, 3: true });
});
test("a person's check covers all their accounts; webhooks must be signed", async () => {
const b = await personWithAccounts("ben", [{ id: 10 }, { id: 11 }]);
expect((await call(b, "GET", "/api/v1/age")).json.providers.map((p: any) => p.id)).toEqual(["imvu", "vendor", "didit"]);
// IMVU checks accounts, not people: it can't be started this way.
expect((await call(b, "POST", "/api/v1/age/imvu/start", { returnURL: `${webURL}/account` })).status).toBe(400);
const started = (await call(b, "POST", "/api/v1/age/vendor/start", { returnURL: `${webURL}/account` })).json;
expect(started.kind).toBe("redirect");
expect((await call(b, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ provider: "vendor", status: "pending" });
const hook = (signature: string, approved: boolean) =>
call({ v: "" }, "POST", "/api/v1/age/webhook/vendor", { session: started.ref, approved, over18: true }, { "x-signature": signature });
expect((await hook("forged", true)).status).toBe(401);
expect(await adultOf(b)).toEqual({ 10: false, 11: false });
expect((await hook(webhookSecret, true)).status).toBe(204);
expect(await adultOf(b)).toEqual({ 10: true, 11: true });
// A repeated webhook is harmless.
expect((await hook(webhookSecret, true)).status).toBe(204);
// Verified already: no more checks, with this provider or another.
const again = await call(b, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` });
expect(again).toMatchObject({ status: 409, json: { error: "refused" } });
});
test("people are sent back only to the hub's website", async () => {
const e = await personWithAccounts("eve", []);
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "https://evil.test/account" })).status).toBe(400);
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "//evil.test/account" })).status).toBe(400);
// A path is on the website.
expect((await call(e, "POST", "/api/v1/age/didit/start", { returnURL: "/account?age=back" })).json.kind).toBe("redirect");
});
test("Didit: signed webhooks, the age on the document, and answers changed later", async () => {
const f = await personWithAccounts("fay", [{ id: 30 }]);
const started = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account?age=didit` })).json;
expect(started).toMatchObject({ kind: "redirect", url: `https://verify.didit.test/session/${started.ref}` });
// Going back to an unfinished check gives the same session, and doesn't count as another.
const resumed = (await call(f, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
expect(resumed.ref).toBe(started.ref);
expect((await call(f, "GET", "/api/v1/age")).json.checks).toHaveLength(1);
const event = (status: string, decision?: unknown) => ({
event_id: crypto.randomUUID(),
webhook_type: "status.updated",
session_id: started.ref,
status,
timestamp: Math.floor(Date.now() / 1000),
...(decision ? { decision } : {}),
});
const approved = event("Approved", { id_verifications: [{ status: "Approved", age: 31, date_of_birth: "1995-01-01" }] });
expect((await diditHook(approved, "forged")).status).toBe(401);
expect((await diditHook(approved, "raw", Math.floor(Date.now() / 1000) - 3600)).status).toBe(401); // stale
// In review: no answer yet.
expect((await diditHook(event("In Review"))).status).toBe(204);
expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("pending");
expect(await adultOf(f)).toEqual({ 30: false });
expect((await diditHook(approved, "v2")).status).toBe(204);
expect(await adultOf(f)).toEqual({ 30: true });
const [row] = await db.select().from(schema.ageCheck).where(eq(schema.ageCheck.providerRef, started.ref));
expect(row).toMatchObject({ provider: "didit", method: "id_document", status: "complete", overEighteen: true, imvuId: null });
// A reviewer at Didit declines it afterwards: the answer is taken back.
expect((await diditHook(event("Declined"))).status).toBe(204);
expect(await adultOf(f)).toEqual({ 30: false });
expect((await call(f, "GET", "/api/v1/age")).json.checks[0].status).toBe("revoked");
});
test("Didit: under 18, and a decision fetched when the webhook has none", async () => {
const g = await personWithAccounts("gus", [{ id: 40 }]);
const started = (await call(g, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
const session = diditSessions.get(started.ref)!;
session.finished = true;
session.decision = { id_verifications: [{ date_of_birth: `${new Date().getUTCFullYear() - 17}-12-31` }] };
const body = { webhook_type: "status.updated", session_id: started.ref, status: "Approved", timestamp: 0 };
expect((await diditHook(body)).status).toBe(204);
expect(await adultOf(g)).toEqual({ 40: false });
expect((await call(g, "GET", "/api/v1/age")).json.checks[0]).toMatchObject({ status: "complete", overEighteen: false });
});
test("checks are limited per person and by a provider's monthly allowance", async () => {
const h = await personWithAccounts("hal", []);
// Each abandoned check is a new one.
for (let i = 0; i < attemptsPerPerson; i++) {
const s = (await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` })).json;
diditSessions.get(s.ref)!.finished = true;
const body = { webhook_type: "status.updated", session_id: s.ref, status: "Abandoned", timestamp: 0 };
expect((await diditHook(body)).status).toBe(204);
}
const limited = await call(h, "POST", "/api/v1/age/didit/start", { returnURL: `${webURL}/account` });
expect(limited).toMatchObject({ status: 409, json: { error: "refused" } });
expect(limited.json.message).toContain("30 days");
// The month's allowance is everyone's (here a service with room for one more).
const capped = new AgeService(db, [{ ...didit, monthlyLimit: (await startedThisMonth()) + 1 }]);
await personWithAccounts("ivy", []);
await personWithAccounts("joe", []);
const ivy = await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`);
await expect(capped.start({ userId: await userOf("joe") }, "didit", `${webURL}/account`)).rejects.toThrow("used up");
// Ivy can still go back to hers.
expect(await capped.start({ userId: await userOf("ivy") }, "didit", `${webURL}/account`)).toEqual(ivy);
});
test("birth dates to ages", () => {
const day = new Date("2026-11-03T12:00:00Z");
expect(ageOn("2008-11-03", day)).toBe(18);
expect(ageOn("2008-11-04", day)).toBe(17);
expect(ageOn("2008-12-01", day)).toBe(17);
expect(ageOn("nonsense", day)).toBeUndefined();
});
test("a moved account takes nobody's age with it", async () => {
const c = await personWithAccounts("cal", [{ id: 20, ageVerified: true }]);
expect(await adultOf(c)).toEqual({ 20: true });
// Dee proves the account is hers; IMVU now says it isn't verified (say, the verification was the old owner's).
accounts.get(20)!.ageVerified = false;
const d = { v: "" };
await call(d, "POST", "/api/auth/sign-up/email", { email: "[email protected]", password: "a long password", name: "dee", username: "dee" });
const started = await call(d, "POST", "/api/v1/account/links", { username: "cal20" });
accounts.get(20)!.tagline = started.json.challenge.code;
expect((await call(d, "POST", "/api/v1/account/links/20/check")).json.moved).toBe(true);
expect(await adultOf(d)).toEqual({ 20: false });
const leftovers = await db.select().from(schema.ageCheck);
expect(leftovers.filter((c) => c.imvuId === 20)).toHaveLength(1);
});
+121
View File
@@ -0,0 +1,121 @@
// The T5 badge on people: who shows it, as the proxy asks.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, proxy = false) {
const auth: Record<string, string> = p ? (proxy ? { Authorization: `Bearer ${p.token}` } : { Cookie: p.cookie }) : {};
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...auth },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
/** Signs a person up and links their IMVU accounts. */
async function person(name: string, ...ids: number[]): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
};
expect(p.token).not.toBe("");
for (const [i, id] of ids.entries()) {
const username = `${name}Av${i}`;
accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${id}/check`)).status).toBe(200);
}
return p;
}
const badges = async (p: Person | undefined, imvuIds: unknown) => call(p, "POST", "/api/v1/badges", { imvuIds }, true);
test("only linked accounts whose badge is on, of people who aren't banned, get the badge", async () => {
const admin = await person("badgeadmin");
const ann = await person("ann", 301, 302);
const bob = await person("bob", 303);
const asker = await person("asker");
// Off by default.
const account = (await call(ann, "GET", "/api/v1/account")).json;
expect(account.links.map((l: any) => l.showBadge)).toEqual([false, false]);
expect((await badges(asker, [301, 302, 303, 999])).json).toEqual({ badges: [] });
// Turned on for one account; the other stays off.
const patched = await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: true });
expect(patched.json.link.showBadge).toBe(true);
await call(bob, "PATCH", "/api/v1/account/links/303", { showBadge: true });
const got = await badges(asker, [301, 302, 303, 999, 301]);
expect(got.status).toBe(200);
expect(got.json.badges.sort()).toEqual([301, 303]);
expect(Object.keys(got.json)).toEqual(["badges"]);
// Turned off again.
await call(ann, "PATCH", "/api/v1/account/links/301", { showBadge: false });
expect((await badges(asker, [301, 303])).json).toEqual({ badges: [303] });
// A banned person's accounts don't.
const bobId = (await call(bob, "GET", "/api/v1/me")).json.person.id;
await db.update(schema.user).set({ banned: true }).where(eq(schema.user.id, bobId));
expect((await badges(asker, [303])).json).toEqual({ badges: [] });
expect((await badges(asker, [])).json).toEqual({ badges: [] });
expect((await badges(admin, [303])).status).toBe(200);
});
test("lookups are validated and need signing in", async () => {
const p = await person("carol");
expect((await badges(undefined, [1])).status).toBe(401);
expect((await badges(p, Array.from({ length: 201 }, (_, i) => i + 1))).status).toBe(400);
expect((await badges(p, Array.from({ length: 200 }, (_, i) => i + 1))).status).toBe(200);
for (const bad of [[0], [-1], [1.5], [Number.MAX_SAFE_INTEGER + 1], ["x"], null, "x", { id: 1 }])
expect(JSON.stringify(bad) + " " + (await badges(p, bad)).status).toBe(JSON.stringify(bad) + " 400");
expect((await call(p, "POST", "/api/v1/badges", {}, true)).status).toBe(400);
});
+203
View File
@@ -0,0 +1,203 @@
// File blocks (docs/products.md): moderation blocks a file network-wide; products using it are removed, catalogs told
// to delete it, and uploads with it refused.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
// Catalog addresses that answer /health (for address changes).
const healthy = new Set<string>(["https://moved.example.test"]);
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({
db,
auth,
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
apiURL,
webURL,
catalogHealthy: async (url) => healthy.has(url),
});
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; name: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
}
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
/** A person's catalog, registered and approved; its key's Authorization header. */
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
const reg = await call(owner, "POST", "/api/v1/catalogs", {
name,
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
contact: "[email protected]",
visibility: "public",
membership,
});
expect(reg.status).toBe(201);
const id = reg.json.catalog.id as string;
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
}
/** A product uploaded by a person to a catalog; its ID. */
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const r = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
c.auth,
);
expect(r.status).toBe(201);
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
return r.json.product as string;
}
/** A revision with these files, by a person, to a catalog: the answer. */
async function uploadFiles(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, files: unknown[]) {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
return call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files, rating: "general", parent: 0, name, notes: "" }, c.auth);
}
let admin: Person;
let mod: Person;
test("the first account is the admin; a moderator", async () => {
admin = await person("admin1");
mod = await person("warden");
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
});
test("blocking a file removes the products using it, tells their catalogs, and refuses it in uploads", async () => {
const owner = await person("shelfowner");
const maker = await person("maker");
const copier = await person("copier");
const c = await catalogOf(owner, admin, "Block Shelf");
const other = await catalogOf(copier, admin, "Copy Shelf");
const logo = file("logo.png", 2).sha256;
const tee = await upload(maker, c, "Logo Tee", 1);
const second = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id, product: tee });
expect(
(await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: second.json.token, files: [file("index.xml", 1), file("logo.png", 2)], rating: "general", parent: 0, name: "", notes: "Added the logo" }, c.auth)).status,
).toBe(201);
const copy = (await uploadFiles(copier, other, "Copy Tee", [file("index.xml", 5), file("logo.png", 2)])).json.product as string;
const plain = await upload(maker, c, "Plain Tee", 3);
// Only those who can remove products hub-wide.
for (const p of [owner, maker]) {
expect((await call(p, "GET", "/api/v1/blocks")).status).toBe(403);
expect((await call(p, "GET", `/api/v1/blocks/${logo}`)).status).toBe(403);
expect((await call(p, "PUT", `/api/v1/blocks/${logo}`, { reason: "x" })).status).toBe(403);
}
expect((await call(owner, "GET", "/api/v1/console")).json.sections).not.toContain("blocks");
expect((await call(mod, "GET", "/api/v1/console")).json.sections).toContain("blocks");
expect((await call(mod, "GET", "/api/v1/blocks/not-a-hash")).status).toBe(400);
// What blocking it would remove.
const preview = (await call(mod, "GET", `/api/v1/blocks/${logo}`)).json;
expect(preview.block).toBeNull();
expect(preview.products.map((p: any) => [p.name, p.creatorUsername, p.revisions, p.removed]).sort()).toEqual([
["Copy Tee", "copier", [1], false],
["Logo Tee", "maker", [2], false],
]);
const blocked = await call(mod, "PUT", `/api/v1/blocks/${logo}`, { reason: "Trademarked logo" });
expect(blocked.status).toBe(200);
expect(blocked.json.removed.map((p: any) => p.id).sort()).toEqual([copy, tee].sort());
expect((await call(mod, "PUT", `/api/v1/blocks/${logo}`, { reason: "Again" })).json.error).toBe("blocked");
expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(410);
expect((await call(undefined, "GET", `/api/v1/products/${copy}`)).status).toBe(410);
expect((await call(undefined, "GET", `/api/v1/products/${plain}`)).status).toBe(200);
expect((await call(mod, "GET", `/api/v1/blocks/${logo}`)).json.block).toMatchObject({ sha256: logo, reason: "Trademarked logo", by: "warden" });
expect((await call(mod, "GET", "/api/v1/blocks")).json.blocks.map((b: any) => b.sha256)).toEqual([logo]);
// Each catalog that hosts it is told to delete it.
for (const k of [c, other]) {
const feed = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, k.auth)).json.changes;
expect(feed.filter((x: any) => x.type === "block")).toEqual([{ type: "block", sha256: logo }]);
}
// Creators are told, and may appeal the removal.
const told = (await call(maker, "GET", "/api/v1/notifications")).json.notifications[0];
expect(told).toMatchObject({ type: "product_removed", data: { product: tee, reason: "Trademarked logo" } });
const removal = (await call(maker, "GET", `/api/v1/console/audit?action=product.remove`)).json.entries[0];
expect(removal).toMatchObject({ target: tee, appealable: true, detail: { file: logo } });
// Uploads with it are refused, and moderators see who tried.
const again = await uploadFiles(copier, other, "Copy Tee 2", [file("index.xml", 6), file("logo2.png", 2)]);
expect(again.status).toBe(409);
expect(again.json.error).toBe("blocked");
const tried = (await call(mod, "GET", "/api/v1/console/audit?action=file.blocked_upload")).json.entries[0];
expect(tried).toMatchObject({ actor: "copier", tags: { catalog: other.id }, detail: { files: [{ name: "logo2.png", sha256: logo }] } });
// Unblocking lets it in again; the products it removed stay removed until restored.
expect((await call(owner, "DELETE", `/api/v1/blocks/${logo}`)).status).toBe(403);
expect((await call(mod, "DELETE", `/api/v1/blocks/${logo}`, { reason: "Licensed after all" })).status).toBe(204);
expect((await call(mod, "DELETE", `/api/v1/blocks/${logo}`)).status).toBe(404);
expect((await uploadFiles(copier, other, "Copy Tee 2", [file("index.xml", 6), file("logo2.png", 2)])).status).toBe(201);
expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(410);
expect((await call(mod, "POST", `/api/v1/products/${tee}/unremove`, { reason: "Licensed" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(200);
expect((await call(mod, "GET", "/api/v1/console/audit?action=file.unblock")).json.entries[0]).toMatchObject({ target: logo, detail: { reason: "Licensed after all" } });
});
+306
View File
@@ -0,0 +1,306 @@
// Cases (docs/console.md): reports, rating reviews, deletion and removal requests, appeals; their threads, who sees
// and decides them, what deciding does; and notifications.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
// Catalog addresses that answer /health (for address changes).
const healthy = new Set<string>(["https://moved.example.test"]);
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({
db,
auth,
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
apiURL,
webURL,
catalogHealthy: async (url) => healthy.has(url),
});
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; name: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
}
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
/** A person's catalog, registered and approved; its key's Authorization header. */
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
const reg = await call(owner, "POST", "/api/v1/catalogs", {
name,
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
contact: "[email protected]",
visibility: "public",
membership,
});
expect(reg.status).toBe(201);
const id = reg.json.catalog.id as string;
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
}
/** A product uploaded by a person to a catalog; its ID. */
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const r = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
c.auth,
);
expect(r.status).toBe(201);
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
return r.json.product as string;
}
let admin: Person;
let mod: Person;
const notes = async (p: Person) => (await call(p, "GET", "/api/v1/notifications")).json;
test("the first account is the admin; a moderator", async () => {
admin = await person("admin1");
mod = await person("warden");
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
});
test("reports: anyone signed in reports; the hosting catalog's team and moderators decide; who reported stays private", async () => {
const owner = await person("shelfowner");
const maker = await person("maker");
const reporter = await person("reporter");
const outsider = await person("outsider");
const c = await catalogOf(owner, admin, "Report Shelf");
const hat = await upload(maker, c, "Odd Hat", 1);
expect((await call(undefined, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "x" })).status).toBe(401);
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: " " })).json.error).toBe("reason_needed");
const made = await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Copied from another creator" });
expect(made.status).toBe(201);
const id = made.json.case.id;
expect(made.json.case).toMatchObject({ kind: "report", status: "open", tags: { product: hat }, tagNames: { [hat]: "Odd Hat" } });
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { product: hat }, reason: "Again" })).json.error).toBe("already_reported");
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: "reporter" }, reason: "Me" })).status).toBe(400);
// Who sees it: the reporter, the hosting catalog's team, moderators. Not its creator, not others.
const ids = async (p: Person) => (await call(p, "GET", "/api/v1/cases")).json.cases.map((x: any) => x.id);
expect(await ids(reporter)).toEqual([id]);
expect(await ids(owner)).toContain(id);
expect(await ids(mod)).toContain(id);
expect(await ids(maker)).not.toContain(id);
expect((await call(maker, "GET", `/api/v1/cases/${id}`)).status).toBe(404);
expect((await call(outsider, "GET", `/api/v1/cases/${id}`)).status).toBe(404);
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: true, canAssign: false });
expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json).toMatchObject({ canDecide: false, case: { openedBy: "reporter" } });
// The catalog's team decides it without knowing who reported; moderators know.
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("");
expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.case.openedBy).toBe("reporter");
expect((await call(owner, "GET", "/api/v1/console")).json.todo.cases).toBe(1);
expect((await call(owner, "GET", "/api/v1/console")).json.sections).toContain("cases");
// A thread: replies reach the reporter; internal notes stay with those deciding.
expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Looking into it" })).status).toBe(200);
expect((await call(owner, "POST", `/api/v1/cases/${id}/notes`, { body: "Probably fine", internal: true })).status).toBe(200);
expect((await call(reporter, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => n.body)).toEqual(["Looking into it"]);
expect((await call(owner, "GET", `/api/v1/cases/${id}`)).json.notes.map((n: any) => [n.body, n.internal])).toEqual([
["Looking into it", false],
["Probably fine", true],
]);
expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_note", data: { case: id, by: "shelfowner" } });
// Assigning is for moderators, to moderators.
expect((await call(owner, "POST", `/api/v1/cases/${id}/assign`, { to: "shelfowner" })).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "outsider" })).json.error).toBe("invalid");
expect((await call(admin, "POST", `/api/v1/cases/${id}/assign`, { to: "warden" })).json.case.assignee).toBe("warden");
// Deciding: the catalog's owner declines it; the reporter is told; it's done.
expect((await call(outsider, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404);
const decided = await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "It's an original." });
expect(decided.json.case).toMatchObject({ status: "declined", resolution: "It's an original.", decidedBy: "shelfowner" });
expect((await call(owner, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "" })).json.error).toBe("decided");
expect((await notes(reporter)).notifications[0]).toMatchObject({ type: "case_decided", data: { case: id, status: "declined" } });
expect((await call(reporter, "GET", "/api/v1/me")).json.person.unread).toBe(2);
expect((await call(reporter, "POST", "/api/v1/notifications/read", {})).json.unread).toBe(0);
// The audit log keeps who reported out of what the product's creator can read.
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${hat}`)).json.entries.map((e: any) => e.action);
expect(audit).not.toContain("case.open");
// Reports are limited: ten an hour.
for (let i = 0; i < 9; i++) {
const p = await person(`spam${i}`);
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { person: p.name }, reason: "Spam" })).status).toBe(201);
}
expect((await call(reporter, "POST", "/api/v1/cases", { kind: "report", about: { catalog: c.id }, reason: "One more" })).status).toBe(429);
});
test("rating reviews: a creator lowering a rating asks moderators; resolving lowers it", async () => {
const owner = await person("reviewshelf");
const maker = await person("rater");
const c = await catalogOf(owner, admin, "Review Shelf");
const top = await upload(maker, c, "Plain Top", 2);
expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "adult" })).json.review).toBeNull();
// Lowering it opens a case (one at a time); other fields in the same edit still change.
const asked = await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general", name: "Plain Tee", reason: "Nothing adult about it" });
expect(asked.status).toBe(200);
expect(asked.json.product).toMatchObject({ rating: "adult", name: "Plain Tee" });
const review = asked.json.review as number;
expect(review).toBeGreaterThan(0);
expect((await call(maker, "PATCH", `/api/v1/products/${top}`, { rating: "general" })).json.review).toBe(review);
const kase = (await call(maker, "GET", `/api/v1/cases/${review}`)).json;
expect(kase.case).toMatchObject({ kind: "rating-review", reason: "Nothing adult about it", openedBy: "rater" });
expect(kase.canDecide).toBe(false);
// Its catalog's team doesn't see or decide ratings; moderators do.
expect((await call(owner, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "" })).status).toBe(404);
expect((await call(mod, "POST", `/api/v1/cases/${review}/decide`, { decision: "resolve", resolution: "Agreed" })).json.case.status).toBe("resolved");
const history = (await call(maker, "GET", `/api/v1/products/${top}/history`)).json.entries;
expect(history[0]).toMatchObject({ by: "warden", reason: "Agreed", changes: { rating: { from: "adult", to: "general" } } });
const types = (await notes(maker)).notifications.map((n: any) => n.type);
expect(types).toContain("case_decided");
expect(types).toContain("product_edited");
// Files rated adult can't be lowered by anyone: the creator is told at once.
const lace = await upload(maker, c, "Lace", 3, "adult");
expect((await call(maker, "PATCH", `/api/v1/products/${lace}`, { rating: "general" })).json.error).toBe("files_adult");
});
test("a catalog's deletion goes to moderators; resolving it deletes the catalog", async () => {
const owner = await person("leaver");
const operator = await person("leaverop");
const c = await catalogOf(owner, admin, "Leaving Shelf");
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/leaverop?role=catalog-operator`, { role: "catalog-operator" })).status).toBe(200);
expect((await call(operator, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Done" })).json.missing.permission).toBe("catalog:delete-request");
const first = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Closing shop" })).json.case;
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Again" })).json).toMatchObject({ error: "requested", case: first });
// Its team sees the case; a decline leaves the catalog as it was.
expect((await call(operator, "GET", `/api/v1/cases/${first}`)).json.case.kind).toBe("catalog-deletion");
expect((await call(mod, "POST", `/api/v1/cases/${first}/decide`, { decision: "decline", resolution: "Move your products first" })).status).toBe(200);
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("approved");
const second = (await call(owner, "POST", `/api/v1/catalogs/${c.id}/delete`, { reason: "Moved them" })).json.case;
expect((await call(mod, "POST", `/api/v1/cases/${second}/decide`, { decision: "resolve", resolution: "" })).status).toBe(200);
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}`)).json.catalog.status).toBe("deleted");
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
});
test("removal requests: a catalog's team asks; moderators remove the product everywhere", async () => {
const owner = await person("cautious");
const maker = await person("copier");
const c = await catalogOf(owner, admin, "Careful Shelf");
const shoes = await upload(maker, c, "Copied Shoes", 4);
expect((await call(maker, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "x" })).status).toBe(403);
const asked = await call(owner, "POST", "/api/v1/cases", { kind: "removal-request", about: { product: shoes }, catalog: c.id, reason: "Stolen work" });
expect(asked.status).toBe(201);
expect((await call(owner, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "" })).status).toBe(403);
expect((await call(mod, "POST", `/api/v1/cases/${asked.json.case.id}/decide`, { decision: "resolve", resolution: "Confirmed" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${shoes}`)).status).toBe(410);
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { product: shoes, reason: "Confirmed" } });
});
test("appeals: the person affected appeals an action once; another moderator decides", async () => {
const owner = await person("appealshelf");
const maker = await person("appealer");
const c = await catalogOf(owner, admin, "Appeal Shelf");
const coat = await upload(maker, c, "Fine Coat", 5);
expect((await call(mod, "POST", `/api/v1/products/${coat}/remove`, { reason: "Looks stolen" })).status).toBe(200);
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_removed", data: { name: "Fine Coat", reason: "Looks stolen" } });
// The creator's audit log offers the removal for appeal; others can't appeal it.
const entry = (await call(maker, "GET", `/api/v1/console/audit?product=${coat}`)).json.entries.find((e: any) => e.action === "product.remove");
expect(entry.appealable).toBe(true);
expect((await call(owner, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "x" })).status).toBe(404);
const appeal = await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "I made it from scratch" });
expect(appeal.json.case).toMatchObject({ kind: "appeal", data: { action: "product.remove" } });
expect((await call(maker, "POST", "/api/v1/cases", { kind: "appeal", audit: entry.id, reason: "Again" })).json.error).toBe("appealed");
// The moderator who removed it doesn't decide the appeal; an admin may (flagged in the audit log when it was theirs).
const id = appeal.json.case.id;
expect((await call(mod, "GET", `/api/v1/cases/${id}`)).json.canDecide).toBe(false);
expect((await call(mod, "POST", `/api/v1/cases/${id}/decide`, { decision: "decline", resolution: "" })).json.message).toContain("Another moderator");
expect((await call(admin, "POST", `/api/v1/cases/${id}/decide`, { decision: "resolve", resolution: "Restoring it" })).json.case.status).toBe("resolved");
// Upholding it records the decision; the moderator restores the product (only moderators can).
expect((await call(maker, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, { reason: "Appeal upheld" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${coat}`)).status).toBe(200);
expect((await call(admin, "POST", `/api/v1/products/${coat}/unremove`, {})).status).toBe(404);
expect((await notes(maker)).notifications[0]).toMatchObject({ type: "product_restored", data: { reason: "Appeal upheld" } });
});
test("membership decisions and moderators' edits reach people as notifications", async () => {
const owner = await person("gatekeeper");
const joiner = await person("joiner");
const c = await catalogOf(owner, admin, "Gated Shelf", "approval");
expect((await call(joiner, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/joiner`, { status: "member" })).status).toBe(200);
expect((await notes(joiner)).notifications[0]).toMatchObject({ type: "membership_decided", data: { catalog: c.id, status: "member" } });
const maker = await person("edited");
const p = await upload(maker, c, "Wrong Name", 6);
expect((await call(mod, "PATCH", `/api/v1/products/${p}`, { name: "Right Name", reason: "Typo" })).status).toBe(200);
expect((await notes(maker)).notifications[0]).toMatchObject({
type: "product_edited",
data: { product: p, name: "Right Name", fields: ["name"], reason: "Typo", by: "warden" },
});
// Their own edits don't notify them.
const before = (await notes(maker)).notifications.length;
await call(maker, "PATCH", `/api/v1/products/${p}`, { tags: ["mine"] });
expect((await notes(maker)).notifications.length).toBe(before);
});
+339
View File
@@ -0,0 +1,339 @@
// Managing catalogs (docs/products.md) with permissions (docs/console.md): settings, keys, teams, membership and
// what it gates, invites, delisting, suspension, the changes feed, and the console's scoped audit log.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
// Catalog addresses that answer /health (for address changes).
const healthy = new Set<string>(["https://moved.example.test"]);
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({
db,
auth,
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
apiURL,
webURL,
catalogHealthy: async (url) => healthy.has(url),
});
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; name: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
}
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
/** A person's catalog, registered and approved; its key's Authorization header. */
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
const reg = await call(owner, "POST", "/api/v1/catalogs", {
name,
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
contact: "[email protected]",
visibility: "public",
membership,
});
expect(reg.status).toBe(201);
const id = reg.json.catalog.id as string;
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
}
/** A product uploaded by a person to a catalog; its ID. */
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const r = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
c.auth,
);
expect(r.status).toBe(201);
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
return r.json.product as string;
}
let admin: Person;
test("the first account is the admin", async () => {
admin = await person("admin1");
expect((await call(admin, "GET", "/api/v1/me")).json.person.role).toBe("admin");
});
test("a catalog's owner changes its settings and key; others can't, and are told which permission they lack", async () => {
const owner = await person("owner1");
const stranger = await person("stranger1");
const c = await catalogOf(owner, admin, "Owner Shelf");
// Anyone can see an approved catalog; the owner sees their permissions there.
const seen = await call(owner, "GET", `/api/v1/catalogs/${c.id}`);
expect(seen.status).toBe(200);
expect(seen.json.roles).toContain("catalog-owner");
expect(seen.json.permissions).toContain("catalog:edit");
expect((await call(stranger, "GET", `/api/v1/catalogs/${c.id}`)).json.permissions).toEqual([]);
// A stranger is refused, naming what's missing.
const refused = await call(stranger, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Mine now" });
expect(refused.status).toBe(403);
expect(refused.json).toMatchObject({ error: "forbidden", missing: { permission: "catalog:edit", scope: { catalog: c.id } } });
// The owner changes settings; an address must answer /health.
const edited = await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Owner's Shelf", maxRating: "general", uploads: "members" });
expect(edited.status).toBe(200);
expect(edited.json.catalog).toMatchObject({ name: "Owner's Shelf", maxRating: "general", uploads: "members" });
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://nowhere.example.test" })).json.error).toBe("unreachable");
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://moved.example.test/" })).json.catalog.url).toBe(
"https://moved.example.test",
);
// A new key: the old one stops working at once.
const rotated = await call(owner, "POST", `/api/v1/catalogs/${c.id}/key`);
expect(rotated.status).toBe(200);
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
expect(
(await call(undefined, "GET", "/api/v1/catalog/changes", undefined, { Authorization: `Catalog ${c.id}:${rotated.json.key}` })).status,
).toBe(200);
});
test("teams: the owner gives the operator role; operators manage members but not the team", async () => {
const owner = await person("owner2");
const op = await person("op2");
const other = await person("other2");
const c = await catalogOf(owner, admin, "Team Shelf", "approval");
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).status).toBe(403);
const added = await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/op2`, { role: "catalog-operator" });
expect(added.status).toBe(200);
expect(added.json.team.roles).toMatchObject([{ username: "op2", role: "catalog-operator" }]);
// The operator holds member:approve, not team:manage.
const perms = (await call(op, "GET", `/api/v1/console/permissions?catalog=${c.id}`)).json;
expect(perms.roles).toEqual(["catalog-operator"]);
expect(perms.permissions).toContain("member:approve");
expect(perms.permissions).not.toContain("team:manage");
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).json.missing.permission).toBe(
"team:manage",
);
// An approval catalog: other asks, the operator approves.
expect((await call(other, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
const requests = await call(op, "GET", `/api/v1/catalogs/${c.id}/members?status=requested`);
expect(requests.json.members).toMatchObject([{ username: "other2", status: "requested" }]);
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/members/other2`, { status: "member" })).status).toBe(200);
expect((await call(other, "GET", `/api/v1/catalogs/${c.id}`)).json.membership).toBe("member");
// Operators leave on their own; the console lists the catalog for them while they're on the team.
expect((await call(op, "GET", "/api/v1/console/catalogs")).json.catalogs.map((x: any) => x.id)).toEqual([c.id]);
expect((await call(op, "DELETE", `/api/v1/catalogs/${c.id}/team/op2?role=catalog-operator`)).status).toBe(200);
expect((await call(op, "GET", "/api/v1/console/catalogs")).status).toBe(403);
});
test("membership gates getting and wearing products; seeing them doesn't need it", async () => {
const owner = await person("owner3");
const maker = await person("maker3");
const fan = await person("fan3");
const c = await catalogOf(owner, admin, "Invite Shelf", "invite");
// Uploads to an invite catalog: anyone may upload by default.
const pid = await upload(maker, c, "Hat", 31);
// Seeing works; getting doesn't, and says which catalogs host it.
const page = await call(fan, "GET", `/api/v1/products/${pid}`);
expect(page.json).toMatchObject({ available: false, catalogs: [{ id: c.id, membership: "invite", joined: null }] });
expect((await call(fan, "GET", `/api/v1/products/${pid}/files`)).status).toBe(200);
const refused = await call(fan, "PUT", `/api/v1/library/${pid}`);
expect(refused.status).toBe(403);
expect(refused.json).toMatchObject({ error: "members_only", catalogs: [{ id: c.id }] });
// Invite only: joining without an invite is refused; with one, they're in.
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("invite_only");
const inv = await call(owner, "POST", `/api/v1/catalogs/${c.id}/invites`, { maxUses: 1 });
expect(inv.status).toBe(201);
expect(inv.json.invite.url).toBe(`${webURL}/catalogs/${c.id}?invite=${inv.json.invite.code}`);
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.membership).toBe("member");
const late = await person("late3");
expect((await call(late, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.error).toBe("invite_invalid");
expect((await call(fan, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
// Banned: the product stays in the table but isn't listed (or wearable) until they're back.
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "banned" })).status).toBe(200);
expect((await call(fan, "GET", "/api/v1/library")).json.library).toEqual([]);
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("banned");
expect((await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/members/fan3`)).status).toBe(204);
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "member" })).status).toBe(200);
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
// Its creator always has their own product.
expect((await call(maker, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
});
test("a catalog's upload policy and rating ceiling apply to uploads", async () => {
const owner = await person("owner4");
const maker = await person("maker4");
const c = await catalogOf(owner, admin, "Strict Shelf");
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "members", maxRating: "general" })).status).toBe(200);
const refused = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(refused).toMatchObject({ status: 403, json: { error: "forbidden" } });
expect((await call(maker, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("member");
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const adult = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", 41)], rating: "adult", parent: 0, name: "Spicy", notes: "" },
c.auth,
);
expect(adult).toMatchObject({ status: 400, json: { error: "rating_ceiling" } });
// Team only: members can't either.
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "team" })).status).toBe(200);
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(403);
expect((await call(owner, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(200);
});
test("delisting and suspension reach the catalog through its changes feed", async () => {
const owner = await person("owner5");
const maker = await person("maker5");
const c = await catalogOf(owner, admin, "Feed Shelf");
const pid = await upload(maker, c, "Scarf", 51);
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toMatchObject([{ id: pid, revisions: [1] }]);
expect((await call(maker, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=no`)).status).toBe(403);
const delisted = await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=${encodeURIComponent("off-topic")}`);
expect(delisted.json.revisions).toEqual([1]);
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toEqual([]);
const feed = await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth);
expect(feed.json.changes).toEqual([{ type: "delete", product: pid, revision: 1, files: [file("index.xml", 51)] }]);
const cursor = feed.json.cursor;
expect((await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth)).json.changes).toEqual([]);
// Owners can't suspend their own catalog; moderators can. A suspended catalog can't register revisions, still
// reads its feed, and is told.
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "x" })).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "spam" })).json.catalog).toMatchObject({
status: "suspended",
statusReason: "spam",
});
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(404);
const told = await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth);
expect(told.json.changes).toEqual([{ type: "status", status: "suspended" }]);
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/reinstate`)).json.catalog.status).toBe("approved");
});
test("the console is for everyone who controls something; its audit log shows only what they may see", async () => {
const owner = await person("owner6");
const maker = await person("maker6");
const nobody = await person("nobody6");
const c = await catalogOf(owner, admin, "Audit Shelf");
const other = await catalogOf(await person("owner6b"), admin, "Other Shelf");
const pid = await upload(maker, c, "Gloves", 61);
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { contact: "[email protected]" })).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${pid}`, { name: "Warm Gloves" })).status).toBe(200);
expect((await call(admin, "PATCH", `/api/v1/catalogs/${other.id}`, { name: "Other Shelf 2" })).status).toBe(200);
// Sections by what they control.
expect((await call(nobody, "GET", "/api/v1/console")).status).toBe(403);
expect((await call(nobody, "GET", "/api/v1/console/audit")).status).toBe(403);
const ownerConsole = (await call(owner, "GET", "/api/v1/console")).json;
expect(ownerConsole.sections).toEqual(["overview", "catalogs", "cases", "audit"]);
expect(ownerConsole.moderation).toBeNull();
expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "cases", "audit"]);
expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "cases", "people", "blocks", "audit", "settings"]);
// The owner sees their catalog's entries, not the other catalog's; the maker sees their product's.
const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries;
expect(ownerLog.some((e: any) => e.action === "catalog.edit" && e.tags.catalog === c.id)).toBe(true);
expect(ownerLog.some((e: any) => e.tags.catalog === other.id)).toBe(false);
expect(ownerLog.find((e: any) => e.tags.catalog === c.id).tagNames[c.id]).toBe("Audit Shelf");
const makerLog = (await call(maker, "GET", "/api/v1/console/audit")).json.entries;
expect(makerLog.map((e: any) => e.action)).toContain("product.edit");
expect(makerLog.some((e: any) => e.tags.catalog === c.id && e.action === "catalog.edit")).toBe(false);
// Admins see everything, and filter.
const filtered = (await call(admin, "GET", `/api/v1/console/audit?catalog=${other.id}&action=catalog.edit`)).json.entries;
expect(filtered).toHaveLength(1);
expect(filtered[0]).toMatchObject({ actor: "admin1", action: "catalog.edit" });
expect((await call(admin, "GET", "/api/v1/console/audit?actor=maker6")).json.entries.every((e: any) => e.actor === "maker6")).toBe(true);
expect((await call(admin, "GET", "/api/v1/console/audit?q=legal@example")).json.entries).toHaveLength(1);
});
test("outfits relay only the hub products the wearer may wear; local packs pass", async () => {
const { Realtime } = await import("../src/realtime");
const rt = new Realtime({
owner: async () => "u1",
wearable: async (_, ids) => new Set(ids.filter((id) => id === "aaaaaaaaaa")),
});
rt.open("s1", "u1", () => {});
await rt.message("s1", { type: "session", imvu_id: 7 });
await rt.message("s1", {
type: "outfit",
items: [
{ id: "aaaaaaaaaa", name: "Mine", adult: false },
{ id: "bbbbbbbbbb", name: "Members only", adult: false },
{ id: "local-pack-1", name: "A pack", adult: false },
],
});
expect(rt.online()[0]!.outfit.map((i) => i.id)).toEqual(["aaaaaaaaaa", "local-pack-1"]);
});
+239
View File
@@ -0,0 +1,239 @@
// Decorations over IMVU rooms (docs/products.md, "Decorations"): the REST routes and the realtime notice, with fake
// proxies.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string; id: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
id: "",
};
expect(p.token).not.toBe("");
p.id = (await call(p, "GET", "/api/v1/me")).json.person.id;
for (const [i, a] of imvu.entries()) {
const username = `${name}Av${i || ""}`;
accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(a.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
}
return p;
}
/** A registered product with one revision (retracted: that revision is). */
async function product(id: string, creatorId: string, rating: "general" | "adult", retracted = false) {
await db.insert(schema.product).values({ id, creatorId, name: id, rating, latest: 1 });
await db.insert(schema.productRevision).values({ productId: id, number: 1, files: [], rating, token: "t", retracted });
}
/** A proxy's live connection, signed in to the client as imvuId. */
async function proxy(p: Person, imvuId: number) {
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
const got: any[] = [];
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
const send = (m: object) => ws.send(JSON.stringify(m));
send({ type: "session", imvu_id: imvuId });
await Bun.sleep(30);
return { ws, got, send, decor: () => got.filter((m) => m.type === "decor") };
}
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
expect(ok()).toBe(true);
}
const hash = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
test("only the room's owner decorates it; contents are checked", async () => {
const owner = await person("dora", { id: 501 }, { id: 502 });
const other = await person("dave", { id: 511 });
const unlinked = await person("dina");
await product("aaaaaaaaaa", owner.id, "general");
const room = "502-3";
const item = [7, "aaaaaaaaaa", "0 0 0"];
expect((await call(undefined, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(401);
expect((await call(other, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
expect((await call(unlinked, "PUT", `/api/v1/decorations/${room}`, { contents: [item] })).status).toBe(403);
// Validation.
const put = (contents: unknown, instance = room) => call(owner, "PUT", `/api/v1/decorations/${instance}`, { contents });
for (const bad of ["502", "0502-3", "502-0", "502-3-4", "x-1", "1234567890123-1"])
expect((await put([item], bad)).status).toBe(400);
expect((await put([item], "12345-2100000005")).json.error).toBe("hosted_room");
expect((await put([[7, "AAAAAAAAAA", ""]])).status).toBe(400);
expect((await put([[7, 12345, ""]])).status).toBe(400);
expect((await put([[-1, "aaaaaaaaaa", ""]])).status).toBe(400);
expect((await put([[7, "aaaaaaaaaa", "x".repeat(201)]])).status).toBe(400);
expect((await put([[7, "aaaaaaaaaa"]])).status).toBe(400);
expect((await put([item, item])).status).toBe(400);
expect((await put(Array.from({ length: 301 }, (_, i) => [i, "aaaaaaaaaa", ""]))).status).toBe(400);
const unknown = await put([item, [8, "bbbbbbbbbb", ""]]);
expect(unknown.status).toBe(400);
expect(unknown.json.error).toBe("unknown_product");
// The owner's other account is fine too; up to 300 items.
const ok = await put(Array.from({ length: 300 }, (_, i) => [i, "aaaaaaaaaa", "0 0 0"]));
expect(ok.status).toBe(200);
expect(ok.json.contents).toHaveLength(300);
const set = await put([item]);
expect(set.json).toEqual({ instance: room, contents: [item], hash: hash([item]) });
expect((await call(undefined, "GET", `/api/v1/decorations/${room}`)).json).toEqual(set.json);
// Not another person's room of the same number.
expect((await call(owner, "PUT", "/api/v1/decorations/511-3", { contents: [item] })).status).toBe(403);
});
test("viewers see the products they may, with a hash of what they see", async () => {
const owner = await person("eli", { id: 601, ageVerified: true });
const kid = await person("emma", { id: 611 });
const grown = await person("evan", { id: 621, ageVerified: true });
await product("cccccccccc", owner.id, "general");
await product("dddddddddd", owner.id, "adult");
await product("eeeeeeeeee", owner.id, "general", true); // its only revision is retracted
const room = "601-1";
const g = [1, "cccccccccc", "a"];
const a = [2, "dddddddddd", "b"];
const r = [3, "eeeeeeeeee", "c"];
// Unknown rooms have nothing.
expect((await call(undefined, "GET", "/api/v1/decorations/601-9")).json).toEqual({ instance: "601-9", contents: [], hash: hash([]) });
expect((await call(undefined, "GET", "/api/v1/decorations/601-x")).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/decorations/12345-2100000001")).status).toBe(400);
const put = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [g, a, r] });
expect(put.status).toBe(200);
expect(put.json.contents).toEqual([g, a]); // the owner is adult; the retracted one is left out
const view = async (p?: Person) => (await call(p, "GET", `/api/v1/decorations/${room}`)).json;
expect(await view()).toEqual({ instance: room, contents: [g], hash: hash([g]) });
expect(await view(kid)).toEqual({ instance: room, contents: [g], hash: hash([g]) });
expect(await view(grown)).toEqual({ instance: room, contents: [g, a], hash: hash([g, a]) });
// A product kept but hidden comes back once it has a usable revision again.
await db.update(schema.productRevision).set({ retracted: false }).where(eq(schema.productRevision.productId, "eeeeeeeeee"));
expect((await view(kid)).contents).toEqual([g, r]);
// Changes change the hash.
const before = (await view(kid)).hash;
const moved = [1, "cccccccccc", "moved"];
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [moved, a, r] });
const after = await view(kid);
expect(after.contents).toEqual([moved, r]);
expect(after.hash).not.toBe(before);
// Several rooms at once, in the order asked.
const many = await call(grown, "GET", `/api/v1/decorations?instances=601-9,${room}`);
expect(many.json.rooms).toEqual([
{ instance: "601-9", contents: [], hash: hash([]) },
{ instance: room, contents: [moved, a, r], hash: hash([moved, a, r]) },
]);
expect((await call(undefined, "GET", "/api/v1/decorations")).json).toEqual({ rooms: [] });
const tooMany = Array.from({ length: 21 }, (_, i) => `601-${i + 1}`).join(",");
expect((await call(undefined, "GET", `/api/v1/decorations?instances=${tooMany}`)).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/decorations?instances=601-1,nope")).status).toBe(400);
// An empty list removes them.
const cleared = await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
expect(cleared.json).toEqual({ instance: room, contents: [], hash: hash([]) });
expect(await db.select().from(schema.roomDecoration).where(eq(schema.roomDecoration.instanceId, room))).toHaveLength(0);
});
test("proxies in the room are told when its decorations change", async () => {
const owner = await person("fay", { id: 701 });
const guest = await person("finn", { id: 711 });
const away = await person("fred", { id: 721 });
await product("ffffffffff", owner.id, "general");
const room = "701-2";
const host = await proxy(owner, 701); // the owner's client, in the room
const editor = await proxy(owner, 701); // the owner's other session, saving (not in the room)
const inRoom = await proxy(guest, 711);
const elsewhere = await proxy(away, 721);
host.send({ type: "join", chat: "1400000001", room });
inRoom.send({ type: "join", chat: "1400000001", room });
elsewhere.send({ type: "join", chat: "1400000002", room: "701-3" });
elsewhere.send({ type: "join", chat: "1400000003" });
await Bun.sleep(50);
expect((await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [[1, "ffffffffff", ""]] })).status).toBe(200);
await waitFor(() => host.decor().length === 1 && inRoom.decor().length === 1);
expect(inRoom.decor()[0]).toEqual({ type: "decor", room });
await Bun.sleep(50);
expect(editor.decor()).toHaveLength(0);
expect(elsewhere.decor()).toHaveLength(0);
// Not after leaving.
inRoom.send({ type: "leave", chat: "1400000001" });
await Bun.sleep(30);
await call(owner, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
await waitFor(() => host.decor().length === 2);
await Bun.sleep(50);
expect(inRoom.decor()).toHaveLength(1);
// A refused change tells no one.
await call(guest, "PUT", `/api/v1/decorations/${room}`, { contents: [] });
await Bun.sleep(50);
expect(host.decor()).toHaveLength(2);
for (const p of [host, editor, inRoom, elsewhere]) p.ws.close();
});
+656
View File
@@ -0,0 +1,656 @@
// T5DE-hosted rooms (docs/hosted-rooms.md): the REST routes and the realtime room protocol, with fake proxies.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
/** Signs a person up with linked IMVU accounts (adult: age verified on IMVU). */
async function person(name: string, ...imvu: { id: number; ageVerified?: boolean }[]): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
};
expect(p.token).not.toBe("");
for (const [i, a] of imvu.entries()) {
const username = `${name}Av${i || ""}`;
accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username });
accounts.get(a.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200);
}
return p;
}
/** A proxy's live connection, signed in to the client as imvuId (0: none). */
async function proxy(p: Person, imvuId: number) {
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
const got: any[] = [];
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
const send = (m: object) => ws.send(JSON.stringify(m));
if (imvuId) {
send({ type: "session", imvu_id: imvuId });
await Bun.sleep(30);
}
const last = (type: string, room?: number) => got.filter((m) => m.type === type && (room === undefined || m.room === room)).at(-1);
/** Sends a message and waits for the answer of that type for that room. */
const ask = async (m: { type: string; room: number }, answer: (m: any) => boolean) => {
const before = got.length;
send(m);
await waitFor(() => got.slice(before).some(answer));
return got.slice(before).find(answer);
};
const join = (room: number) =>
ask({ type: "room.join", room }, (m) => (m.type === "room.joined" || m.type === "room.error") && m.room === room);
return { ws, got, send, last, join, ask };
}
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
expect(ok()).toBe(true);
}
const newRoom = (imvuId: number, over: Record<string, unknown> = {}) => ({
name: "Lounge",
description: "A quiet place.",
roomPid: 1234,
rating: "general",
maxUsers: 10,
imvuId,
...over,
});
test("rooms are made, listed, changed and closed by their owners", async () => {
const ada = await person("adam", { id: 301, ageVerified: true }, { id: 302 });
const ben = await person("benn", { id: 311 });
// Validation and permissions.
expect((await call(undefined, "POST", "/api/v1/hosted-rooms", newRoom(301))).status).toBe(401);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: " " }))).json.error).toBe("invalid");
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 31 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { maxUsers: 1 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { roomPid: 0 }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { rating: "x" }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { description: "x".repeat(513) }))).status).toBe(400);
expect((await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(311))).json.error).toBe("not_linked");
expect((await call(ben, "POST", "/api/v1/hosted-rooms", newRoom(311, { rating: "adult" }))).json.error).toBe("not_adult");
// Made: owned by the chosen account, shown with its IMVU name.
const made = await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(302, { name: " The Lounge ", maxUsers: undefined }));
expect(made.status).toBe(201);
const n = made.json.room.n;
expect(made.json.room).toMatchObject({
instanceId: `302-${2_100_000_000 + n}`,
chatId: 2_100_000_000 + n,
name: "The Lounge",
description: "A quiet place.",
roomPid: 1234,
rating: "general",
maxUsers: 10,
ownerImvuId: 302,
ownerName: "adamAv1",
occupancy: 0,
});
const adult = (await call(ada, "POST", "/api/v1/hosted-rooms", newRoom(301, { name: "Late", rating: "adult" }))).json.room;
expect(adult.rating).toBe("adult");
// Listed newest first; adult rooms only for adults.
const names = async (p?: Person, q = "") => ((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).map((r) => r.name);
expect(await names(ada)).toEqual(["Late", "The Lounge"]);
expect(await names(ben)).toEqual(["The Lounge"]);
expect(await names()).toEqual(["The Lounge"]);
expect(await names(ben, "?mine=1")).toEqual([]);
expect(await names(ada, "?mine=1")).toEqual(["Late", "The Lounge"]);
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?mine=1")).status).toBe(401);
// One room, with its scene.
const got = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`);
expect(got.json.scene.contents).toEqual([]);
expect(got.json.scene.hash).toMatch(/^[0-9a-f]{64}$/);
expect((await call(ben, "GET", `/api/v1/hosted-rooms/${adult.n}`)).status).toBe(404);
expect((await call(ben, "GET", "/api/v1/hosted-rooms/999999")).status).toBe(404);
expect((await call(ben, "GET", "/api/v1/hosted-rooms/x")).status).toBe(400);
// Changes: owner only.
expect((await call(ben, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Mine now" })).status).toBe(403);
expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { maxUsers: 40 })).status).toBe(400);
const patched = await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Lounge 2", maxUsers: 4, description: "" });
expect(patched.json.room).toMatchObject({ name: "Lounge 2", maxUsers: 4, description: "", roomPid: 1234 });
// The scene: owner only, checked.
const scene = { contents: [[1, 5678, "1 0 0 0 0 0 0 1"], [2, "abcdefghij", "1 2 0 0"]] };
expect((await call(ben, "PUT", `/api/v1/hosted-rooms/${n}/scene`, scene)).status).toBe(403);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, "Nope!", "x"]] })).status).toBe(400);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2]] })).status).toBe(400);
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { contents: [[1, 2, "x".repeat(201)]] })).status).toBe(400);
const tooMany = { contents: Array.from({ length: 501 }, (_, i) => [i, 2, ""]) };
expect((await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, tooMany)).status).toBe(400);
const put = await call(ada, "PUT", `/api/v1/hosted-rooms/${n}/scene`, { ...scene, roomPid: 4321 });
expect(put.status).toBe(200);
expect(put.json.scene.contents).toEqual(scene.contents);
expect(put.json.scene.hash).not.toBe(got.json.scene.hash);
const after = await call(ben, "GET", `/api/v1/hosted-rooms/${n}`);
expect(after.json.scene).toEqual(put.json.scene);
expect(after.json.room.roomPid).toBe(4321);
// Closing: owner only; then it's gone from lists and answers 410.
expect((await call(ben, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(403);
expect((await call(ada, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204);
expect((await call(ben, "GET", `/api/v1/hosted-rooms/${n}`)).json.error).toBe("closed");
expect(await names(ada)).toEqual(["Late"]);
expect((await call(ada, "PATCH", `/api/v1/hosted-rooms/${n}`, { name: "Back" })).status).toBe(410);
// Unlinking the owner account hides its rooms.
await call(ada, "DELETE", "/api/v1/account/links/301");
expect(await names(ada)).toEqual([]);
});
test("a person may own up to 10 open rooms", async () => {
const cy = await person("cyd", { id: 321 });
const ns: number[] = [];
for (let i = 0; i < 10; i++) {
const r = await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321, { name: `Room ${i}` }));
expect(r.status).toBe(201);
ns.push(r.json.room.n);
}
expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).json.error).toBe("room_cap");
await call(cy, "DELETE", `/api/v1/hosted-rooms/${ns[0]}`);
expect((await call(cy, "POST", "/api/v1/hosted-rooms", newRoom(321))).status).toBe(201);
});
test("joining, seats and messages in a hosted room", async () => {
const dee = await person("deen", { id: 401, ageVerified: true });
const eli = await person("elia", { id: 402 });
const fay = await person("faye", { id: 403 });
const room = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { maxUsers: 2 }))).json.room;
const n: number = room.n;
const adultRoom = (await call(dee, "POST", "/api/v1/hosted-rooms", newRoom(401, { rating: "adult" }))).json.room.n;
const d = await proxy(dee, 401);
const e = await proxy(eli, 402);
const f = await proxy(fay, 403);
// Refusals: no linked account in use, no such room, adult room for a non-adult.
const nobody = await proxy(eli, 0);
expect((await nobody.join(n)).reason).toBe("not_linked");
nobody.ws.close();
const notMine = await proxy(eli, 403); // Fay's account, not Eli's
expect((await notMine.join(n)).reason).toBe("not_linked");
notMine.ws.close();
expect((await e.join(999_999)).reason).toBe("not_found");
expect((await e.join(adultRoom)).reason).toBe("adult");
e.send({ type: "room.join", room: 0 });
await waitFor(() => e.got.some((m) => m.message === "bad room"));
// Dee and Eli join; each sees the other.
const dj = await d.join(n);
expect(dj).toEqual({ type: "room.joined", room: n, chat: 2_100_000_000 + n, participants: [{ imvu_id: 401, seat: 0, name: "deenAv" }], sequence: 0 });
const ej = await e.join(n);
expect(ej.participants).toEqual([{ imvu_id: 401, seat: 0, name: "deenAv" }, { imvu_id: 402, seat: 0, name: "eliaAv" }]);
await waitFor(() => d.last("room.participants", n)?.participants.length === 2);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.occupancy).toBe(2);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${n}/participants`)).json.participants).toEqual(ej.participants);
expect((await call(eli, "GET", `/api/v1/hosted-rooms/${adultRoom}/participants`)).status).toBe(404);
// Full at two people; joining again (a reconnect) answers the same.
expect((await f.join(n)).reason).toBe("full");
expect((await e.join(n)).type).toBe("room.joined");
// Seats.
e.send({ type: "room.seat", room: n, seat: 3 });
await waitFor(() => d.last("room.participants", n)?.participants.some((p: any) => p.imvu_id === 402 && p.seat === 3));
e.send({ type: "room.seat", room: n, seat: 100 });
await waitFor(() => e.got.some((m) => m.message === "bad seat"));
// Messages go to the others, numbered; forged senders are dropped.
const chat = (userId: number, message: string) => JSON.stringify({ userId, chatId: 2_100_000_000 + n, message, to: 0 });
e.send({ type: "room.send", room: n, mount: "messages", message: chat(402, "hi") });
e.send({ type: "room.send", room: n, mount: "messages", message: chat(401, "I'm Dee") });
e.send({ type: "room.send", room: n, mount: "messages", message: "not JSON" });
e.send({ type: "room.send", room: n, mount: "control", message: JSON.stringify({ command: "PlayAction" }) });
await waitFor(() => d.got.filter((m) => m.type === "room.message").length === 2);
await Bun.sleep(50);
const msgs = d.got.filter((m) => m.type === "room.message");
expect(msgs).toEqual([
{ type: "room.message", room: n, from: 402, mount: "messages", message: chat(402, "hi"), sequence: 1 },
{ type: "room.message", room: n, from: 402, mount: "control", message: JSON.stringify({ command: "PlayAction" }), sequence: 2 },
]);
expect(e.got.filter((m) => m.type === "room.message")).toEqual([]); // not back to the sender
e.send({ type: "room.send", room: n, mount: "other", message: "x" });
await waitFor(() => e.got.some((m) => m.message === "bad mount"));
e.send({ type: "room.send", room: n, mount: "control", message: "x".repeat(64 * 1024 + 1) });
await waitFor(() => e.got.some((m) => m.message === "bad message"));
f.send({ type: "room.send", room: n, mount: "control", message: "{}" });
await waitFor(() => f.got.some((m) => m.message === "not in that room"));
// Rate: a burst of 20, then the rest is dropped.
for (let i = 0; i < 30; i++) e.send({ type: "room.send", room: n, mount: "control", message: `{"i":${i}}` });
await Bun.sleep(150);
const burst = d.got.filter((m) => m.type === "room.message").length - 2;
expect(burst).toBeGreaterThanOrEqual(18);
expect(burst).toBeLessThanOrEqual(23);
expect((await d.join(n)).sequence).toBe(2 + burst);
// Leaving makes room; a closed connection leaves too.
e.send({ type: "room.leave", room: n });
await waitFor(() => d.last("room.participants", n)?.participants.length === 1);
expect((await f.join(n)).type).toBe("room.joined");
f.ws.close();
await waitFor(() => d.last("room.participants", n)?.participants.length === 1);
// Closing tells everyone in it; joining it again is refused.
expect((await e.join(n)).type).toBe("room.joined");
expect((await call(dee, "DELETE", `/api/v1/hosted-rooms/${n}`)).status).toBe(204);
await waitFor(() => e.last("room.closed", n) !== undefined && d.last("room.closed", n) !== undefined);
expect((await e.join(n)).reason).toBe("closed");
d.ws.close();
e.ws.close();
});
test("a room made adult sends non-adults out; an unlinked account leaves its rooms", async () => {
const gil = await person("gilt", { id: 501, ageVerified: true });
const hal = await person("hale", { id: 502 });
const n = (await call(gil, "POST", "/api/v1/hosted-rooms", newRoom(501))).json.room.n;
const g = await proxy(gil, 501);
const h = await proxy(hal, 502);
await g.join(n);
await h.join(n);
expect((await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "adult" })).status).toBe(200);
await waitFor(() => h.last("room.closed", n) !== undefined);
await waitFor(() => g.last("room.participants", n)?.participants.length === 1);
expect(g.last("room.closed", n)).toBeUndefined();
await call(gil, "PATCH", `/api/v1/hosted-rooms/${n}`, { rating: "general" });
await h.join(n);
await waitFor(() => g.last("room.participants", n)?.participants.length === 2);
await call(hal, "DELETE", "/api/v1/account/links/502");
await waitFor(() => h.got.filter((m) => m.type === "room.closed").length === 2);
await waitFor(() => g.last("room.participants", n)?.participants.length === 1);
g.ws.close();
h.ws.close();
});
// ---- Phase 2 ----
test("moderators boot and ban; owners lift bans", async () => {
const own = await person("ownr", { id: 601 });
const mod = await person("modr", { id: 602 });
const usr = await person("usrr", { id: 603 });
const md2 = await person("mod2", { id: 604 });
accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false, picture: null }); // IMVU only
const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n;
// Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20.
expect((await call(mod, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602] })).status).toBe(403);
const many = Array.from({ length: 21 }, (_, i) => i + 1);
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: many })).status).toBe(400);
expect((await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["nobody_here"] })).json.error).toBe(
"no_such_imvu_account",
);
const set = await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [602, "MOD2av", "notont5", 601, 602] });
expect(set.status).toBe(200);
expect(set.json.room.moderators).toEqual([602, 604, 605]);
expect(set.json.room.moderatorNames).toEqual(["modrAv", "mod2Av", "NotOnT5"]);
await call(own, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: ["602", 604] });
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.moderators).toEqual([602, 604]);
const o = await proxy(own, 601);
const m = await proxy(mod, 602);
const u = await proxy(usr, 603);
const m2 = await proxy(md2, 604);
for (const p of [o, m, u, m2]) expect((await p.join(n)).type).toBe("room.joined");
const boot = (p: typeof o, imvu_id: number, extra: object = {}) =>
p.ask({ type: "room.boot", room: n, imvu_id, ...extra } as any, (x) => x.type === "room.boot_result" && x.imvu_id === imvu_id);
// Who may boot whom.
expect(await boot(u, 602)).toMatchObject({ ok: false, reason: "forbidden" });
expect((await boot(m, 604)).reason).toBe("forbidden"); // a moderator
expect((await boot(m, 601)).reason).toBe("forbidden"); // the owner
expect((await boot(o, 601)).reason).toBe("forbidden"); // themselves
m.send({ type: "room.boot", room: n, imvu_id: 603, minutes: -1 });
await waitFor(() => m.got.some((x) => x.message === "bad minutes"));
// A boot: out, but may come back.
expect(await boot(m, 603)).toEqual({ type: "room.boot_result", room: n, imvu_id: 603, ok: true, ban: false });
await waitFor(() => u.last("room.closed", n)?.reason === "booted");
await waitFor(() => o.last("room.participants", n)?.participants.length === 3);
expect((await u.join(n)).type).toBe("room.joined");
// A ban: out, and refused.
expect((await boot(m, 603, { ban: true })).ban).toBe(true);
await waitFor(() => u.got.filter((x) => x.type === "room.closed" && x.reason === "booted").length === 2);
expect(await u.join(n)).toMatchObject({ type: "room.error", reason: "banned" });
// Bans: listed for the owner and moderators; lifted by the owner only.
expect((await call(usr, "GET", `/api/v1/hosted-rooms/${n}/bans`)).status).toBe(403);
const bans = await call(mod, "GET", `/api/v1/hosted-rooms/${n}/bans`);
expect(bans.json.bans).toMatchObject([{ imvuId: 603, byImvuId: 602, until: null }]);
expect((await call(mod, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(403);
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(204);
expect((await call(own, "DELETE", `/api/v1/hosted-rooms/${n}/bans/603`)).status).toBe(404);
expect((await u.join(n)).type).toBe("room.joined");
// A ban for a while; the owner may boot a moderator.
await boot(o, 603, { ban: true, minutes: 60 });
const until = (await call(own, "GET", `/api/v1/hosted-rooms/${n}/bans`)).json.bans[0].until;
expect(Date.parse(until) - Date.now()).toBeGreaterThan(59 * 60_000);
expect((await boot(o, 604)).ok).toBe(true);
await waitFor(() => m2.last("room.closed", n)?.reason === "booted");
// Logged.
const log = await db.select().from(schema.audit).where(eq(schema.audit.target, String(n)));
const actions = log.map((e) => e.action);
expect(actions.filter((a) => a === "hosted_room.boot").length).toBe(2);
expect(actions.filter((a) => a === "hosted_room.ban").length).toBe(2);
expect(actions).toContain("hosted_room.unban");
expect(log.find((e) => e.action === "hosted_room.ban")!.detail).toEqual({ imvu: 603, by: 602, minutes: 0 });
for (const p of [o, m, u, m2]) p.ws.close();
});
test("private rooms and invitations", async () => {
const pam = await person("pamm", { id: 701 });
const quin = await person("quin", { id: 702 });
const rex = await person("rexx", { id: 703 }, { id: 705 });
const n = (await call(pam, "POST", "/api/v1/hosted-rooms", newRoom(701, { name: "Hideout", privacy: "private" }))).json.room.n;
expect((await call(pam, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.privacy).toBe("private");
await call(pam, "PATCH", `/api/v1/hosted-rooms/${n}`, { moderators: [702] });
const pub = (await call(rex, "POST", "/api/v1/hosted-rooms", newRoom(703, { name: "Open" }))).json.room.n;
// Listed only for the owner and moderators.
const listed = async (p?: Person, q = "") =>
((await call(p, "GET", `/api/v1/hosted-rooms${q}`)).json.rooms as any[]).some((r) => r.n === n);
expect(await listed()).toBe(false);
expect(await listed(rex)).toBe(false);
expect(await listed(pam)).toBe(true);
expect(await listed(quin)).toBe(true);
expect(await listed(pam, "?mine=1")).toBe(true);
expect((await call(rex, "GET", `/api/v1/hosted-rooms/${n}`)).status).toBe(200); // not listed, but there
const p = await proxy(pam, 701);
const q = await proxy(quin, 702);
const r = await proxy(rex, 703);
expect((await r.join(n)).reason).toBe("private");
expect((await q.join(n)).type).toBe("room.joined");
const invite = (from: typeof p, room: number, imvu_id: number) =>
from.ask({ type: "room.invite", room, imvu_id } as any, (x) => x.type === "room.invite_result" && x.room === room && x.imvu_id === imvu_id);
const joinWith = (x: typeof p, invite: string) =>
x.ask({ type: "room.join", room: n, invite } as any, (m) => m.room === n && (m.type === "room.joined" || m.type === "room.error"));
// Only the owner and moderators invite to a private room (anyone in it, to a public one).
expect(await invite(r, n, 702)).toMatchObject({ ok: false, reason: "forbidden" });
expect(await invite(p, pub, 702)).toMatchObject({ ok: false, reason: "not_in_room" });
// Invited: the invitee's sessions are told; it answers; the inviter hears.
const sent = await invite(p, n, 703);
expect(sent.ok).toBe(true);
const id: string = sent.invite;
await waitFor(() => r.last("room.invited", n) !== undefined);
expect(r.last("room.invited", n)).toEqual({
type: "room.invited",
room: n,
invite: id,
from: 701,
room_name: "Hideout",
owner_name: "pammAv",
});
expect((await joinWith(r, id)).reason).toBe("private"); // not accepted yet
q.send({ type: "room.invite_reply", invite: id, accept: true }); // not Quin's to answer
await waitFor(() => q.got.some((x) => x.message === "no such invite"));
r.send({ type: "room.invite_reply", invite: id, accept: true, note: " on my way\n" });
await waitFor(() => p.last("room.invite_answer") !== undefined);
expect(p.last("room.invite_answer")).toEqual({ type: "room.invite_answer", invite: id, to: 703, accept: true, note: "on my way" });
r.send({ type: "room.invite_reply", invite: id, accept: false });
await waitFor(() => r.got.some((x) => x.message === "already answered"));
// An accepted invite lets the account in once; reconnecting is fine, leaving ends it.
expect((await joinWith(r, id)).type).toBe("room.joined");
r.ws.close();
const r2 = await proxy(rex, 703);
expect((await r2.join(n)).type).toBe("room.joined");
r2.send({ type: "room.leave", room: n });
await waitFor(() => q.last("room.participants", n)?.participants.length === 1);
expect((await joinWith(r2, id)).reason).toBe("private");
// Declined.
const second = (await invite(q, n, 703)).invite;
await waitFor(() => r2.got.some((x) => x.invite === second));
r2.send({ type: "room.invite_reply", invite: second, accept: false });
await waitFor(() => q.got.some((x) => x.type === "room.invite_answer" && x.invite === second));
expect(q.got.find((x) => x.type === "room.invite_answer" && x.invite === second).accept).toBe(false);
expect((await joinWith(r2, second)).reason).toBe("private");
// Nobody online as that account; banned accounts aren't invited.
const off = await invite(p, n, 705);
expect(off.ok).toBe(true);
await waitFor(() => p.got.some((x) => x.type === "room.invite_answer" && x.invite === off.invite));
expect(p.got.find((x) => x.type === "room.invite_answer" && x.invite === off.invite)).toEqual({
type: "room.invite_answer",
invite: off.invite,
to: 705,
accept: false,
offline: true,
});
await p.ask({ type: "room.boot", room: n, imvu_id: 703, ban: true } as any, (x) => x.type === "room.boot_result");
expect(await invite(p, n, 703)).toMatchObject({ ok: false, reason: "banned" });
// Public rooms: anyone in it invites.
expect((await r2.join(pub)).type).toBe("room.joined");
expect((await invite(r2, pub, 701)).ok).toBe(true);
await waitFor(() => p.last("room.invited", pub) !== undefined);
for (const x of [p, q, r2]) x.ws.close();
});
const png = (bytes = 64) => {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
return b;
};
async function upload(p: Person, n: number, data: Uint8Array, type = "image/png") {
const form = new FormData();
form.set("image", new File([data as Uint8Array<ArrayBuffer>], "room.png", { type }));
const res = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, {
method: "PUT",
headers: { Origin: webURL, Cookie: p.cookie },
body: form,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
test("room images", async () => {
const ivy = await person("ivyy", { id: 801 });
const jon = await person("jonn", { id: 802 });
const n = (await call(ivy, "POST", "/api/v1/hosted-rooms", newRoom(801))).json.room.n;
expect((await call(jon, "GET", `/api/v1/hosted-rooms/${n}`)).json.room.image).toBeNull();
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
expect((await upload(jon, n, png())).status).toBe(403);
const gif = new TextEncoder().encode("GIF89a................................");
expect((await upload(ivy, n, gif)).json.error).toBe("invalid");
const svg = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"/>');
expect((await upload(ivy, n, svg)).status).toBe(400);
expect((await upload(ivy, n, png(512 * 1024 + 1))).status).toBe(400);
const up = await upload(ivy, n, png());
expect(up.status).toBe(200);
expect(up.json.room.image).toMatch(new RegExp(`^http://localhost:4101/api/v1/hosted-rooms/${n}/image\\?v=[0-9a-f]{16}$`));
const got = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
expect(got.status).toBe(200);
expect(got.headers.get("content-type")).toBe("image/png");
expect(got.headers.get("cache-control")).toBe("public, max-age=3600");
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
expect(new Uint8Array(await got.arrayBuffer())).toEqual(png());
const etag = got.headers.get("etag")!;
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`, { headers: { "If-None-Match": etag } })).status).toBe(304);
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46]);
expect((await upload(ivy, n, jpeg)).status).toBe(200);
const again = await fetch(`${base}/api/v1/hosted-rooms/${n}/image`);
expect(again.headers.get("content-type")).toBe("image/jpeg");
expect(again.headers.get("etag")).not.toBe(etag);
expect((await call(jon, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).status).toBe(403);
expect((await call(ivy, "DELETE", `/api/v1/hosted-rooms/${n}/image`)).json.room.image).toBeNull();
expect((await fetch(`${base}/api/v1/hosted-rooms/${n}/image`)).status).toBe(404);
});
test("favorites, ratings and recent rooms", async () => {
const kim = await person("kimm", { id: 901 });
const lou = await person("louu", { id: 902 });
const mia = await person("miaa", { id: 903 });
const ns: number[] = [];
for (const name of ["One", "Two", "Three"])
ns.push((await call(kim, "POST", "/api/v1/hosted-rooms", newRoom(901, { name }))).json.room.n);
const [a, b, c] = ns as [number, number, number];
// Favorites.
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?favorites=1")).status).toBe(401);
expect((await call(undefined, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(401);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${c}/favorite`)).status).toBe(204);
expect((await call(lou, "PUT", "/api/v1/hosted-rooms/999999/favorite")).status).toBe(404);
const favs = async (p: Person) => ((await call(p, "GET", "/api/v1/hosted-rooms?favorites=1")).json.rooms as any[]).map((r) => r.n);
expect(await favs(lou)).toEqual([c, a]);
expect(await favs(mia)).toEqual([]);
const flag = async (p: Person | undefined, n: number) =>
((await call(p, "GET", "/api/v1/hosted-rooms")).json.rooms as any[]).find((r) => r.n === n).favorite;
expect(await flag(lou, a)).toBe(true);
expect(await flag(mia, a)).toBe(false);
expect(await flag(undefined, a)).toBe(false);
expect((await call(lou, "GET", `/api/v1/hosted-rooms/${a}`)).json.room.favorite).toBe(true);
expect((await call(lou, "DELETE", `/api/v1/hosted-rooms/${a}/favorite`)).status).toBe(204);
expect(await favs(lou)).toEqual([c]);
// Ratings.
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 6 })).status).toBe(400);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 0 })).status).toBe(400);
expect((await call(kim, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 })).status).toBe(403); // own room
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 3 })).json.myRating).toBe(3);
expect((await call(lou, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 4 })).json.room).toMatchObject({ score: 4, ratings: 1 });
await call(mia, "PUT", `/api/v1/hosted-rooms/${a}/rating`, { score: 5 });
const one = await call(lou, "GET", `/api/v1/hosted-rooms/${a}`);
expect(one.json.room).toMatchObject({ score: 4.5, ratings: 2 });
expect(one.json.myRating).toBe(4);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.myRating).toBe(0);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${b}`)).json.room).toMatchObject({ score: 0, ratings: 0 });
expect((await call(mia, "DELETE", `/api/v1/hosted-rooms/${a}/rating`)).status).toBe(204);
expect((await call(undefined, "GET", `/api/v1/hosted-rooms/${a}`)).json.room).toMatchObject({ score: 4, ratings: 1 });
// Recent rooms: joined, latest first, each once.
expect((await call(undefined, "GET", "/api/v1/hosted-rooms?recent=1")).status).toBe(401);
const l = await proxy(lou, 902);
const recent = async () => ((await call(lou, "GET", "/api/v1/hosted-rooms?recent=1")).json.rooms as any[]).map((r) => r.n);
for (const n of [a, b, c]) {
await l.join(n);
await Bun.sleep(20);
}
for (let i = 0; i < 100 && (await recent()).length < 3; i++) await Bun.sleep(10);
expect(await recent()).toEqual([c, b, a]);
l.send({ type: "room.leave", room: a });
await Bun.sleep(30);
await l.join(a);
for (let i = 0; i < 100 && (await recent())[0] !== a; i++) await Bun.sleep(10);
expect(await recent()).toEqual([a, c, b]);
const visit = await db.select().from(schema.hostedRoomVisit).where(eq(schema.hostedRoomVisit.n, a));
expect(visit.map((v) => v.imvuId)).toEqual([902]);
l.ws.close();
});
test("watching rooms' occupancy", async () => {
const ned = await person("nedd", { id: 1001 });
const ola = await person("olaa", { id: 1002 });
const a = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "A" }))).json.room.n;
const b = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "B" }))).json.room.n;
const hidden = (await call(ned, "POST", "/api/v1/hosted-rooms", newRoom(1001, { name: "C", privacy: "private" }))).json.room.n;
const w = await proxy(ola, 1002);
const n = await proxy(ned, 1001);
w.send({ type: "room.watch", rooms: Array.from({ length: 101 }, (_, i) => i + 1) });
await waitFor(() => w.got.some((x) => x.message === "bad rooms"));
w.send({ type: "room.watch", rooms: [a, b, hidden, 999_999] });
await waitFor(() => w.last("room.occupancy", a) !== undefined && w.last("room.occupancy", b) !== undefined);
expect(w.last("room.occupancy", a)).toEqual({ type: "room.occupancy", room: a, participants: [] });
// The private room's owner may watch it.
n.send({ type: "room.watch", rooms: [hidden] });
await waitFor(() => n.last("room.occupancy", hidden) !== undefined);
await n.join(a);
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 1);
expect(w.last("room.occupancy", a).participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
n.send({ type: "room.seat", room: a, seat: 2 });
await waitFor(() => w.last("room.occupancy", a)?.participants[0]?.seat === 2);
n.send({ type: "room.leave", room: a });
await waitFor(() => w.last("room.occupancy", a)?.participants.length === 0);
await n.join(hidden);
await Bun.sleep(50);
expect(w.last("room.occupancy", hidden)).toBeUndefined();
expect(n.last("room.occupancy", hidden)?.participants).toEqual([{ imvu_id: 1001, seat: 0, name: "neddAv" }]);
// Replaced; closing tells watchers it's empty.
w.send({ type: "room.watch", rooms: [b] });
await Bun.sleep(50);
const before = w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length;
await n.join(a);
await n.join(b);
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 1);
expect(w.got.filter((x) => x.type === "room.occupancy" && x.room === a).length).toBe(before);
await call(ned, "DELETE", `/api/v1/hosted-rooms/${b}`);
await waitFor(() => w.last("room.occupancy", b)?.participants.length === 0);
w.ws.close();
n.ws.close();
});
+15 -8
View File
@@ -130,14 +130,14 @@ describe("linking IMVU accounts", () => {
test("by tagline; the most recent proof wins; link caps", async () => { test("by tagline; the most recent proof wins; link caps", async () => {
const carol = new Browser(); const carol = new Browser();
await carol.signup("carol"); await carol.signup("carol");
accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false }); accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false, picture: null });
const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" }); const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" });
expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/); expect(started.json.challenge.code).toMatch(/^t5de-[A-Z2-9]{4}-[A-Z2-9]{4}$/);
const missing = await carol.call("POST", "/api/v1/account/links/42/check"); const missing = await carol.call("POST", "/api/v1/account/links/42/check");
expect(missing.status).toBe(409); expect(missing.status).toBe(409);
expect(missing.json.error).toBe("code_missing"); expect(missing.json.error).toBe("code_missing");
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false }); const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false, picture: null });
expect(linked.status).toBe(200); expect(linked.status).toBe(200);
expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true }); expect(linked.json.link).toMatchObject({ imvuId: 42, username: "CarolAv", adult: true });
expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe( expect((await carol.call("POST", "/api/v1/account/links", { username: "nobody" })).json.error).toBe(
@@ -148,7 +148,7 @@ describe("linking IMVU accounts", () => {
const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!; const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
const dave = new Browser(); const dave = new Browser();
await dave.signup("dave"); await dave.signup("dave");
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false }); const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false, picture: null });
expect(moved.json.moved).toBe(true); expect(moved.json.moved).toBe(true);
const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!; const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
expect(after.userId).not.toBe(before.userId); expect(after.userId).not.toBe(before.userId);
@@ -160,7 +160,7 @@ describe("linking IMVU accounts", () => {
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" }); await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id; const daveId = (await dave.call("GET", "/api/v1/me")).json.person.id;
expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200); expect((await admin.call("POST", `/api/v1/console/people/${daveId}/link-cap`, { linkCap: 1 })).status).toBe(200);
const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false }); const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false, picture: null });
expect(over.status).toBe(403); expect(over.status).toBe(403);
expect(over.json.error).toBe("link_cap"); expect(over.json.error).toBe("link_cap");
}); });
@@ -170,7 +170,7 @@ describe("T5DE's proxy", () => {
test("signs in with a device code, connects, shows presence; a ban ends it", async () => { test("signs in with a device code, connects, shows presence; a ban ends it", async () => {
const erin = new Browser(); const erin = new Browser();
await erin.signup("erin"); await erin.signup("erin");
await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false }); await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false, picture: null });
// The proxy asks for a code; until it's approved, the token request waits. // The proxy asks for a code; until it's approved, the token request waits.
const codeRes = await fetch(`${base}/api/auth/device/code`, { const codeRes = await fetch(`${base}/api/auth/device/code`, {
@@ -213,6 +213,13 @@ describe("T5DE's proxy", () => {
await new Promise((r) => (ws.onopen = r)); await new Promise((r) => (ws.onopen = r));
ws.send(JSON.stringify({ type: "session", imvu_id: 42 })); ws.send(JSON.stringify({ type: "session", imvu_id: 42 }));
await waitFor(() => messages.some((m) => m.message === "not_linked")); await waitFor(() => messages.some((m) => m.message === "not_linked"));
// An account linked while the client runs counts at once.
ws.send(JSON.stringify({ type: "session", imvu_id: 78 }));
await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2);
await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false, picture: null });
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78));
await erin.call("DELETE", "/api/v1/account/links/78");
await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78));
ws.send(JSON.stringify({ type: "session", imvu_id: 77 })); ws.send(JSON.stringify({ type: "session", imvu_id: 77 }));
ws.send(JSON.stringify({ type: "outfit", items: [{ id: "blue-coat", name: "Blue Coat" }] })); ws.send(JSON.stringify({ type: "outfit", items: [{ id: "blue-coat", name: "Blue Coat" }] }));
await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 77 && o.outfit.length === 1)); await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 77 && o.outfit.length === 1));
@@ -222,12 +229,12 @@ describe("T5DE's proxy", () => {
const admin = new Browser(); const admin = new Browser();
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" }); await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
const consoleView = (await admin.call("GET", "/api/v1/console")).json; const consoleView = (await admin.call("GET", "/api/v1/console")).json;
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true }); expect(consoleView.moderation.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
expect(consoleView.counts.computers).toBe(1); expect(consoleView.moderation.counts.computers).toBe(1);
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true }); await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
const online = (await visitor.call("GET", "/api/v1/online")).json.online; const online = (await visitor.call("GET", "/api/v1/online")).json.online;
expect(online).toEqual([ expect(online).toEqual([
{ imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat" }] }, { imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat", adult: false }] },
]); ]);
// A ban closes the connection and ends the proxy's session. // A ban closes the connection and ends the proxy's session.
+462
View File
@@ -0,0 +1,462 @@
// The product registry: catalogs, uploads registered by catalogs, product info, revisions, libraries, tokens.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { createLocalJWKSet, jwtVerify } from "jose";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, imvuImage, parse, parseProduct, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
// IMVU products by ID, as IMVU's API answers them ("down": IMVU can't be reached for it).
const imvuProducts = new Map<number, Record<string, unknown> | "down">();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const pm = url.pathname.match(/^\/product\/product-(\d+)$/);
if (pm) {
const pid = Number(pm[1]);
const data = imvuProducts.get(pid);
if (data === "down") throw new Error("connection refused");
if (!data) return Response.json({ status: "failure", error: "NODE-001" }, { status: 404 });
return Response.json({ status: "success", denormalized: { [`https://api.imvu.com/product/product-${pid}`]: { data } } });
}
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
let hub: ReturnType<typeof createApp>;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU), apiURL });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string, imvu?: { id: number; ageVerified: boolean }): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") };
if (imvu) {
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
}
return p;
}
// Set by the first test for the next: the admin and an approved catalog.
let shared: { admin: Person; catalogID: string; auth: Record<string, string> };
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
test("catalogs register uploads; products have revisions, info and libraries", async () => {
const admin = await person("admin1");
const maker = await person("maker", { id: 501, ageVerified: true });
const kid = await person("kid", { id: 502, ageVerified: false });
// A catalog registers; it can't do anything until approved.
const reg = await call(maker, "POST", "/api/v1/catalogs", {
name: "Maker's Shelf",
url: "http://localhost:4102/",
contact: "[email protected]",
visibility: "public",
membership: "open",
});
expect(reg.status).toBe(201);
const catalogID = reg.json.catalog.id as string;
const auth = { Authorization: `Catalog ${catalogID}:${reg.json.key}` };
shared = { admin, catalogID, auth };
expect(reg.json.catalog.url).toBe("http://localhost:4102");
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs).toHaveLength(0);
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).status).toBe(404);
expect((await call(admin, "POST", `/api/v1/console/catalogs/${catalogID}/approve`)).status).toBe(200);
expect((await call(undefined, "GET", "/api/v1/catalogs")).json.catalogs.map((c: any) => c.name)).toEqual(["Maker's Shelf"]);
// The maker gets an upload token; the catalog registers what was uploaded with it.
const up = await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID });
expect(up.json.url).toBe("http://localhost:4102/api/v1/uploads");
const files = [file("tex.png", 2), file("index.xml", 1)];
const revision = (token: string, extra: object = {}) =>
call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files, rating: "general", parent: 500, name: "Blue Coat", notes: "", ...extra }, auth);
expect((await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: up.json.token, files, rating: "general", parent: 0, name: "x", notes: "" }, { Authorization: `Catalog ${catalogID}:wrong` })).status).toBe(401);
const made = await revision(up.json.token);
expect(made.status).toBe(201);
expect(made.json).toMatchObject({ revision: 1, draft: true });
const id = made.json.product as string;
expect(id).toMatch(/^[a-z2-7]{10}$/);
// A new product is a draft: only its creator (and moderators) see it, and nobody can get it until it's published.
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).status).toBe(404);
expect((await call(kid, "GET", `/api/v1/products/${id}`)).status).toBe(404);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).status).toBe(404);
expect((await call(admin, "GET", `/api/v1/products/${id}`)).json.product.status).toBe("draft");
expect((await call(maker, "GET", `/api/v1/products/${id}`)).json.product).toMatchObject({ status: "draft", publishedAt: null });
expect((await call(maker, "PUT", `/api/v1/library/${id}`)).json.error).toBe("draft");
expect((await call(maker, "GET", "/api/v1/products?creator=maker")).json.products).toEqual([]);
expect((await call(kid, "POST", `/api/v1/products/${id}/publish`)).status).toBe(403);
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.product.status).toBe("published");
expect((await call(maker, "POST", `/api/v1/products/${id}/publish`)).json.error).toBe("published");
// Its page: info, revisions (files sorted), hosting.
const page = (await call(undefined, "GET", `/api/v1/products/${id}`)).json;
expect(page.product).toMatchObject({ name: "Blue Coat", rating: "general", latest: 1, creator: "maker" });
expect(page.revisions[0].files.map((f: any) => f.name)).toEqual(["index.xml", "tex.png"]);
expect(page.revisions[0].catalogs).toEqual(["Maker's Shelf"]);
// Descriptions are cleaned by the allowlist.
const edited = await call(maker, "PATCH", `/api/v1/products/${id}`, {
description: `<p style="color:red;position:fixed">Warm <b>coat</b><script>alert(1)</script><a href="javascript:x">x</a><a href="https://example.test" onclick="x()">site</a><img src="http://x/a.png"><img src="relative.png"><img src="https://img.test/a.png"></p>`,
});
expect(edited.status).toBe(200);
expect(edited.json.product.description).toBe(
`<p style="color:red">Warm <b>coat</b><a rel="nofollow ugc noopener noreferrer" target="_blank">x</a><a href="https://example.test" rel="nofollow ugc noopener noreferrer" target="_blank">site</a><img src="https://img.test/a.png" /></p>`,
);
expect((await call(kid, "PATCH", `/api/v1/products/${id}`, { name: "Mine now" })).status).toBe(403);
// A new revision needs notes and different files.
const up2 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).json.token;
expect((await revision(up2)).status).toBe(400);
expect((await revision(up2, { notes: "same" })).status).toBe(409);
const r2 = await revision(up2, { notes: "Brighter", files: [file("index.xml", 1), file("tex.png", 3)] });
expect(r2.json).toEqual({ product: id, revision: 2, draft: false });
// Someone else's upload token can't add to it.
expect((await call(kid, "POST", "/api/v1/products/upload", { catalog: catalogID, product: id })).status).toBe(403);
// The library: get it, the proxy resolves it to the latest, or the revision the person stays on.
expect((await call(kid, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
expect((await call(kid, "GET", "/api/v1/library")).json.library.map((l: any) => [l.product.id, l.product.creator])).toEqual([[id, "maker"]]);
let files2 = (await call(kid, "GET", `/api/v1/products/${id}/files`)).json;
expect(files2).toMatchObject({ product: id, revision: 2, parent: 500, name: "Blue Coat", creator: "maker", catalogs: ["http://localhost:4102"] });
expect((await call(kid, "PATCH", `/api/v1/library/${id}`, { stayOn: 1 })).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(1);
// Retracting revision 1 moves those staying on it to the nearest one that isn't.
await call(maker, "POST", `/api/v1/products/${id}/revisions/1/retract`);
expect((await call(kid, "GET", `/api/v1/products/${id}/files`)).json.revision).toBe(2);
// The revision token is the hub's signature on the files, checkable with its public keys.
const keys = createLocalJWKSet((await call(undefined, "GET", "/.well-known/t5de-keys.json")).json);
const { payload } = await jwtVerify(files2.token, keys, { issuer: apiURL });
expect(payload).toMatchObject({ typ: "t5de-revision", product: id, revision: 2, rating: "general", parent: 500 });
// Access tokens say whether the person is adult.
const access = async (p: Person) => (await jwtVerify((await call(p, "POST", "/api/v1/access")).json.token, keys)).payload;
expect(await access(maker)).toMatchObject({ typ: "t5de-access", adult: true });
expect(await access(kid)).toMatchObject({ typ: "t5de-access", adult: false });
// Adult products are only for adults.
const up3 = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const adultID = (await revision(up3, { rating: "adult", name: "Lace" })).json.product;
await call(maker, "POST", `/api/v1/products/${adultID}/publish`);
expect((await call(maker, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(kid, "GET", `/api/v1/products/${adultID}`)).status).toBe(404);
// Its creator and moderators see it without an adult account; getting or wearing it still needs one.
expect((await call(admin, "GET", `/api/v1/products/${adultID}`)).status).toBe(200);
expect((await call(admin, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
expect((await call(admin, "GET", `/api/v1/products/${adultID}/files`)).status).toBe(404);
expect((await call(kid, "PUT", `/api/v1/library/${adultID}`)).status).toBe(404);
expect((await call(undefined, "GET", "/api/v1/products")).json.products.map((p: any) => p.name)).toEqual(["Blue Coat"]);
expect((await call(maker, "GET", "/api/v1/products?q=lac")).json.products.map((p: any) => p.name)).toEqual(["Lace"]);
// Its files are rated adult, so it can't be lowered (a revision rated general can be reviewed: cases.test.ts).
expect((await call(maker, "PATCH", `/api/v1/products/${adultID}`, { rating: "general" })).json.error).toBe("files_adult");
expect((await call(kid, "DELETE", `/api/v1/library/${id}`)).status).toBe(204);
expect((await call(kid, "GET", "/api/v1/library")).json.library).toEqual([]);
});
test("IMVU product info: parsing and image addresses", () => {
expect(imvuImage("https://userimages-akm.imvu.com/productdata/a.png")).toBe("https://userimages-akm.imvu.com/productdata/a.png");
expect(imvuImage("//userimages-akm.imvu.com/a.png")).toBe("https://userimages-akm.imvu.com/a.png");
for (const bad of ["http://userimages-akm.imvu.com/a.png", "https://imvu.com.evil.test/a.png", "https://evilimvu.com/a.png", "https://[email protected]/a.png", "javascript:alert(1)", 5, null])
expect(imvuImage(bad)).toBeNull();
const answer = {
denormalized: {
"https://api.imvu.com/product/product-9": {
data: { product_image: "https://x.imvu.com/i.png", gender: "female", category_path: [{ id: 106, name: "Clothing" }, { id: "x", name: "Bad" }, { id: 716, name: " Coats & Jackets " }] },
},
},
};
expect(parseProduct(answer, 9)).toEqual({
image: "https://x.imvu.com/i.png",
categories: [{ id: 106, name: "Clothing" }, { id: 716, name: "Coats & Jackets" }],
gender: "female",
});
expect(parseProduct(answer, 10)).toBeNull();
});
test("derived products carry their IMVU parent's image and categories", async () => {
const { catalogID, auth } = shared;
const dresser = await person("dresser", { id: 601, ageVerified: false });
const coat = {
product_id: 7001,
product_name: "Coat",
rating: "GA",
product_image: "https://userimages-akm.imvu.com/productdata/coat.png",
gender: "female",
category_path: [
{ id: 106, name: "Clothing" },
{ id: 40, name: "Clothing for Female" },
{ id: 128, name: "Tops" },
{ id: 716, name: "Coats & Jackets" },
],
};
imvuProducts.set(7001, coat);
imvuProducts.set(7002, { ...coat, product_image: "https://elsewhere.test/hat.png", category_path: [{ id: 75, name: "Hair" }] });
imvuProducts.set(7003, "down");
const upload = async (name: string, parent: number) => {
const token = (await call(dresser, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("index.xml", parent || 9)], rating: "general", parent, name, notes: "" }, auth);
expect(r.status).toBe(201);
await call(dresser, "POST", `/api/v1/products/${r.json.product}/publish`);
return r.json.product as string;
};
const coatID = await upload("Dresser Coat", 7001);
const hatID = await upload("Dresser Hat", 7002);
const downID = await upload("Dresser Down", 7003);
const plainID = await upload("Dresser Plain", 0);
const missingID = await upload("Dresser Missing", 7999);
const page = async (id: string) => (await call(undefined, "GET", `/api/v1/products/${id}`)).json.product;
// Looked up in the background after registering.
for (let i = 0; i < 100 && (await page(coatID)).image === null; i++) await Bun.sleep(20);
expect(await page(coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
for (let i = 0; i < 100 && (await page(hatID)).categories.length === 0; i++) await Bun.sleep(20);
expect(await page(hatID)).toMatchObject({ parent: 7002, image: null, categories: [{ id: 75, name: "Hair" }] });
expect(await page(plainID)).toMatchObject({ parent: 0, image: null, categories: [] });
expect(await page(downID)).toMatchObject({ parent: 7003, image: null, categories: [] });
// Lists carry the same fields; category filters by any ID in the path.
const list = async (qs: string) => (await call(undefined, "GET", `/api/v1/products?creator=dresser${qs}`)).json.products;
const names = async (qs: string) => (await list(qs)).map((p: any) => p.name).sort();
expect((await list("")).find((p: any) => p.id === coatID)).toMatchObject({ parent: 7001, image: coat.product_image, categories: coat.category_path });
expect(await names("&category=128")).toEqual(["Dresser Coat"]);
expect(await names("&category=106")).toEqual(["Dresser Coat"]);
expect(await names("&category=75")).toEqual(["Dresser Hat"]);
expect(await names("&category=1")).toEqual([]);
expect(await names("&limit=2")).toHaveLength(2);
expect((await call(undefined, "GET", "/api/v1/products?limit=201")).status).toBe(400);
expect((await call(undefined, "GET", "/api/v1/products?category=x")).status).toBe(400);
// IMVU was down for one: the periodic pass fills it in once IMVU answers; one IMVU doesn't have stays empty.
imvuProducts.set(7003, { ...coat, category_path: [{ id: 3114, name: "Body" }] });
await hub.products.fillParentInfo(50);
expect(await page(downID)).toMatchObject({ image: coat.product_image, categories: [{ id: 3114, name: "Body" }] });
expect(await page(missingID)).toMatchObject({ parent: 7999, image: null, categories: [] });
expect(await hub.products.fillParentInfo(50)).toBe(0);
// A product can be filed under another category the hub has seen, in its parent's top-level category: a jacket
// made from a plain top goes under Coats & Jackets, never under Hair or Body (another kind of product).
imvuProducts.set(7004, { ...coat, category_path: coat.category_path.slice(0, 3) });
const jacketID = await upload("Dresser Jacket", 7004);
for (let i = 0; i < 100 && (await page(jacketID)).categories.length === 0; i++) await Bun.sleep(20);
const { choices, parent } = (await call(dresser, "GET", `/api/v1/products/${jacketID}/categories`)).json;
expect(parent.map((c: any) => c.id)).toEqual([106, 40, 128]);
expect(choices.map((c: any) => c.at(-1).id).sort()).toEqual([106, 128, 40, 716]);
expect((await call(undefined, "GET", `/api/v1/products/${jacketID}/categories`)).status).toBe(401);
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 75 })).json.error).toBe("invalid");
const filed = await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: 716 });
expect(filed.json.fields).toEqual(["category"]);
expect(filed.json.product.categories).toEqual(coat.category_path);
expect(filed.json.product.category).toEqual(coat.category_path);
expect(await names("&category=716")).toEqual(["Dresser Coat", "Dresser Jacket"]);
// Back to its parent's: the history keeps paths, and undoing puts the chosen one back.
expect((await call(dresser, "PATCH", `/api/v1/products/${jacketID}`, { category: null })).json.product.categories).toEqual(parent);
expect(await names("&category=716")).toEqual(["Dresser Coat"]);
const [cleared] = (await call(dresser, "GET", `/api/v1/products/${jacketID}/history`)).json.entries;
expect(cleared.changes.category).toEqual({ from: coat.category_path, to: null });
expect((await call(dresser, "POST", `/api/v1/products/${jacketID}/history/${cleared.id}/revert`, {})).json.product.categories).toEqual(coat.category_path);
// The library shows them too.
expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200);
expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image });
});
test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () => {
const user = (thumbnail_url: unknown) =>
parse({ denormalized: { "https://api.imvu.com/user/user-5": { data: { legacy_cid: 5, username: "Av", thumbnail_url } } } });
expect(user("https://webasset-akm.imvu.com/resized_image/p.png").picture).toBe("https://webasset-akm.imvu.com/resized_image/p.png");
expect(user("https://evil.example/p.png").picture).toBeNull();
expect(user(undefined).picture).toBeNull();
});
test("creators delete products (those who have them keep them); moderation removes them everywhere", async () => {
const { admin, catalogID, auth } = shared;
const maker = await person("deleter", { id: 511, ageVerified: true });
const owner = await person("keeper", { id: 512, ageVerified: true });
const late = await person("latecomer", { id: 513, ageVerified: true });
const make = async (name: string) => {
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const r = await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file(`${name}.xml`, 1)], rating: "general", parent: 500, name, notes: "" }, auth);
await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`);
return r.json.product as string;
};
const listed = async () => (await call(undefined, "GET", "/api/v1/products?q=gone")).json.products.map((p: any) => p.name);
const id = await make("Gone Hat");
expect((await call(owner, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
// Only its creator deletes it; then it's not listed and nobody new adds it, but its page, the library entry and
// the files stay for those who have it.
expect((await call(owner, "DELETE", `/api/v1/products/${id}`)).status).toBe(403);
expect((await call(maker, "DELETE", `/api/v1/products/${id}`)).status).toBe(204);
expect(await listed()).toEqual([]);
expect((await call(undefined, "GET", `/api/v1/products/${id}`)).json.product.deleted).toBe(true);
expect((await call(late, "PUT", `/api/v1/library/${id}`)).json.error).toBe("deleted");
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
expect((await call(owner, "GET", `/api/v1/products/${id}/files`)).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Back" })).json.error).toBe("deleted");
// Moderation removes one: no page, no files, out of libraries.
const bad = await make("Gone Shoes");
expect((await call(owner, "PUT", `/api/v1/library/${bad}`)).status).toBe(200);
expect((await call(maker, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(403);
expect((await call(admin, "POST", `/api/v1/products/${bad}/remove`, { reason: "Stolen" })).status).toBe(200);
expect((await call(undefined, "GET", `/api/v1/products/${bad}`)).status).toBe(410);
expect((await call(owner, "GET", `/api/v1/products/${bad}/files`)).status).toBe(410);
expect((await call(owner, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([id]);
});
test("product info: moderators edit with reasons and lock fields; history and reverts; unlisted and tags", async () => {
const { admin, catalogID, auth } = shared;
const maker = await person("tailor", { id: 521, ageVerified: true });
const other = await person("browser", { id: 522, ageVerified: true });
const mod = await person("warden", { id: 523, ageVerified: true });
const modID = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${modID}/role`, { role: "moderator" })).status).toBe(200);
const token = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const id = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: token, files: [file("vest.xml", 7)], rating: "general", parent: 0, name: "Vest", notes: "" }, auth)).json.product as string;
// The creator sets it up while it's a draft: tags (cleaned), unlisted; then publishes.
const set = await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: [" Formal ", "formal", "Wool Blend"], listed: false });
expect(set.json.fields).toEqual(["tags", "listed"]);
expect(set.json.product).toMatchObject({ tags: ["formal", "wool blend"], listed: false });
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { tags: ["<b>"] })).json.error).toBe("invalid");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: false })).json.fields).toEqual([]);
await call(maker, "POST", `/api/v1/products/${id}/publish`);
// Unlisted: not in lists or search for others (its creator still sees it); its link works, and people can get it.
const names = async (p: Person | undefined, qs: string) => (await call(p, "GET", `/api/v1/products?${qs}`)).json.products.map((x: any) => x.name);
expect(await names(other, "creator=tailor")).toEqual([]);
expect(await names(maker, "creator=tailor")).toEqual(["Vest"]);
expect((await call(other, "GET", `/api/v1/products/${id}`)).status).toBe(200);
expect((await call(other, "PUT", `/api/v1/library/${id}`)).status).toBe(200);
await call(maker, "PATCH", `/api/v1/products/${id}`, { listed: true });
expect(await names(other, "tag=formal")).toEqual(["Vest"]);
expect(await names(other, "tag=Wool%20Blend")).toEqual(["Vest"]);
expect(await names(other, "tag=denim")).toEqual([]);
expect(await names(other, `creator=tailor&catalog=${catalogID}`)).toEqual(["Vest"]);
// Others can't edit; a moderator can, with a reason the creator sees.
expect((await call(other, "PATCH", `/api/v1/products/${id}`, { name: "Mine" })).json).toMatchObject({
error: "forbidden",
missing: { permission: "product:edit" },
});
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest" })).json.error).toBe("reason_needed");
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { name: "Wool Vest", reason: "Misleading name" })).status).toBe(200);
// Locks: the creator can't change a locked field; moderators can. The page lists what the person may do.
expect((await call(maker, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "x" })).status).toBe(403);
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "" })).json.error).toBe("reason_needed");
expect((await call(mod, "PUT", `/api/v1/products/${id}/locks`, { fields: ["name"], reason: "Keeps renaming it" })).json.product.locked).toEqual(["name"]);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { name: "Vest!" })).json.error).toBe("locked");
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { description: "<p>Warm</p>" })).status).toBe(200);
const page = (await call(maker, "GET", `/api/v1/products/${id}`)).json;
expect(page.product.locked).toEqual(["name"]);
expect(page.permissions).toContain("product:edit");
expect(page.permissions).not.toContain("product:edit-locked");
expect((await call(mod, "GET", `/api/v1/products/${id}`)).json.permissions).toContain("product:edit-locked");
// Ratings: anyone with product:edit raises it; lowering is for moderators, and not below its files' rating.
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "adult" })).status).toBe(200);
expect((await call(maker, "PATCH", `/api/v1/products/${id}`, { rating: "general" })).json).toMatchObject({
product: { rating: "adult" },
review: expect.any(Number),
});
expect((await call(mod, "PATCH", `/api/v1/products/${id}`, { rating: "general", reason: "Not adult" })).status).toBe(200);
// The history: every change, newest first, with who and why. Only the creator and moderators read it.
expect((await call(other, "GET", `/api/v1/products/${id}/history`)).status).toBe(403);
const history = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries;
expect(history.map((e: any) => [e.kind, Object.keys(e.changes).join(), e.by, e.byCreator])).toEqual([
["edit", "rating", "warden", false],
["edit", "rating", "tailor", true],
["edit", "description", "tailor", true],
["lock", "locked", "warden", false],
["edit", "name", "warden", false],
["edit", "listed", "tailor", true],
["publish", "status", "tailor", true],
["edit", "tags,listed", "tailor", true],
]);
expect(history[4]).toMatchObject({ reason: "Misleading name", changes: { name: { from: "Vest", to: "Wool Vest" } } });
// Reverting puts back what an entry changed, by the same rules: the name is locked for the creator.
const rename = history[4].id;
expect((await call(maker, "POST", `/api/v1/products/${id}/history/${rename}/revert`, {})).json.error).toBe("locked");
const reverted = await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "My mistake" });
expect(reverted.json.product.name).toBe("Vest");
expect((await call(mod, "POST", `/api/v1/products/${id}/history/${rename}/revert`, { reason: "Again" })).json.error).toBe("unchanged");
const latest = (await call(maker, "GET", `/api/v1/products/${id}/history`)).json.entries[0];
expect(latest).toMatchObject({ kind: "revert", revertOf: rename, reason: "My mistake" });
// Moderators' edits are in the creator's audit log, tagged with the product.
const audit = (await call(maker, "GET", `/api/v1/console/audit?product=${id}`)).json.entries;
expect(audit.map((e: any) => e.action)).toContain("product.lock");
// The console's product list: the creator's own (drafts too); moderators everyone's, filtered.
const draftToken = (await call(maker, "POST", "/api/v1/products/upload", { catalog: catalogID })).json.token;
const draft = (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: draftToken, files: [file("cap.xml", 8)], rating: "general", parent: 0, name: "Cap", notes: "" }, auth)).json.product as string;
const mine = (await call(maker, "GET", "/api/v1/console/products")).json;
expect(mine.products.map((p: any) => [p.name, p.status, p.libraries])).toEqual([["Cap", "draft", 0], ["Vest", "published", 1]]);
expect((await call(maker, "GET", "/api/v1/console")).json.todo.drafts).toBe(1);
expect((await call(other, "GET", "/api/v1/console/products")).status).toBe(403);
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=locked")).json.products.map((p: any) => p.id)).toEqual([id]);
expect((await call(mod, "GET", "/api/v1/console/products?all=1&status=draft&q=tailor")).json.products.map((p: any) => p.id)).toEqual([draft]);
// Drafts never published go after 30 days, and their catalogs are told to delete the files.
expect(await hub.products.purgeDrafts(new Date(Date.now() + 29 * 24 * 60 * 60 * 1000))).toBe(0);
expect(await hub.products.purgeDrafts(new Date(Date.now() + 31 * 24 * 60 * 60 * 1000))).toBeGreaterThanOrEqual(1);
expect((await call(maker, "GET", `/api/v1/products/${draft}`)).status).toBe(404);
expect((await call(maker, "GET", `/api/v1/products/${id}`)).status).toBe(200);
const changes = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, auth)).json.changes;
expect(changes.find((c: any) => c.product === draft)).toMatchObject({ type: "delete", revision: 1, files: [file("cap.xml", 8)] });
});
+104
View File
@@ -0,0 +1,104 @@
// Registration: open (new accounts can use the hub at once) or closed (they sign in, and wait for a moderator's
// approval); admins switch it in the console, and the environment gives the default.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient } from "../src/imvu";
import { Settings } from "../src/settings";
const webURL = "http://web.test";
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
// Closed by default (as on staging), until an admin changes it.
const settings = new Settings(db, { registration: "closed" });
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false, settings });
const imvu = new ImvuClient("http://imvu.test", (async () => Response.json({ denormalized: {} })) as unknown as typeof fetch);
const hub = createApp({ db, auth, imvu, webURL, settings });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
async function call(cookie: { v: string }, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(cookie.v ? { Cookie: cookie.v } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const set = res.headers.getSetCookie();
if (set.length) cookie.v = set.map((c) => c.split(";")[0]).join("; ");
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined };
}
async function signUp(name: string) {
const cookie = { v: "" };
expect((await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name })).status).toBe(200);
return cookie;
}
test("closed: new accounts wait for approval; the first one runs the hub", async () => {
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "closed" });
const admin = await signUp("ada");
expect((await call(admin, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "admin", approved: true });
expect((await call(admin, "GET", "/api/v1/account")).status).toBe(200);
const bo = await signUp("bo_");
// Signed in, but nothing else.
expect((await call(bo, "GET", "/api/v1/me")).json.person).toMatchObject({ role: "user", approved: false });
expect(await call(bo, "GET", "/api/v1/account")).toMatchObject({ status: 403, json: { error: "awaiting_approval" } });
expect((await call(bo, "POST", "/api/v1/account/links", { username: "someone" })).status).toBe(403);
expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403);
// Moderators see who's waiting, and approve them.
expect((await call(admin, "GET", "/api/v1/console")).json.moderation.counts.waiting).toBe(1);
const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]);
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true);
expect((await call(bo, "GET", "/api/v1/account")).status).toBe(200);
expect((await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people).toEqual([]);
// An approved account can't be declined.
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/decline`)).status).toBe(400);
});
test("declining deletes the account", async () => {
const admin = { v: "" };
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
const cy = await signUp("cyd");
const [p] = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
expect(p.username).toBe("cyd");
expect((await call(admin, "POST", `/api/v1/console/people/${p.id}/decline`)).status).toBe(200);
expect((await call(cy, "GET", "/api/v1/me")).status).toBe(401);
expect((await call({ v: "" }, "POST", "/api/auth/sign-in/username", { username: "cyd", password: "a long password" })).status).toBe(401);
});
test("only admins open or close registration", async () => {
const admin = { v: "" };
await call(admin, "POST", "/api/auth/sign-in/username", { username: "ada", password: "a long password" });
const mod = await signUp("dot");
const dot = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people[0];
await call(admin, "POST", `/api/v1/console/people/${dot.id}/approve`);
await call(admin, "POST", `/api/v1/console/people/${dot.id}/role`, { role: "moderator" });
expect((await call(mod, "GET", "/api/v1/console/settings")).json).toEqual({ registration: "closed" });
expect((await call(mod, "PUT", "/api/v1/console/settings", { registration: "open" })).status).toBe(403);
expect((await call(admin, "PUT", "/api/v1/console/settings", { registration: "open" })).json).toEqual({ registration: "open" });
expect((await call({ v: "" }, "GET", "/api/v1/hub")).json).toEqual({ registration: "open" });
// Open: new accounts can use the hub at once.
const eve = await signUp("eve");
expect((await call(eve, "GET", "/api/v1/account")).status).toBe(200);
const audit = (await call(admin, "GET", "/api/v1/console")).json.audit.map((e: any) => e.action);
expect(audit).toContain("hub.registration");
});
+192
View File
@@ -0,0 +1,192 @@
// The room channel (T5DE users in the same IMVU chat see each other's community products) and the rooms list.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
const rooms = new Map<string, Record<string, unknown>>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const room = url.pathname.match(/^\/room\/room-(\d+-\d+)$/);
if (room) {
const r = rooms.get(room[1]!);
return Response.json({
denormalized: r ? { [`https://api.imvu.com/room/room-${room[1]}`]: { data: r } } : {},
});
}
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
const imvuRoom = (owner: number, n: number, over: Record<string, unknown> = {}) =>
rooms.set(`${owner}-${n}`, {
name: `Room ${n}`,
description: "<b>hi</b>",
type: "listed",
privacy: "public",
image_url: "//webasset-akm.imvu.com/room.gif",
occupancy: 2,
capacity: 10,
content_rating: "GA",
is_ap: false,
customers_id: owner,
is_expired: false,
...over,
});
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL: "http://api.test", webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; token: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}) },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
return { status: res.status, json: text ? JSON.parse(text) : undefined, res };
}
/** Signs a person up with one linked IMVU account; token is a session token for their proxy. */
async function person(name: string, imvu: { id: number; ageVerified?: boolean }): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
const p = {
cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "),
token: res.headers.get("set-auth-token") ?? "",
};
expect(p.token).not.toBe("");
accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false, picture: null });
const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` });
accounts.get(imvu.id)!.tagline = started.json.challenge.code;
expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200);
return p;
}
/** A proxy's live connection: everything the hub sent it, and the latest "chat" message per chat. */
async function proxy(p: Person, imvuId: number) {
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: { Authorization: `Bearer ${p.token}` } } as any);
const got: any[] = [];
ws.onmessage = (e) => got.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
const send = (m: object) => ws.send(JSON.stringify(m));
send({ type: "session", imvu_id: imvuId });
return {
ws,
got,
send,
chat: (chat: string) => got.filter((m) => m.type === "chat" && m.chat === chat).at(-1),
};
}
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200 && !ok(); i++) await Bun.sleep(10);
expect(ok()).toBe(true);
}
test("T5DE users in the same chat see each other's community products; adult ones only if adult", async () => {
const fiona = await person("fiona", { id: 101, ageVerified: true });
const gus = await person("gus", { id: 102 });
const f = await proxy(fiona, 101);
const g = await proxy(gus, 102);
f.send({ type: "outfit", items: [{ id: "coat", name: "Coat" }, { id: "lace", name: "Lace", adult: true }] });
g.send({ type: "outfit", items: [{ id: "hat", name: "Hat" }] });
f.send({ type: "join", chat: "555", room: "101-1" });
g.send({ type: "join", chat: "555", room: "101-1" });
await waitFor(() => g.chat("555")?.avatars.length === 1 && f.chat("555")?.avatars.length === 1);
expect(g.chat("555").avatars).toEqual([{ imvu_id: 101, items: [{ id: "coat", name: "Coat", adult: false }] }]);
expect(f.chat("555").avatars).toEqual([{ imvu_id: 102, items: [{ id: "hat", name: "Hat", adult: false }] }]);
// Outfit changes reach the others in the chat; a chat they're not in hears nothing.
g.send({ type: "join", chat: "777" });
g.send({ type: "outfit", items: [] });
await waitFor(() => f.chat("555")?.avatars[0]?.items.length === 0);
expect(f.chat("777")).toBeUndefined();
// Leaving (or disconnecting) takes them off the others' list.
g.send({ type: "leave", chat: "555" });
await waitFor(() => f.chat("555")?.avatars.length === 0);
g.send({ type: "join", chat: "555" });
await waitFor(() => f.chat("555")?.avatars.length === 1);
g.ws.close();
await waitFor(() => f.chat("555")?.avatars.length === 0);
// Nonsense is refused.
f.send({ type: "join", chat: "../x" });
await waitFor(() => f.got.some((m) => m.message === "bad chat"));
f.ws.close();
});
test("rooms are listed when public and listed, and their owner chose to list them", async () => {
const hana = await person("hana", { id: 201, ageVerified: true });
const ivan = await person("ivan", { id: 202 });
imvuRoom(201, 1);
imvuRoom(201, 2, { type: "unlisted" });
imvuRoom(201, 3, { content_rating: "AP", is_ap: true });
imvuRoom(201, 4, { privacy: "closed" });
const h = await proxy(hana, 201);
for (const [chat, room] of [["1", "201-1"], ["2", "201-2"], ["3", "201-3"], ["4", "201-4"]]) h.send({ type: "join", chat, room });
// A room whose owner isn't on T5DE is never stored.
imvuRoom(999, 1);
h.send({ type: "join", chat: "9", room: "999-1" });
const names = async (p?: Person) => ((await call(p, "GET", "/api/v1/rooms")).json.rooms as any[]).map((r) => r.name);
// The hub asks IMVU about each (spaced out); until Hana chooses to list them, none are listed.
for (let i = 0; i < 300 && (await db.select().from(schema.room)).length < 4; i++) await Bun.sleep(10);
expect((await db.select().from(schema.room)).map((r) => r.instanceId).sort()).toEqual(["201-1", "201-2", "201-3", "201-4"]);
expect(await names()).toEqual([]);
await call(hana, "PATCH", "/api/v1/account/links/201", { listRooms: true });
expect(await names()).toEqual(["Room 1"]);
expect(await names(ivan)).toEqual(["Room 1"]); // Ivan isn't adult
expect((await names(hana)).sort()).toEqual(["Room 1", "Room 3"]);
const [listed] = (await call(undefined, "GET", "/api/v1/rooms")).json.rooms;
expect(listed).toMatchObject({
instanceId: "201-1",
description: "<b>hi</b>", // the website shows it as text
imageURL: "https://webasset-akm.imvu.com/room.gif",
owner: { imvuId: 201, username: "hanaAv" },
url: "https://go.imvu.com/chat/room-201-1",
t5de: [], // Hana is there, but doesn't show she's online
});
await call(hana, "PATCH", "/api/v1/account/links/201", { showPresence: true });
expect((await call(undefined, "GET", "/api/v1/rooms")).json.rooms[0].t5de).toEqual([{ imvuId: 201, username: "hanaAv", badge: false }]);
// Unlinking forgets the account's rooms.
await call(hana, "DELETE", "/api/v1/account/links/201");
expect(await names(hana)).toEqual([]);
h.ws.close();
});
+213
View File
@@ -0,0 +1,213 @@
// API tokens (docs/console.md): made from a session, limited to their grants and to what their owner holds now,
// usable only on routes that check permissions, audited by name, revocable.
import { afterAll, beforeAll, expect, test } from "bun:test";
import { eq, openDatabase, schema, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const apiURL = "http://api.test";
const accounts = new Map<number, ImvuUser>();
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 });
const u = [...accounts.values()].find(
(u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(),
);
return Response.json({
denormalized: u
? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } }
: {},
});
}) as typeof fetch;
// Catalog addresses that answer /health (for address changes).
const healthy = new Set<string>(["https://moved.example.test"]);
let db: Database;
let close: () => Promise<void>;
let base: string;
let stop: () => void;
beforeAll(async () => {
({ db, close } = await openDatabase("memory"));
const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false });
const hub = createApp({
db,
auth,
imvu: new ImvuClient("http://imvu.test", fakeIMVU),
apiURL,
webURL,
catalogHealthy: async (url) => healthy.has(url),
});
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
const server = hub.app.server as unknown as { port: number; stop: () => void };
stop = () => server.stop();
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
stop();
await close();
});
type Person = { cookie: string; name: string };
async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
const res = await fetch(base + path, {
method,
headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async function person(name: string): Promise<Person> {
const res = await fetch(base + "/api/auth/sign-up/email", {
method: "POST",
headers: { Origin: webURL, "Content-Type": "application/json" },
body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }),
});
return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name };
}
const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n });
/** A person's catalog, registered and approved; its key's Authorization header. */
async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") {
const reg = await call(owner, "POST", "/api/v1/catalogs", {
name,
url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`,
contact: "[email protected]",
visibility: "public",
membership,
});
expect(reg.status).toBe(201);
const id = reg.json.catalog.id as string;
expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200);
return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } };
}
/** A product uploaded by a person to a catalog; its ID. */
async function upload(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, n: number, rating = "general") {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
expect(tok.status).toBe(200);
const r = await call(
undefined,
"POST",
"/api/v1/catalog/revisions",
{ uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" },
c.auth,
);
expect(r.status).toBe(201);
expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200);
return r.json.product as string;
}
/** A revision with these files, by a person, to a catalog: the answer. */
async function uploadFiles(maker: Person, c: { id: string; auth: Record<string, string> }, name: string, files: unknown[]) {
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
return call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files, rating: "general", parent: 0, name, notes: "" }, c.auth);
}
let admin: Person;
let mod: Person;
test("the first account is the admin; a moderator", async () => {
admin = await person("admin1");
mod = await person("warden");
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200);
});
const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
const at = (type: "product" | "catalog", id: string) => ({ type, id });
test("tokens: made from a session, limited to their grants, usable only where permissions are checked", async () => {
const owner = await person("shelfowner");
const maker = await person("maker");
const c = await catalogOf(owner, admin, "Token Shelf");
const hat = await upload(maker, c, "Hat", 1);
const scarf = await upload(maker, c, "Scarf", 2);
const theirs = await upload(owner, c, "Owner's Coat", 3);
// Only what the person holds now.
const make = (p: Person, grants: unknown[], name = "script", days = 30) => call(p, "POST", "/api/v1/account/tokens", { name, days, grants });
expect((await make(maker, [{ permission: "product:edit", scope: at("product", theirs) }])).json.error).toBe("not_held");
expect((await make(maker, [{ permission: "product:remove", scope: { type: "hub" } }])).json.error).toBe("not_held");
expect((await make(maker, [{ permission: "no:such", scope: at("product", hat) }])).status).toBe(400);
expect((await make(maker, [])).status).toBe(400);
expect((await make(maker, [{ permission: "product:edit", scope: at("product", hat) }], "x", 400)).status).toBe(400);
expect((await make(undefined as unknown as Person, [{ permission: "product:edit", scope: at("product", hat) }])).status).toBe(401);
const made = await make(maker, [
{ permission: "product:edit", scope: at("product", hat) },
{ permission: "revision:upload", scope: at("product", hat) },
{ permission: "product:edit", scope: at("product", hat) },
], "Hat updater");
expect(made.status).toBe(201);
const tok = made.json.token as string;
expect(tok).toMatch(/^t5de_pat_[A-Za-z0-9_-]{43}$/);
expect(made.json.info).toMatchObject({ name: "Hat updater", status: "active", lastUsedAt: null });
expect(made.json.info.grants).toEqual([
{ permission: "product:edit", scope: at("product", hat), name: "Hat" },
{ permission: "revision:upload", scope: at("product", hat), name: "Hat" },
]);
expect(JSON.stringify(made.json.info)).not.toContain(tok);
// What it grants, there.
const asToken = (method: string, path: string, body?: unknown) => call(undefined, method, path, body, bearer(tok));
expect((await asToken("PATCH", `/api/v1/products/${hat}`, { description: "By script" })).status).toBe(200);
const other = await asToken("PATCH", `/api/v1/products/${scarf}`, { description: "Nope" });
expect(other.status).toBe(403);
expect(other.json.missing).toEqual({ permission: "product:edit", scope: { product: scarf } });
expect((await asToken("POST", `/api/v1/products/${hat}/publish`)).status).toBe(403);
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id, product: hat })).status).toBe(200);
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id, product: scarf })).status).toBe(403);
expect((await asToken("POST", "/api/v1/products/upload", { catalog: c.id })).json.error).toBe("token_not_allowed");
// Nothing else: session-only routes refuse it, and it can't make tokens; elsewhere it's signed out.
expect((await asToken("GET", "/api/v1/me")).json.error).toBe("token_not_allowed");
expect((await asToken("POST", "/api/v1/account/tokens", { name: "more", days: 1, grants: [{ permission: "product:edit", scope: at("product", hat) }] })).json.error).toBe("token_not_allowed");
expect((await asToken("DELETE", `/api/v1/products/${hat}`)).json.error).toBe("token_not_allowed");
expect((await asToken("POST", "/api/v1/cases", { kind: "report", about: { product: theirs }, reason: "x" })).json.error).toBe("token_not_allowed");
// Audited as the person, by token; listed with its last use.
const entry = (await call(maker, "GET", `/api/v1/console/audit?action=product.edit&product=${hat}`)).json.entries[0];
expect(entry).toMatchObject({ actor: "maker", detail: { token: "Hat updater" } });
const listed = (await call(maker, "GET", "/api/v1/account/tokens")).json.tokens;
expect(listed).toHaveLength(1);
expect(listed[0].lastUsedAt).not.toBeNull();
expect((await call(owner, "GET", "/api/v1/account/tokens")).json.tokens).toEqual([]);
// Revoked or expired: refused.
expect((await call(owner, "DELETE", `/api/v1/account/tokens/${listed[0].id}`)).status).toBe(404);
expect((await call(maker, "DELETE", `/api/v1/account/tokens/${listed[0].id}`)).status).toBe(204);
expect((await asToken("PATCH", `/api/v1/products/${hat}`, { description: "Again" })).json.error).toBe("bad_token");
expect((await call(maker, "GET", "/api/v1/account/tokens")).json.tokens[0].status).toBe("revoked");
expect((await call(undefined, "PATCH", `/api/v1/products/${hat}`, { description: "x" }, bearer("t5de_pat_nope"))).json.error).toBe("bad_token");
const short = (await make(maker, [{ permission: "product:edit", scope: at("product", hat) }], "short", 1)).json;
await db.update(schema.apiToken).set({ expiresAt: new Date(Date.now() - 1000) }).where(eq(schema.apiToken.id, short.info.id));
expect((await call(undefined, "PATCH", `/api/v1/products/${hat}`, { description: "x" }, bearer(short.token))).json.error).toBe("bad_token");
});
test("tokens never have more than their owner holds now", async () => {
const made = await call(mod, "POST", "/api/v1/account/tokens", { name: "blocks", days: 7, grants: [{ permission: "product:remove", scope: { type: "hub" } }] });
expect(made.status).toBe(201);
const tok = made.json.token as string;
expect((await call(undefined, "GET", "/api/v1/blocks", undefined, bearer(tok))).status).toBe(200);
// Its grants don't reach other permissions the moderator holds.
expect((await call(undefined, "GET", `/api/v1/catalogs/${(await call(mod, "GET", "/api/v1/console/catalogs")).json.catalogs[0].id}/team`, undefined, bearer(tok))).status).toBe(403);
const id = (await call(mod, "GET", "/api/v1/me")).json.person.id;
expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "user" })).status).toBe(200);
expect((await call(undefined, "GET", "/api/v1/blocks", undefined, bearer(tok))).status).toBe(403);
});
+46 -2
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { api, unwrap } from "@/lib/api"; import { redirect } from "next/navigation";
import { api, ApiError, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions"; import { attempt, field, type ActionState } from "@/lib/actions";
const done = () => revalidatePath("/account"); const done = () => revalidatePath("/account");
@@ -43,7 +44,7 @@ export async function linkAction(_: ActionState, form: FormData): Promise<Action
export async function saveSettings( export async function saveSettings(
imvu: number, imvu: number,
settings: { showBadge?: boolean; showPresence?: boolean }, settings: { showBadge?: boolean; showPresence?: boolean; listRooms?: boolean },
): Promise<ActionState> { ): Promise<ActionState> {
return attempt(async () => { return attempt(async () => {
unwrap(await api.account.links({ imvu }).patch(settings)); unwrap(await api.account.links({ imvu }).patch(settings));
@@ -58,3 +59,46 @@ export async function signOutComputers(_: ActionState, _form: FormData): Promise
return "Signed out everywhere."; return "Signed out everywhere.";
}); });
} }
/** Starts an age check with a provider that checks the person, and sends them to its page. */
export async function startAgeCheck(_: ActionState, form: FormData): Promise<ActionState> {
let url: string | undefined;
const state = await attempt(async () => {
const started = unwrap(await api.age({ provider: field(form, "provider") }).start.post({ returnURL: "/account?age=back" }));
if (started.kind === "redirect") url = started.url;
done();
});
if (url) redirect(url);
return state;
}
/** What making a token answers: the token itself, shown once. */
export type TokenState = { error?: string; token?: string; name?: string } | null;
/** Makes an API token. `grants` is JSON: [{permission, scope}]. */
export async function createToken(_: TokenState, form: FormData): Promise<TokenState> {
let grants: { permission: string; scope: { type: "hub" } | { type: "catalog" | "product"; id: string } }[];
try {
grants = JSON.parse(field(form, "grants") || "[]");
} catch {
return { error: "Choose what it may do." };
}
if (!Array.isArray(grants) || grants.length === 0) return { error: "Choose what it may do." };
try {
const made = unwrap(await api.account.tokens.post({ name: field(form, "name"), days: Number(field(form, "days")), grants }));
done();
return { token: made.token, name: made.info.name };
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
/** Revokes an API token. */
export async function revokeToken(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
unwrap(await api.account.tokens({ id: field(form, "id") }).delete());
done();
return "Revoked.";
});
}
+68
View File
@@ -0,0 +1,68 @@
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { TimeAgo } from "@/components/time";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { AgeInfo } from "@/lib/api";
import { startAgeCheck } from "./actions";
/** The person's own age check (an ID document, say), which covers all their IMVU accounts. */
export function AgeCard({ age, back }: { age: AgeInfo; back: boolean }) {
const ways = age.providers.filter((p) => p.scope === "person");
if (ways.length === 0) return null;
const now = Date.now();
const verified = age.checks.find(
(c) => c.status === "complete" && c.overEighteen && (!c.expiresAt || new Date(c.expiresAt).getTime() > now),
);
const latest = age.checks[0];
const pending = latest?.status === "pending" ? latest : undefined;
const vendorOf = (id: string) => age.providers.find((p) => p.id === id)?.vendor ?? id;
return (
<Card>
<CardHeader>
<CardTitle>Age Verification</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{verified ? (
<p className="flex flex-wrap items-center gap-2">
<Tag tone="adult">Age verified</Tag>
<span className="text-muted-foreground">
with {vendorOf(verified.provider)} <TimeAgo iso={verified.checkedAt ?? verified.createdAt} />. All your
IMVU accounts count as adult.
</span>
</p>
) : (
<>
{pending ? (
<p>
{back
? "Thanks. Your check is being processed; this page shows the result when it's done."
: "Your check isn't finished yet."}
</p>
) : latest?.status === "complete" && !latest.overEighteen ? (
<p>Your last check found you're under 18.</p>
) : latest?.status === "failed" || latest?.status === "revoked" ? (
<p>Your last check didn&apos;t pass. You can try again.</p>
) : (
<p className="text-muted-foreground">
Verify your age once to count as adult on all your IMVU accounts, not only those IMVU has age verified.
</p>
)}
{ways.map((p) => (
<ActionForm key={p.id} action={startAgeCheck} fields={{ provider: p.id }}>
<div className="space-y-2">
<SubmitButton variant={pending && pending.provider !== p.id ? "outline" : "default"} pendingLabel="Starting…">
{pending?.provider === p.id ? "Continue check" : `Verify with ${p.label}`}
</SubmitButton>
<p className="text-xs text-muted-foreground">
Done by {p.vendor}. T5DE only keeps whether you&apos;re over 18, never your ID, photo or birth date.
</p>
</div>
</ActionForm>
))}
</>
)}
</CardContent>
</Card>
);
}
+6 -1
View File
@@ -27,7 +27,12 @@ export function LinkCard({ link }: { link: Link }) {
</div> </div>
</div> </div>
<Outfit items={link.outfit} /> <Outfit items={link.outfit} />
<LinkSettings imvuId={link.imvuId} showBadge={link.showBadge} showPresence={link.showPresence} /> <LinkSettings
imvuId={link.imvuId}
showBadge={link.showBadge}
showPresence={link.showPresence}
listRooms={link.listRooms}
/>
</div> </div>
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right"> <ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
<div className="flex gap-2 sm:justify-end"> <div className="flex gap-2 sm:justify-end">
+5 -1
View File
@@ -6,7 +6,7 @@ import { Switch } from "@/components/ui/switch";
import type { ActionState } from "@/lib/actions"; import type { ActionState } from "@/lib/actions";
import { saveSettings } from "./actions"; import { saveSettings } from "./actions";
type Settings = { showBadge: boolean; showPresence: boolean }; type Settings = { showBadge: boolean; showPresence: boolean; listRooms: boolean };
export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) { export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) {
const [settings, setOptimistic] = useOptimistic(saved); const [settings, setOptimistic] = useOptimistic(saved);
@@ -29,6 +29,10 @@ export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} /> <Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
Show when I&apos;m online in T5DE Show when I&apos;m online in T5DE
</label> </label>
<label className="flex items-center gap-3 text-sm">
<Switch checked={settings.listRooms} onCheckedChange={change("listRooms")} disabled={pending} />
List my public rooms
</label>
<FormMessage state={state} /> <FormMessage state={state} />
</div> </div>
); );
+16 -5
View File
@@ -8,17 +8,24 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api, getMe, isModerator, unwrap } from "@/lib/api"; import { api, getMe, isModerator, unwrap } from "@/lib/api";
import { linkAccount, signOutComputers } from "./actions"; import { linkAccount, signOutComputers } from "./actions";
import { AgeCard } from "./age-card";
import { ChallengeCard } from "./challenge-card"; import { ChallengeCard } from "./challenge-card";
import { LinkCard } from "./link-card"; import { LinkCard } from "./link-card";
import { TokensCard } from "./tokens-card";
export const metadata: Metadata = { title: "Account" }; export const metadata: Metadata = { title: "Account" };
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) { export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string; age?: string }> }) {
const me = await getMe(); const me = await getMe();
if (!me) redirect("/signin?next=/account"); if (!me) redirect("/signin?next=/account");
const account = unwrap(await api.account.get()); const [account, age, tokens] = await Promise.all([
api.account.get().then(unwrap),
api.age.get().then(unwrap),
api.account.tokens.get().then(unwrap),
]);
const { person, links, challenges, computers } = account; const { person, links, challenges, computers } = account;
const welcome = (await searchParams).welcome !== undefined; const params = await searchParams;
const welcome = params.welcome !== undefined;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -78,12 +85,14 @@ export default async function Account({ searchParams }: { searchParams: Promise<
</p> </p>
)} )}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Other people can&apos;t see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass) Other people can&apos;t see which IMVU accounts are yours. Adult IMVU accounts can see adult community
can see adult community products; each account is checked on its own. products: those IMVU has age verified (or with Access Pass), or all of them once you verify your age below.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<AgeCard age={age} back={params.age === "back"} />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>T5DE on your computers</CardTitle> <CardTitle>T5DE on your computers</CardTitle>
@@ -112,6 +121,8 @@ export default async function Account({ searchParams }: { searchParams: Promise<
)} )}
</CardContent> </CardContent>
</Card> </Card>
<TokensCard data={tokens} />
</div> </div>
); );
} }
+168
View File
@@ -0,0 +1,168 @@
"use client";
// Making an API token: a name, how long it lasts, and what it may do where (some of the person's own permissions).
import { ChevronRightIcon } from "lucide-react";
import { useActionState, useState } from "react";
import { CopyButton } from "@/components/copy-button";
import { ErrorText } from "@/components/forms";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import type { TokenScope } from "@/lib/api";
import { createToken } from "./actions";
const keyOf = (s: TokenScope["scope"]) => (s.type === "hub" ? "hub" : `${s.type}:${s.id}`);
const kindOf = { hub: "Everything", catalog: "Catalog", product: "Product" } as const;
type Row = { scope: string; permissions: string[] };
// Permissions are `resource:action`; they're grouped by resource, in this order (new resources go last, by name).
const groupNames: Record<string, string> = {
product: "Products",
revision: "Revisions",
catalog: "Catalog",
team: "Team",
member: "Members",
hosting: "Hosting",
import: "Imports",
case: "Cases",
audit: "Audit Log",
stats: "Stats",
person: "People",
room: "Rooms",
settings: "Settings",
};
const order = Object.keys(groupNames);
/** A scope's permissions, grouped by resource: [group name, permissions]. */
function groupsOf(permissions: string[]): [string, string[]][] {
const by = new Map<string, string[]>();
for (const p of permissions) {
const resource = p.split(":")[0]!;
by.set(resource, [...(by.get(resource) ?? []), p]);
}
const rank = (r: string) => (order.includes(r) ? order.indexOf(r) : order.length);
return [...by.entries()]
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
.map(([r, list]) => [groupNames[r] ?? r.charAt(0).toUpperCase() + r.slice(1), list]);
}
export function TokenForm({ scopes, maxDays }: { scopes: TokenScope[]; maxDays: number }) {
const [state, formAction, pending] = useActionState(createToken, null);
const [rows, setRows] = useState<Row[]>([{ scope: keyOf(scopes[0]!.scope), permissions: [] }]);
const byKey = new Map(scopes.map((s) => [keyOf(s.scope), s]));
const grants = rows.flatMap((r) => r.permissions.map((permission) => ({ permission, scope: byKey.get(r.scope)!.scope })));
const set = (i: number, row: Row) => setRows(rows.map((r, j) => (j === i ? row : r)));
const unused = scopes.find((s) => !rows.some((r) => r.scope === keyOf(s.scope)));
return (
<form action={formAction} className="space-y-4">
<input type="hidden" name="grants" value={JSON.stringify(grants)} />
<div className="flex flex-wrap gap-2">
<Input name="name" required maxLength={64} placeholder="Name, e.g. Upload script" aria-label="Name" className="max-w-xs" />
<NativeSelect name="days" defaultValue="90" aria-label="Lasts" className="w-36">
{[7, 30, 90, maxDays].map((d) => (
<NativeSelectOption key={d} value={d}>
{d === 365 ? "1 year" : `${d} days`}
</NativeSelectOption>
))}
</NativeSelect>
</div>
{rows.map((row, i) => {
const scope = byKey.get(row.scope)!;
return (
<fieldset key={i} className="space-y-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
value={row.scope}
onChange={(e) => set(i, { scope: e.target.value, permissions: [] })}
aria-label="Where"
className="min-w-56"
>
{scopes.map((s) => {
const k = keyOf(s.scope);
if (k !== row.scope && rows.some((r) => r.scope === k)) return null;
return (
<NativeSelectOption key={k} value={k}>
{s.scope.type === "hub" ? "Everything on T5DE" : `${kindOf[s.scope.type]}: ${s.name}`}
</NativeSelectOption>
);
})}
</NativeSelect>
{rows.length > 1 && (
<Button type="button" variant="ghost" size="sm" onClick={() => setRows(rows.filter((_, j) => j !== i))}>
Remove
</Button>
)}
</div>
<div className="divide-y rounded-md border">
{groupsOf(scope.permissions).map(([group, list]) => {
const chosen = list.filter((p) => row.permissions.includes(p)).length;
const toggleAll = (on: boolean) =>
set(i, { ...row, permissions: on ? [...new Set([...row.permissions, ...list])] : row.permissions.filter((x) => !list.includes(x)) });
return (
<details key={group} className="group/perm">
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-sm select-none [&::-webkit-details-marker]:hidden">
<ChevronRightIcon className="size-4 text-muted-foreground transition-transform group-open/perm:rotate-90" aria-hidden />
<input
type="checkbox"
aria-label={`All ${group}`}
checked={chosen === list.length}
ref={(el) => {
if (el) el.indeterminate = chosen > 0 && chosen < list.length;
}}
onChange={(e) => toggleAll(e.target.checked)}
/>
<span className="font-medium">{group}</span>
<span className="text-xs text-muted-foreground">
{chosen > 0 ? `${chosen} of ${list.length}` : list.length === 1 ? "1 permission" : `${list.length} permissions`}
</span>
</summary>
<div className="grid gap-x-4 gap-y-1 px-3 pt-1 pb-3 pl-10 sm:grid-cols-2">
{list.map((p) => (
<label key={p} className="flex items-center gap-2 font-mono text-xs">
<input
type="checkbox"
checked={row.permissions.includes(p)}
onChange={(e) =>
set(i, { ...row, permissions: e.target.checked ? [...row.permissions, p] : row.permissions.filter((x) => x !== p) })
}
/>
{p.slice(p.indexOf(":") + 1)}
</label>
))}
</div>
</details>
);
})}
</div>
</fieldset>
);
})}
<div className="flex flex-wrap gap-2">
{unused && (
<Button type="button" variant="outline" onClick={() => setRows([...rows, { scope: keyOf(unused.scope), permissions: [] }])}>
Add another place
</Button>
)}
<Button type="submit" disabled={pending || grants.length === 0}>
{pending ? "Making…" : "Make token"}
</Button>
</div>
{state?.error && <ErrorText>{state.error}</ErrorText>}
{state?.token && (
<div role="status" className="space-y-2 rounded-lg border p-3 text-sm">
<p>
Your token <strong>{state.name}</strong>. Copy it now: it won&apos;t be shown again.
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 rounded bg-muted px-2 py-1 font-mono text-xs break-all">{state.token}</code>
<CopyButton text={state.token} />
</div>
<p className="text-muted-foreground">
Send it as <code className="font-mono text-xs">Authorization: Bearer &lt;token&gt;</code>.
</p>
</div>
)}
</form>
);
}
+97
View File
@@ -0,0 +1,97 @@
// API tokens on the account page: the person's tokens, and making one.
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { TokenEntry, TokenList } from "@/lib/api";
import { revokeToken } from "./actions";
import { TokenForm } from "./token-form";
export function TokensCard({ data }: { data: TokenList }) {
const active = data.tokens.filter((t) => t.status === "active");
const past = data.tokens.filter((t) => t.status !== "active").slice(0, 10);
return (
<Card>
<CardHeader>
<CardTitle>API Tokens</CardTitle>
<CardDescription>
For your own scripts. A token can do only what you give it, and never more than you can do yourself.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{active.length > 0 && (
<ul className="divide-y">
{active.map((t) => (
<TokenRow key={t.id} token={t} />
))}
</ul>
)}
{data.scopes.length > 0 ? (
<TokenForm scopes={data.scopes} maxDays={data.maxDays} />
) : (
<p className="text-sm text-muted-foreground">
Tokens act on what you manage: your products or a catalog&apos;s team. You don&apos;t manage anything yet.
</p>
)}
{past.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Revoked and expired</summary>
<ul className="mt-2 divide-y">
{past.map((t) => (
<TokenRow key={t.id} token={t} />
))}
</ul>
</details>
)}
</CardContent>
</Card>
);
}
function TokenRow({ token: t }: { token: TokenEntry }) {
return (
<li className="space-y-1 py-3 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{t.name}</span>
{t.status !== "active" && <Tag tone={t.status === "revoked" ? "danger" : "neutral"}>{t.status}</Tag>}
<span className="flex-1" />
{t.status === "active" && (
<ActionForm action={revokeToken} fields={{ id: t.id }}>
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…" confirm={`Revoke ${t.name}? Scripts using it stop working.`}>
Revoke
</SubmitButton>
</ActionForm>
)}
</div>
<p className="text-xs text-muted-foreground">
{t.grants.map((g, i) => (
<span key={i}>
{i > 0 && ", "}
<span className="font-mono">{g.permission}</span> on {g.scope.type === "hub" ? "everything" : g.name}
</span>
))}
</p>
<p className="text-xs text-muted-foreground">
Made <DateTime iso={t.createdAt} dateOnly />
{" · "}
{t.status === "revoked" && t.revokedAt ? (
<>
revoked <DateTime iso={t.revokedAt} dateOnly />
</>
) : (
<>
{t.status === "expired" ? "expired" : "expires"} <DateTime iso={t.expiresAt} dateOnly />
</>
)}
{" · "}
{t.lastUsedAt ? (
<>
last used <DateTime iso={t.lastUsedAt} />
</>
) : (
"never used"
)}
</p>
</li>
);
}
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ReportForm } from "@/components/cases";
import { membership as membershipLabel } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { api, getMe, unwrap } from "@/lib/api";
import { membership } from "../../console/catalogs/actions";
export const metadata: Metadata = { title: "Catalog" };
export default async function CatalogPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ invite?: string | string[] }>;
}) {
const { id } = await params;
const raw = (await searchParams).invite;
const invite = typeof raw === "string" && /^[a-z0-9]{1,32}$/.test(raw) ? raw : "";
const [me, answer] = await Promise.all([getMe(), api.catalogs({ id }).get()]);
if (answer.error) notFound();
const data = unwrap(answer);
const c = data.catalog;
const manage = data.permissions.includes("audit:read");
return (
<div className="space-y-6">
<PageHeader title={c.name}>
<span className="flex flex-wrap items-center gap-2">
<span>{membershipLabel[c.membership] ?? c.membership}</span>
{c.maxRating === "general" && <Tag>General audiences</Tag>}
{data.membership === "member" && <Tag tone="strong">Member</Tag>}
{data.membership === "requested" && <Tag>Asked to join</Tag>}
{data.membership === "banned" && <Tag tone="danger">Banned</Tag>}
</span>
</PageHeader>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Products</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{data.hosted.products}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardDescription>Takedown Contact</CardDescription>
<CardTitle className="text-base font-normal break-all">{c.contact}</CardTitle>
</CardHeader>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Membership</CardTitle>
<CardDescription>
{c.membership === "open"
? "Anyone on T5DE can get its products."
: c.membership === "approval"
? "Ask to join; its team decides. Anyone can see its products on others."
: "Join with an invitation. Anyone can see its products on others."}
</CardDescription>
</CardHeader>
<CardContent>
{!me ? (
<Link href={`/signin?next=${encodeURIComponent(`/catalogs/${id}${invite ? `?invite=${invite}` : ""}`)}`} className={buttonVariants()}>
Sign in to join
</Link>
) : data.membership === "banned" ? (
<p className="text-sm text-muted-foreground">This catalog&apos;s team banned you from it.</p>
) : data.membership === "member" || data.membership === "requested" ? (
<ActionForm action={membership} fields={{ id, intent: "leave" }}>
<SubmitButton variant="outline" pendingLabel="Leaving…" confirm={data.membership === "member" ? `Leave ${c.name}?` : undefined}>
{data.membership === "member" ? "Leave" : "Withdraw request"}
</SubmitButton>
</ActionForm>
) : c.membership === "open" ? (
<p className="text-sm text-muted-foreground">Nothing to join: get its products from their pages.</p>
) : c.membership === "invite" && !invite ? (
<p className="text-sm text-muted-foreground">You need an invitation link from its team.</p>
) : (
<ActionForm action={membership} fields={{ id, intent: "join", invite }}>
<SubmitButton pendingLabel="Joining…">{invite ? "Accept invitation" : "Ask to join"}</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
{manage && (
<Link href={`/console/catalogs/${id}`} className={buttonVariants({ variant: "outline" })}>
Manage
</Link>
)}
{me && !manage && <ReportForm about="catalog" id={id} />}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use server";
import { revalidatePath } from "next/cache";
import { api, ApiError, unwrap } from "@/lib/api";
import { field } from "@/lib/actions";
export type RegisterState = { error?: string; registered?: { id: string; name: string; key: string } } | null;
/** Registers a catalog; its key is in the answer, the only time it's shown. */
export async function registerCatalog(_: RegisterState, form: FormData): Promise<RegisterState> {
try {
const { catalog, key } = unwrap(
await api.catalogs.post({
name: field(form, "name"),
url: field(form, "url"),
contact: field(form, "contact"),
visibility: field(form, "visibility"),
membership: field(form, "membership"),
}),
);
revalidatePath("/catalogs");
return { registered: { id: catalog.id, name: catalog.name, key } };
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CatalogStatus, membership } from "@/components/catalogs";
import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, getMe, unwrap, type Catalog } from "@/lib/api";
import { RegisterForm } from "./register-form";
export const metadata: Metadata = { title: "Catalogs" };
export default async function Catalogs() {
const [me, { catalogs }] = await Promise.all([getMe(), api.catalogs.get().then(unwrap)]);
const mine = catalogs.filter((c) => c.mine);
const directory = catalogs.filter((c) => !c.mine && c.status === "approved");
return (
<div className="space-y-6">
<PageHeader title="Catalogs">Servers that host community products.</PageHeader>
{mine.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Yours</CardTitle>
</CardHeader>
<CardContent>
<CatalogTable catalogs={mine} showStatus />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Directory</CardTitle>
</CardHeader>
<CardContent>
{directory.length === 0 ? (
<p className="text-sm text-muted-foreground">No catalogs yet.</p>
) : (
<CatalogTable catalogs={directory} />
)}
</CardContent>
</Card>
{me && (
<Card>
<CardHeader>
<CardTitle>Register a catalog</CardTitle>
<CardDescription>Run t5de-catalog, then register its address here.</CardDescription>
</CardHeader>
<CardContent>
<RegisterForm />
</CardContent>
</Card>
)}
</div>
);
}
function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; showStatus?: boolean }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Address</TableHead>
<TableHead>Membership</TableHead>
{showStatus && <TableHead>Visibility</TableHead>}
{showStatus && <TableHead>Status</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{catalogs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">
<Link href={showStatus ? `/console/catalogs/${c.id}` : `/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
{c.name}
</Link>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
<TableCell>{membership[c.membership] ?? c.membership}</TableCell>
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
{showStatus && (
<TableCell>
<CatalogStatus status={c.status} />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useActionState } from "react";
import { CopyButton } from "@/components/copy-button";
import { ErrorText, SubmitButton } from "@/components/forms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { registerCatalog } from "./actions";
export function RegisterForm() {
const [state, action] = useActionState(registerCatalog, null);
if (state?.registered) {
const { id, name, key } = state.registered;
return (
<Alert>
<AlertTitle>{name} is registered</AlertTitle>
<AlertDescription className="space-y-3">
<p>Put these in the catalog&apos;s settings. The key is shown only once. A moderator approves it next.</p>
<KeyLine label="CATALOG_ID" value={id} />
<KeyLine label="CATALOG_KEY" value={key} />
</AlertDescription>
</Alert>
);
}
return (
<form action={action}>
<FieldGroup>
<div className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" required maxLength={64} />
</Field>
<Field>
<FieldLabel htmlFor="url">Address</FieldLabel>
<Input id="url" name="url" type="url" required maxLength={300} placeholder="https://" />
</Field>
<Field>
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
<Input id="contact" name="contact" required maxLength={200} placeholder="Email or URL" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="visibility">Visibility</FieldLabel>
<NativeSelect id="visibility" name="visibility" defaultValue="public" className="w-full">
<NativeSelectOption value="public">Public</NativeSelectOption>
<NativeSelectOption value="private">Private</NativeSelectOption>
</NativeSelect>
</Field>
<Field>
<FieldLabel htmlFor="membership">Membership</FieldLabel>
<NativeSelect id="membership" name="membership" defaultValue="open" className="w-full">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="approval">By approval</NativeSelectOption>
<NativeSelectOption value="invite">Invite only</NativeSelectOption>
</NativeSelect>
</Field>
</div>
</div>
<div>
<SubmitButton pendingLabel="Registering…">Register</SubmitButton>
</div>
{state?.error && <ErrorText>{state.error}</ErrorText>}
</FieldGroup>
</form>
);
}
function KeyLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-28 font-mono text-xs text-muted-foreground">{label}</span>
<code className="min-w-0 rounded-md border bg-muted px-2 py-1 font-mono text-xs break-all text-foreground">
{value}
</code>
<CopyButton text={value} />
</div>
);
}
+33
View File
@@ -1,5 +1,6 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api"; import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions"; import { attempt, field, type ActionState } from "@/lib/actions";
@@ -42,3 +43,35 @@ export async function setRole(_: ActionState, form: FormData): Promise<ActionSta
return `Role set to ${person.role}.`; return `Role set to ${person.role}.`;
}); });
} }
export async function decideCatalog(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const decision = field(form, "decision") === "approve" ? "approve" : "reject";
return attempt(async () => {
unwrap(await api.console.catalogs({ id })({ decision }).post());
revalidatePath("/console/catalogs");
revalidatePath("/catalogs");
});
}
/** An account waiting for approval: approve it, or decline it (deleting it). */
export async function decideAccount(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const decline = field(form, "decision") === "decline";
const state = await attempt(async () => {
if (decline) unwrap(await api.console.people({ id }).decline.post());
else unwrap(await api.console.people({ id }).approve.post());
revalidatePath("/console", "layout");
});
if (decline && !state?.error) redirect("/console/people?waiting=true");
return state;
}
export async function setRegistration(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
const registration = field(form, "registration") === "closed" ? "closed" : "open";
unwrap(await api.console.settings.put({ registration }));
revalidatePath("/console");
return registration === "closed" ? "Registration closed: new accounts wait for approval." : "Registration open.";
});
}
+105
View File
@@ -0,0 +1,105 @@
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { DateTime } from "@/components/time";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { AuditEntry } from "@/lib/api";
import { appeal } from "./cases/actions";
const tagLinks: Record<string, (id: string) => string> = {
catalog: (id) => `/console/catalogs/${id}`,
product: (id) => `/products/${id}`,
room: (id) => `/rooms/${id}/edit`,
};
/** Audit entries: when, who, what, which resources (linked), and the details. */
export function AuditTable({ entries, empty = "Nothing yet." }: { entries: AuditEntry[]; empty?: string }) {
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{empty}</p>;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Who</TableHead>
<TableHead>What</TableHead>
<TableHead>About</TableHead>
<TableHead>Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e) => (
<TableRow key={e.id}>
<TableCell className="text-muted-foreground">
<DateTime iso={e.at} />
</TableCell>
<TableCell>
{e.actor ? (
<Link href={`/console/audit?actor=${encodeURIComponent(e.actor)}`} className="underline-offset-4 hover:underline">
{e.actor}
</Link>
) : (
<span className="text-muted-foreground">hub</span>
)}
</TableCell>
<TableCell className="font-mono text-xs">{e.action}</TableCell>
<TableCell className="space-x-2 text-sm">
{Object.entries(e.tags as Record<string, string | undefined>).map(([kind, id]) => {
if (!id) return null;
const name = (e.tagNames as Record<string, string>)[id] ?? id;
const href = tagLinks[kind]?.(id);
return (
<span key={kind}>
<span className="text-muted-foreground">{kind} </span>
{href ? (
<Link href={href} className="underline-offset-4 hover:underline">
{name}
</Link>
) : (
name
)}
</span>
);
})}
{Object.keys(e.tags).length === 0 &&
(e.action.startsWith("file.") && /^[0-9a-f]{64}$/.test(e.target) ? (
<Link href={`/console/blocks/${e.target}`} className="font-mono text-xs underline-offset-4 hover:underline">
file {e.target.slice(0, 12)}…
</Link>
) : (
e.target
))}
</TableCell>
<TableCell className="text-xs whitespace-normal text-muted-foreground">
{detailText(e.detail)}
{e.appealable && (
<details className="mt-1">
<summary className="cursor-pointer text-foreground underline-offset-4 hover:underline">Appeal</summary>
<ActionForm action={appeal} fields={{ audit: e.id }} className="mt-2">
<div className="flex gap-2">
<Input name="reason" required maxLength={2000} placeholder="Why it should be undone" aria-label="Why" className="h-8 min-w-48" />
<SubmitButton size="sm" variant="outline" pendingLabel="Sending…">
Send
</SubmitButton>
</div>
</ActionForm>
</details>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function detailText(detail: unknown): string {
return Object.entries((detail ?? {}) as Record<string, unknown>)
.map(([k, v]) => {
if (k === "changed" && v && typeof v === "object")
return Object.entries(v as Record<string, { from: unknown; to: unknown }>)
.map(([f, c]) => `${f}: ${String(c?.from)} → ${String(c?.to)}`)
.join(", ");
return `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`;
})
.join(", ");
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/status";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api, unwrap } from "@/lib/api";
import { AuditTable } from "../audit-table";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Audit Log" };
const filters = ["action", "actor", "person", "catalog", "product", "room", "q", "from", "to"] as const;
type Filters = Partial<Record<(typeof filters)[number], string>>;
export default async function AuditPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
const { console: data } = await requireConsole("/console/audit");
const params = await searchParams;
const f: Filters = {};
for (const k of filters) {
const v = params[k];
if (typeof v === "string" && v.trim()) f[k] = v.trim().slice(0, 100);
}
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
const { entries, before: next } = unwrap(await api.console.audit.get({ query: { ...f, ...(before ? { before } : {}), limit: 100 } }));
const older = new URLSearchParams({ ...f, ...(next ? { before: String(next) } : {}) });
const moderator = data.moderation !== null;
return (
<>
<PageHeader title="Audit Log">
{moderator ? "Every change on the hub." : "Changes to what you manage, and the ones you made."}
</PageHeader>
<ConsoleNav />
<form action="/console/audit" className="mb-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<Input name="q" defaultValue={f.q} placeholder="Search" aria-label="Search" />
<Input name="action" defaultValue={f.action} placeholder="Action, e.g. catalog." aria-label="Action" />
<Input name="actor" defaultValue={f.actor} placeholder="Done by (username)" aria-label="Done by" />
{moderator && <Input name="person" defaultValue={f.person} placeholder="About or by a person" aria-label="Person" />}
<Input name="catalog" defaultValue={f.catalog} placeholder="Catalog ID" aria-label="Catalog" />
<Input name="product" defaultValue={f.product} placeholder="Product ID" aria-label="Product" />
<Input type="date" name="from" defaultValue={f.from} aria-label="From" />
<Input type="date" name="to" defaultValue={f.to} aria-label="To" />
<div className="flex gap-2">
<Button type="submit">Filter</Button>
{Object.keys(f).length > 0 && (
<Link href="/console/audit" className={buttonVariants({ variant: "outline" })}>
Clear
</Link>
)}
</div>
</form>
<Card>
<CardContent>
<AuditTable entries={entries} empty={Object.keys(f).length ? "Nothing matches." : "Nothing yet."} />
</CardContent>
</Card>
{next && entries.length === 100 && (
<div className="mt-4">
<Link href={`/console/audit?${older}`} className={buttonVariants({ variant: "outline" })}>
Older
</Link>
</div>
)}
</>
);
}
@@ -0,0 +1,106 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireConsole } from "../../console-nav";
import { blockFile, unblockFile } from "../actions";
export const metadata: Metadata = { title: "Block" };
/** A file: whether it's blocked, the products using it, and blocking or unblocking it. */
export default async function BlockPage({ params }: { params: Promise<{ sha256: string }> }) {
const { sha256 } = await params;
if (!/^[0-9a-f]{64}$/.test(sha256)) notFound();
const { console: c } = await requireConsole(`/console/blocks/${sha256}`);
if (!c.sections.includes("blocks")) notFound();
const { block, products } = unwrap(await api.blocks({ sha256 }).get());
const live = products.filter((p) => !p.removed);
const removeCount = live.length === 1 ? "1 product" : `${live.length} products`;
return (
<>
<PageHeader title="File">
<span className="font-mono text-xs break-all">{sha256}</span>
</PageHeader>
<ConsoleNav />
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{block ? "Blocked" : "Not Blocked"}</CardTitle>
<CardDescription>
{block ? (
<>
By {block.by ?? "a former moderator"}, <DateTime iso={block.at} />: {block.reason}
</>
) : (
"Blocking it removes the products below, tells their catalogs to delete it, and refuses uploads with it."
)}
</CardDescription>
</CardHeader>
<CardContent>
{block ? (
<ActionForm action={unblockFile} fields={{ sha256 }}>
<div className="flex flex-wrap gap-2">
<Input name="reason" maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
<SubmitButton
variant="outline"
pendingLabel="Unblocking…"
confirm="Unblock this file? The products it removed stay removed until restored."
>
Unblock
</SubmitButton>
</div>
</ActionForm>
) : (
<ActionForm action={blockFile} fields={{ sha256 }}>
<div className="flex flex-wrap gap-2">
<Input name="reason" required maxLength={500} placeholder="Reason (creators see it)" aria-label="Reason" className="max-w-sm" />
<SubmitButton
variant="destructive"
pendingLabel="Blocking…"
confirm={live.length ? `Block this file and remove ${removeCount}?` : "Block this file?"}
>
{live.length ? `Block and remove ${removeCount}` : "Block"}
</SubmitButton>
</div>
</ActionForm>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Used By</CardTitle>
<CardDescription>Products with a revision that has this file.</CardDescription>
</CardHeader>
<CardContent>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">No product uses it.</p>
) : (
<ul className="divide-y">
{products.map((p) => (
<li key={p.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-3 first:pt-0 last:pb-0">
<Link
href={p.removed ? `/console/products?q=${encodeURIComponent(p.name)}&status=removed&all=1` : `/products/${p.id}`}
className="font-medium underline-offset-4 hover:underline"
>
{p.name}
</Link>
<span className="text-sm text-muted-foreground">
by {p.creatorUsername ?? "someone"} · {p.revisions.length === 1 ? "revision" : "revisions"} {p.revisions.join(", ")}
</span>
{p.removed && <Tag tone="danger">removed</Tag>}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use server";
// File blocks, from the console.
import { revalidatePath } from "next/cache";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const done = (sha256: string) => {
revalidatePath("/console/blocks");
revalidatePath(`/console/blocks/${sha256}`);
revalidatePath("/console/products");
};
/** Blocks a file network-wide, removing the products using it. */
export async function blockFile(_: ActionState, form: FormData): Promise<ActionState> {
const sha256 = field(form, "sha256");
return attempt(async () => {
const { removed } = unwrap(await api.blocks({ sha256 }).put({ reason: field(form, "reason") }));
done(sha256);
return removed.length ? `Blocked. Removed ${removed.length === 1 ? "1 product" : `${removed.length} products`}.` : "Blocked.";
});
}
/** Unblocks a file. The products it removed stay removed. */
export async function unblockFile(_: ActionState, form: FormData): Promise<ActionState> {
const sha256 = field(form, "sha256");
return attempt(async () => {
unwrap(await api.blocks({ sha256 }).delete({ reason: field(form, "reason") }));
done(sha256);
return "Unblocked.";
});
}
+88
View File
@@ -0,0 +1,88 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Blocks" };
export default async function BlocksPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
const { console: c } = await requireConsole("/console/blocks");
if (!c.sections.includes("blocks")) notFound();
const params = await searchParams;
// Looking a file up goes to its page.
if (typeof params.sha256 === "string") {
const sha256 = params.sha256.trim().toLowerCase();
if (/^[0-9a-f]{64}$/.test(sha256)) redirect(`/console/blocks/${sha256}`);
}
const before = typeof params.before === "string" && !Number.isNaN(Date.parse(params.before)) ? params.before : undefined;
const data = unwrap(await api.blocks.get({ query: before ? { before } : {} }));
return (
<>
<PageHeader title="Blocks">Blocked files: uploads with them are refused, and catalogs delete them.</PageHeader>
<ConsoleNav />
<form action="/console/blocks" className="mb-6 flex flex-wrap gap-2">
<Input
name="sha256"
required
pattern="\s*[0-9a-fA-F]{64}\s*"
defaultValue={typeof params.sha256 === "string" ? params.sha256 : ""}
placeholder="A file's sha256"
aria-label="sha256"
className="max-w-xl font-mono"
/>
<Button type="submit">Look up</Button>
</form>
<Card>
<CardContent>
{data.blocks.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing blocked. Block a file from a product&apos;s files, or look it up above.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Reason</TableHead>
<TableHead>By</TableHead>
<TableHead>When</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.blocks.map((b) => (
<TableRow key={b.sha256}>
<TableCell className="font-mono text-xs">
<Link href={`/console/blocks/${b.sha256}`} className="underline-offset-4 hover:underline">
{b.sha256.slice(0, 16)}…
</Link>
</TableCell>
<TableCell className="whitespace-normal">{b.reason}</TableCell>
<TableCell className="text-muted-foreground">{b.by ?? ""}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={b.at} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{data.before && (
<div className="mt-4">
<Link href={`/console/blocks?before=${encodeURIComponent(data.before)}`} className={buttonVariants({ variant: "outline" })}>
Older
</Link>
</div>
)}
</>
);
}
+191
View File
@@ -0,0 +1,191 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CaseAbout, caseKinds, CaseStatus, resolveLabel } from "@/components/cases";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireConsole } from "../../console-nav";
import { addNote, assign, decide } from "../actions";
export const metadata: Metadata = { title: "Case" };
/** What each kind of case asks. */
const asks: Record<string, string> = {
report: "Someone reported this.",
"rating-review": "Its creator asks to lower its rating from adult to general.",
"removal-request": "A catalog hosting it asks for it to be removed everywhere.",
"catalog-deletion": "Its owner asks for it to be deleted.",
appeal: "The person it affected asks for this action to be undone.",
};
export default async function CasePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!/^\d{1,9}$/.test(id)) notFound();
const { me } = await requireConsole(`/console/cases/${id}`);
const answer = await api.cases({ id: Number(id) }).get();
if (answer.error) notFound();
const { case: c, notes, canDecide, canAssign } = unwrap(answer);
const appealed = c.kind === "appeal" ? (c.data as { action?: string; detail?: Record<string, unknown> }) : undefined;
return (
<>
<PageHeader title={`${caseKinds[c.kind] ?? c.kind} #${c.id}`}>
<span className="flex flex-wrap items-center gap-2">
<CaseStatus status={c.status} />
<span>
opened by {c.openedBy || "someone"} <DateTime iso={c.createdAt} />
</span>
{c.assignee && <Tag>assigned to {c.assignee}</Tag>}
</span>
</PageHeader>
<ConsoleNav />
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>About</CardTitle>
<CardDescription>{asks[c.kind]}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p>
<CaseAbout tags={c.tags} names={c.tagNames} />
</p>
{appealed && (
<p>
<span className="text-muted-foreground">Action appealed: </span>
<span className="font-mono text-xs">{appealed.action}</span>
{appealed.detail && Object.keys(appealed.detail).length > 0 && (
<span className="text-muted-foreground">
{" "}
({Object.entries(appealed.detail)
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(", ")}
)
</span>
)}
</p>
)}
{c.reason && <p className="whitespace-pre-line">{c.reason}</p>}
{c.status !== "open" && (
<div className="rounded-lg border p-3">
<p className="font-medium">
{c.status === "resolved" ? "Resolved" : "Declined"} by {c.decidedBy || "a moderator"}{" "}
{c.decidedAt && (
<span className="font-normal text-muted-foreground">
<DateTime iso={c.decidedAt} />
</span>
)}
</p>
{c.resolution && <p className="mt-1 whitespace-pre-line">{c.resolution}</p>}
</div>
)}
</CardContent>
</Card>
{canDecide && (
<Card>
<CardHeader>
<CardTitle>Decide</CardTitle>
<CardDescription>
{c.kind === "report" || c.kind === "appeal"
? "Resolving records the decision; take any action where it's done (removed products are restored from Console › Products, edits undone from the product's history)."
: `Resolving does it: ${resolveLabel[c.kind]?.toLowerCase()}.`}{" "}
{c.openedBy ? `${c.openedBy} is told, with what you write.` : ""}
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={decide} fields={{ case: c.id }}>
<Textarea name="resolution" maxLength={2000} rows={3} placeholder="What you decided, and why" aria-label="Resolution" />
<div className="flex flex-wrap gap-2">
<SubmitButton
name="decision"
value="resolve"
pendingLabel="Saving…"
confirm={c.kind === "catalog-deletion" || c.kind === "removal-request" ? `${resolveLabel[c.kind]}?` : undefined}
>
{resolveLabel[c.kind] ?? "Resolve"}
</SubmitButton>
<SubmitButton name="decision" value="decline" variant="outline" pendingLabel="Saving…">
Decline
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
{canAssign && c.status === "open" && (
<Card>
<CardHeader>
<CardTitle>Assignment</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<ActionForm action={assign} fields={{ case: c.id }} className="flex flex-wrap gap-2 space-y-0">
<Input name="to" defaultValue={c.assignee ?? me.username} maxLength={64} aria-label="Moderator" className="max-w-48" />
<SubmitButton variant="outline" pendingLabel="Saving…">
Assign
</SubmitButton>
</ActionForm>
{c.assignee && (
<ActionForm action={assign} fields={{ case: c.id, to: "" }}>
<SubmitButton variant="ghost" pendingLabel="Saving…">
Unassign
</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Thread</CardTitle>
{canDecide && <CardDescription>Internal notes are only for those deciding.</CardDescription>}
</CardHeader>
<CardContent className="space-y-4">
{notes.length === 0 ? (
<p className="text-sm text-muted-foreground">No notes yet.</p>
) : (
<ul className="divide-y">
{notes.map((n) => (
<li key={n.id} className="space-y-1 py-3 first:pt-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{n.by || "Someone"}</span>
{n.internal && <Tag>Internal</Tag>}
<span className="text-muted-foreground">
<DateTime iso={n.at} />
</span>
</div>
<p className="text-sm whitespace-pre-line">{n.body}</p>
</li>
))}
</ul>
)}
<ActionForm action={addNote} fields={{ case: c.id }}>
<Textarea name="body" required maxLength={4000} rows={3} placeholder="Write a note" aria-label="Note" />
<div className="flex flex-wrap items-center gap-4">
<SubmitButton variant="outline" pendingLabel="Sending…">
Add note
</SubmitButton>
{canDecide && (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="internal" className="size-4 accent-foreground" />
Internal
</label>
)}
</div>
</ActionForm>
</CardContent>
</Card>
<Link href="/console/cases" className="text-sm underline-offset-4 hover:underline">
All cases
</Link>
</div>
</>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use server";
// Cases (reports, reviews, requests, appeals) and notifications, from the website.
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
/** Reports a product, catalog, room or person. */
export async function report(_: ActionState, form: FormData): Promise<ActionState> {
const kind = field(form, "about");
const id = field(form, "id");
if (!["product", "catalog", "room", "person"].includes(kind)) return { error: "Nothing to report." };
return attempt(async () => {
unwrap(await api.cases.post({ kind: "report", about: { [kind]: id }, reason: field(form, "reason") }));
revalidatePath("/console/cases");
return "Reported. Moderators will look at it; you'll be told what they decide.";
});
}
/** Appeals an action from the audit log. */
export async function appeal(_: ActionState, form: FormData): Promise<ActionState> {
let id = 0;
const state = await attempt(async () => {
const { case: c } = unwrap(await api.cases.post({ kind: "appeal", audit: Number(field(form, "audit")), reason: field(form, "reason") }));
id = c.id;
});
if (state?.error) return state;
redirect(`/console/cases/${id}`);
}
/** A catalog's team asks moderators to remove a product everywhere. */
export async function requestRemoval(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
unwrap(
await api.cases.post({
kind: "removal-request",
about: { product: field(form, "product") },
catalog: field(form, "catalog"),
reason: field(form, "reason"),
}),
);
revalidatePath("/console/cases");
return "Asked. Moderators will decide.";
});
}
/** A catalog's owner asks for it to be deleted. */
export async function requestDeletion(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
let kase = 0;
const state = await attempt(async () => {
kase = unwrap(await api.catalogs({ id }).delete.post({ reason: field(form, "reason") })).case;
});
if (state?.error) return state;
redirect(`/console/cases/${kase}`);
}
const onCase = (form: FormData) => {
const id = Number(field(form, "case"));
return { id, done: () => revalidatePath(`/console/cases/${id}`) };
};
export async function addNote(_: ActionState, form: FormData): Promise<ActionState> {
const { id, done } = onCase(form);
return attempt(async () => {
unwrap(await api.cases({ id }).notes.post({ body: field(form, "body"), internal: form.get("internal") === "on" }));
done();
});
}
export async function assign(_: ActionState, form: FormData): Promise<ActionState> {
const { id, done } = onCase(form);
return attempt(async () => {
const to = field(form, "to");
unwrap(await api.cases({ id }).assign.post({ to: to || null }));
done();
});
}
export async function decide(_: ActionState, form: FormData): Promise<ActionState> {
const { id, done } = onCase(form);
const decision = field(form, "decision");
if (decision !== "resolve" && decision !== "decline") return { error: "Choose to resolve or decline it." };
return attempt(async () => {
unwrap(await api.cases({ id }).decide.post({ decision, resolution: field(form, "resolution") }));
done();
revalidatePath("/console/cases");
revalidatePath("/console");
});
}
export async function markAllRead(_: ActionState): Promise<ActionState> {
return attempt(async () => {
unwrap(await api.notifications.read.post({}));
revalidatePath("/notifications");
revalidatePath("/", "layout");
});
}
+122
View File
@@ -0,0 +1,122 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CaseAbout, caseKinds, CaseStatus } from "@/components/cases";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Cases" };
const statuses = ["open", "resolved", "declined"] as const;
const kinds = ["report", "rating-review", "removal-request", "catalog-deletion", "appeal"] as const;
export default async function CasesPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
await requireConsole("/console/cases");
const params = await searchParams;
// Open cases unless asked otherwise ("any" for all).
const status = params.status === "any" ? undefined : (statuses.find((s) => s === params.status) ?? "open");
const kind = kinds.find((k) => k === params.kind);
const mine = params.mine === "1";
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
const data = unwrap(
await api.cases.get({
query: {
...(status ? { status } : {}),
...(kind ? { kind } : {}),
...(mine ? { mine: "1" as const } : {}),
...(before ? { before } : {}),
limit: 50,
},
}),
);
const older = new URLSearchParams({
status: status ?? "any",
...(kind ? { kind } : {}),
...(mine ? { mine: "1" } : {}),
...(data.before ? { before: String(data.before) } : {}),
});
return (
<>
<PageHeader title="Cases">
{data.moderator ? "Reports, reviews, requests and appeals." : "Cases you opened, and those about what you manage."}
</PageHeader>
<ConsoleNav />
<form action="/console/cases" className="mb-6 flex flex-wrap gap-2">
<NativeSelect name="status" defaultValue={status ?? "any"} aria-label="Status" className="w-36">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="resolved">Resolved</NativeSelectOption>
<NativeSelectOption value="declined">Declined</NativeSelectOption>
<NativeSelectOption value="any">Any status</NativeSelectOption>
</NativeSelect>
<NativeSelect name="kind" defaultValue={kind ?? ""} aria-label="Kind" className="w-44">
<NativeSelectOption value="">Any kind</NativeSelectOption>
{kinds.map((k) => (
<NativeSelectOption key={k} value={k}>
{caseKinds[k]}
</NativeSelectOption>
))}
</NativeSelect>
<NativeSelect name="mine" defaultValue={mine ? "1" : ""} aria-label="Whose" className="w-44">
<NativeSelectOption value="">{data.moderator ? "Everyone's" : "All I can see"}</NativeSelectOption>
<NativeSelectOption value="1">Opened by me</NativeSelectOption>
</NativeSelect>
<Button type="submit">Filter</Button>
</form>
<Card>
<CardContent>
{data.cases.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing here.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Case</TableHead>
<TableHead>About</TableHead>
<TableHead>Opened by</TableHead>
<TableHead>Assigned</TableHead>
<TableHead>Status</TableHead>
<TableHead>Changed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.cases.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">
<Link href={`/console/cases/${c.id}`} className="underline-offset-4 hover:underline">
{caseKinds[c.kind] ?? c.kind} #{c.id}
</Link>
</TableCell>
<TableCell className="text-sm">
<CaseAbout tags={c.tags} names={c.tagNames} />
</TableCell>
<TableCell className="text-muted-foreground">{c.openedBy || "someone"}</TableCell>
<TableCell className="text-muted-foreground">{c.assignee ?? ""}</TableCell>
<TableCell>
<CaseStatus status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={c.updatedAt} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{data.before && data.cases.length === 50 && (
<div className="mt-4">
<Link href={`/console/cases?${older}`} className={buttonVariants({ variant: "outline" })}>
Older
</Link>
</div>
)}
</>
);
}
+536
View File
@@ -0,0 +1,536 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { CatalogStatus } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { formatSize, RatingTag } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type CatalogMember, type CatalogPage } from "@/lib/api";
import { AuditTable } from "../../audit-table";
import { requestDeletion, requestRemoval } from "../../cases/actions";
import { ConsoleNav, requireConsole } from "../../console-nav";
import {
addOperator,
createInvite,
decideMember,
delist,
removeFromTeam,
revokeInvite,
rotateKey,
saveCatalog,
setSuspended,
} from "../actions";
export const metadata: Metadata = { title: "Catalog" };
type Perm = CatalogPage["permissions"][number];
export default async function ManageCatalog({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { me } = await requireConsole(`/console/catalogs/${id}`);
const answer = await api.catalogs({ id }).get();
if (answer.error) notFound();
const data = unwrap(answer);
const has = (p: Perm) => data.permissions.includes(p);
if (!has("audit:read")) notFound();
const c = data.catalog;
const [team, members, invites, hosted, audit] = await Promise.all([
api.catalogs({ id }).team.get().then(unwrap),
has("member:approve") ? api.catalogs({ id }).members.get().then(unwrap) : undefined,
has("member:invite") ? api.catalogs({ id }).invites.get().then(unwrap) : undefined,
api.catalogs({ id }).products.get().then(unwrap),
api.console.audit.get({ query: { catalog: id, limit: 20 } }).then(unwrap),
]);
const byStatus = (s: CatalogMember["status"]) => members?.members.filter((m) => m.status === s) ?? [];
const onTeam = team.roles.some((r) => r.personId === me.id);
return (
<>
<PageHeader title={c.name}>
<span className="flex flex-wrap items-center gap-2">
<CatalogStatus status={c.status} />
<span className="font-mono text-xs">{c.url}</span>
{c.status === "approved" && (
<Link href={`/catalogs/${id}`} className="text-sm underline-offset-4 hover:underline">
Public page
</Link>
)}
</span>
</PageHeader>
<ConsoleNav />
<div className="space-y-6">
{(c.status === "suspended" || c.status === "rejected") && (
<Alert>
<AlertTitle>{c.status === "suspended" ? "Suspended by moderation" : "Rejected"}</AlertTitle>
<AlertDescription>
{c.statusReason || "No reason given."}{" "}
{c.status === "suspended" && "Its products aren't available from it, and it can't take uploads."}
</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Stat label="Products" value={String(data.hosted.products)} note={`${data.hosted.revisions} revisions`} />
<Stat label="Storage" value={formatSize(data.hosted.bytes)} note="in the revisions it hosts" />
{data.members && (
<>
<Stat label="Members" value={String(data.members.members)} note={`${data.members.banned} banned`} />
<Stat label="Requests" value={String(data.members.requests)} note="waiting" />
</>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
{!has("catalog:edit") && <CardDescription>Only the owner and operators change these.</CardDescription>}
</CardHeader>
<CardContent>
<ActionForm action={saveCatalog} fields={{ id }}>
<fieldset disabled={!has("catalog:edit")} className="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={c.name} required maxLength={64} />
</Field>
<Field>
<FieldLabel htmlFor="url">Address</FieldLabel>
<Input id="url" name="url" type="url" defaultValue={c.url} required maxLength={300} />
</Field>
<Field>
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
<Input id="contact" name="contact" defaultValue={c.contact} required maxLength={200} />
</Field>
<Choice
name="visibility"
label="Visibility"
value={c.visibility}
options={[
["public", "Public (listed)"],
["private", "Private (by link)"],
]}
/>
<Choice
name="membership"
label="Membership"
value={c.membership}
options={[
["open", "Open"],
["approval", "By approval"],
["invite", "Invite only"],
]}
/>
<Choice
name="maxRating"
label="Highest rating"
value={c.maxRating}
options={[
["general", "General only"],
["adult", "Adult too"],
]}
/>
<Choice
name="uploads"
label="Who may upload"
value={c.uploads}
options={[
["anyone", "Anyone"],
["members", "Members"],
["team", "Only the team"],
]}
/>
<Choice
name="mirrors"
label="Mirroring others' products"
value={c.mirrors}
options={[
["auto", "Accept"],
["approve", "Team approves"],
["off", "Off"],
]}
/>
</fieldset>
{has("catalog:edit") && <SubmitButton pendingLabel="Saving…">Save</SubmitButton>}
</ActionForm>
</CardContent>
</Card>
{has("catalog:rotate-key") && (
<Card>
<CardHeader>
<CardTitle>Key</CardTitle>
<CardDescription>
The catalog signs in to the hub with its ID ({id}) and key. A new key stops the old one at once.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={rotateKey} fields={{ id }}>
<SubmitButton variant="outline" pendingLabel="Making one…" confirm="The old key stops working at once. Go on?">
New key
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Team</CardTitle>
<CardDescription>The owner, and operators: they manage everything but the team itself.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="divide-y text-sm">
{team.owner && (
<li className="flex items-center justify-between py-2">
<span>{team.owner.username}</span>
<Tag tone="strong">owner</Tag>
</li>
)}
{team.roles.map((r) => (
<li key={r.personId + r.role} className="flex items-center justify-between gap-2 py-2">
<span>{r.username}</span>
<span className="flex items-center gap-2">
<Tag>operator</Tag>
{(has("team:manage") || r.personId === me.id) && (
<ActionForm action={removeFromTeam} fields={{ id, username: r.username }}>
<SubmitButton
size="sm"
variant="outline"
pendingLabel="Removing…"
confirm={r.personId === me.id ? "Leave this catalog's team?" : `Take ${r.username} off the team?`}
>
{r.personId === me.id ? "Leave" : "Remove"}
</SubmitButton>
</ActionForm>
)}
</span>
</li>
))}
</ul>
{has("team:manage") && (
<ActionForm action={addOperator} fields={{ id }}>
<div className="flex max-w-md gap-2">
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
<SubmitButton pendingLabel="Adding…">Add operator</SubmitButton>
</div>
</ActionForm>
)}
{!onTeam && team.owner?.personId !== me.id && (
<p className="text-sm text-muted-foreground">You see this as a moderator.</p>
)}
</CardContent>
</Card>
{members && (
<Card>
<CardHeader>
<CardTitle>Members</CardTitle>
<CardDescription>
{c.membership === "open"
? "Open: anyone can get its products. Bans still apply."
: c.membership === "approval"
? "By approval: people ask, the team decides."
: "Invite only: people join with an invitation."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<MemberList id={id} title="Requests" members={byStatus("requested")} actions={["approve", "decline"]} />
<MemberList id={id} title="Members" members={byStatus("member")} actions={["ban", "remove"]} />
<MemberList id={id} title="Banned" members={byStatus("banned")} actions={["unban"]} />
<ActionForm action={decideMember} fields={{ id, decision: "add" }}>
<div className="flex max-w-md gap-2">
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
<SubmitButton variant="outline" pendingLabel="Adding…">
Add member
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
{invites && (
<Card>
<CardHeader>
<CardTitle>Invitations</CardTitle>
<CardDescription>Links that let people join (also an approval or invite-only catalog) at once.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{invites.invites.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Link</TableHead>
<TableHead>Used</TableHead>
<TableHead>Expires</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{invites.invites.map((i) => (
<TableRow key={i.code}>
<TableCell className="font-mono text-xs break-all whitespace-normal">{i.url}</TableCell>
<TableCell className="tabular-nums">
{i.uses}
{i.maxUses !== null && ` of ${i.maxUses}`}
</TableCell>
<TableCell className="text-muted-foreground">
{i.expiresAt ? <DateTime iso={i.expiresAt} dateOnly /> : "never"}
</TableCell>
<TableCell className="text-right">
<ActionForm action={revokeInvite} fields={{ id, code: i.code }}>
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…">
Revoke
</SubmitButton>
</ActionForm>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<ActionForm action={createInvite} fields={{ id }}>
<div className="flex flex-wrap items-end gap-2">
<Field className="w-36">
<FieldLabel htmlFor="maxUses">Uses</FieldLabel>
<Input id="maxUses" name="maxUses" type="number" min={1} max={10000} placeholder="Any" />
</Field>
<Field className="w-36">
<FieldLabel htmlFor="expiresInDays">Days</FieldLabel>
<Input id="expiresInDays" name="expiresInDays" type="number" min={1} max={365} placeholder="Never" />
</Field>
<SubmitButton pendingLabel="Making it…">New invitation</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Products</CardTitle>
<CardDescription>What it hosts. Delisting stops hosting a product here; other catalogs may still host it.</CardDescription>
</CardHeader>
<CardContent>
{hosted.products.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Creator</TableHead>
<TableHead>Revisions</TableHead>
<TableHead>Since</TableHead>
{has("hosting:delist") && <TableHead />}
</TableRow>
</TableHeader>
<TableBody>
{hosted.products.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
{p.name}
</Link>{" "}
<RatingTag rating={p.rating === "adult" ? "adult" : "general"} />{" "}
{p.removed && <Tag tone="danger">removed</Tag>}
{p.deleted && <Tag>deleted</Tag>}
</TableCell>
<TableCell>{p.creator}</TableCell>
<TableCell className="tabular-nums">{p.revisions.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={p.since} dateOnly />
</TableCell>
{has("hosting:delist") && (
<TableCell className="text-right">
<ActionForm action={delist} fields={{ id, product: p.id }}>
<div className="flex justify-end gap-2">
<Input name="reason" placeholder="Reason" aria-label="Reason" required maxLength={500} className="h-8 w-40" />
<SubmitButton size="sm" variant="outline" pendingLabel="Delisting…" confirm={`Stop hosting ${p.name}?`}>
Delist
</SubmitButton>
</div>
</ActionForm>
{!p.removed && (
<details className="mt-1 text-left text-xs">
<summary className="cursor-pointer text-right text-muted-foreground hover:text-foreground">
Ask moderators to remove it everywhere
</summary>
<ActionForm action={requestRemoval} fields={{ catalog: id, product: p.id }} className="mt-2">
<div className="flex justify-end gap-2">
<Input name="reason" placeholder="Why" aria-label="Why" required maxLength={2000} className="h-8 w-48" />
<SubmitButton size="sm" variant="outline" pendingLabel="Asking…">
Ask
</SubmitButton>
</div>
</ActionForm>
</details>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{has("catalog:suspend") && (c.status === "approved" || c.status === "suspended") && (
<Card>
<CardHeader>
<CardTitle>Moderation</CardTitle>
<CardDescription>
A suspended catalog's products aren't available from it, it can't take uploads, and its team sees why.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={setSuspended} fields={{ id }}>
{c.status === "suspended" ? (
<SubmitButton name="intent" value="reinstate" pendingLabel="Reinstating…">
Reinstate
</SubmitButton>
) : (
<div className="flex max-w-xl gap-2">
<Input name="reason" placeholder="Reason (shown to its team)" aria-label="Reason" required maxLength={500} />
<SubmitButton name="intent" value="suspend" variant="outline" pendingLabel="Suspending…" confirm={`Suspend ${c.name}?`}>
Suspend
</SubmitButton>
</div>
)}
</ActionForm>
</CardContent>
</Card>
)}
{has("catalog:delete-request") && c.status !== "deleted" && (
<Card>
<CardHeader>
<CardTitle>Delete Catalog</CardTitle>
<CardDescription>
Moderators review deletions for now. Once deleted, nothing is available from it; people who got its products
keep them only where other catalogs host them.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={requestDeletion} fields={{ id }}>
<div className="flex max-w-xl gap-2">
<Input name="reason" placeholder="Why" aria-label="Why" required maxLength={2000} />
<SubmitButton variant="destructive" pendingLabel="Asking…" confirm={`Ask moderators to delete ${c.name}?`}>
Ask to delete
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Recent Changes</CardTitle>
<CardDescription>
<Link href={`/console/audit?catalog=${id}`} className="underline-offset-4 hover:underline">
All changes to this catalog
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<AuditTable entries={audit.entries} />
</CardContent>
</Card>
</div>
</>
);
}
function Stat({ label, value, note }: { label: string; value: string; note: string }) {
return (
<Card>
<CardHeader>
<CardDescription>{label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">{note}</CardContent>
</Card>
);
}
function Choice({ name, label, value, options }: { name: string; label: string; value: string; options: [string, string][] }) {
return (
<Field>
<FieldLabel htmlFor={name}>{label}</FieldLabel>
<NativeSelect id={name} name={name} defaultValue={value} className="w-full">
{options.map(([v, l]) => (
<NativeSelectOption key={v} value={v}>
{l}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
);
}
const memberActions = {
approve: { label: "Approve", variant: "default" as const },
decline: { label: "Decline", variant: "outline" as const },
ban: { label: "Ban", variant: "outline" as const },
remove: { label: "Remove", variant: "outline" as const },
unban: { label: "Unban", variant: "outline" as const },
};
function MemberList({
id,
title,
members,
actions,
}: {
id: string;
title: string;
members: CatalogMember[];
actions: (keyof typeof memberActions)[];
}) {
if (members.length === 0) return null;
return (
<div className="space-y-2">
<h3 className="text-sm font-medium">
{title} <span className="text-muted-foreground tabular-nums">{members.length}</span>
</h3>
<ul className="divide-y text-sm">
{members.map((m) => (
<li key={m.personId} className="flex items-center justify-between gap-2 py-2">
<span>
{m.username}{" "}
<span className="text-muted-foreground">
{m.invited ? "invited, " : ""}
<DateTime iso={m.since} dateOnly />
</span>
</span>
<ActionForm action={decideMember} fields={{ id, username: m.username }}>
<div className="flex gap-2">
{actions.map((a) => (
<SubmitButton
key={a}
size="sm"
variant={memberActions[a].variant}
name="decision"
value={a}
confirm={a === "ban" ? `Ban ${m.username} from this catalog?` : undefined}
>
{memberActions[a].label}
</SubmitButton>
))}
</div>
</ActionForm>
</li>
))}
</ul>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use server";
// Managing a catalog from the console: settings, key, team, members, invitations, what it hosts, suspension.
import { revalidatePath } from "next/cache";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
/** Runs a change to a catalog, then shows its pages again. */
async function onCatalog(form: FormData, change: (id: string) => Promise<string | void>): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const notice = await change(id);
revalidatePath(`/console/catalogs/${id}`);
revalidatePath(`/catalogs/${id}`);
revalidatePath("/console/catalogs");
revalidatePath("/catalogs");
return notice;
});
}
const pick = <T extends string>(v: string, options: readonly T[]): T | undefined => options.find((o) => o === v);
export async function saveCatalog(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(
await api.catalogs({ id }).patch({
name: field(form, "name"),
url: field(form, "url"),
contact: field(form, "contact"),
visibility: pick(field(form, "visibility"), ["public", "private"] as const),
membership: pick(field(form, "membership"), ["open", "approval", "invite"] as const),
maxRating: pick(field(form, "maxRating"), ["general", "adult"] as const),
uploads: pick(field(form, "uploads"), ["anyone", "members", "team"] as const),
mirrors: pick(field(form, "mirrors"), ["auto", "approve", "off"] as const),
}),
);
return "Saved.";
});
}
export async function rotateKey(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const { key } = unwrap(await api.catalogs({ id }).key.post());
return `New key (shown once; put it in the catalog's CATALOG_KEY and restart it): ${key}`;
});
}
export async function setSuspended(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
if (field(form, "intent") === "reinstate") unwrap(await api.catalogs({ id }).reinstate.post());
else unwrap(await api.catalogs({ id }).suspend.post({ reason: field(form, "reason") }));
});
}
export async function addOperator(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const username = field(form, "username");
unwrap(await api.catalogs({ id }).team({ username }).put({ role: "catalog-operator" }));
return `${username} is an operator now.`;
});
}
export async function removeFromTeam(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(await api.catalogs({ id }).team({ username: field(form, "username") }).delete({}, { query: { role: "catalog-operator" } }));
});
}
/** A member or request: approve, decline, add, ban, unban or remove. */
export async function decideMember(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const member = api.catalogs({ id }).members({ username: field(form, "username") });
switch (field(form, "decision")) {
case "approve":
case "add":
unwrap(await member.put({ status: "member" }));
break;
case "ban":
unwrap(await member.put({ status: "banned" }));
break;
default: // decline, unban, remove
unwrap(await member.delete());
}
});
}
export async function createInvite(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const maxUses = Number(field(form, "maxUses")) || undefined;
const expiresInDays = Number(field(form, "expiresInDays")) || undefined;
const { invite } = unwrap(await api.catalogs({ id }).invites.post({ maxUses, expiresInDays }));
return `Invitation link: ${invite.url}`;
});
}
export async function revokeInvite(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
unwrap(await api.catalogs({ id }).invites({ code: field(form, "code") }).delete());
});
}
export async function delist(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
const product = field(form, "product");
unwrap(await api.catalogs({ id }).products({ product }).delete({}, { query: { reason: field(form, "reason") } }));
revalidatePath(`/products/${product}`);
});
}
/** Someone joins a catalog (or asks to), with an invitation code if they have one; or leaves it. */
export async function membership(_: ActionState, form: FormData): Promise<ActionState> {
return onCatalog(form, async (id) => {
if (field(form, "intent") === "leave") {
unwrap(await api.catalogs({ id }).join.delete());
revalidatePath("/library");
return "You left.";
}
const invite = field(form, "invite");
const { membership } = unwrap(await api.catalogs({ id }).join.post(invite ? { invite } : {}));
revalidatePath("/library");
return membership === "member" ? "You're a member." : "Asked. The catalog's team will decide.";
});
}
+92
View File
@@ -0,0 +1,92 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CatalogStatus, membership } from "@/components/catalogs";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap } from "@/lib/api";
import { decideCatalog } from "../actions";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Catalogs" };
export default async function ConsoleCatalogs() {
await requireConsole("/console/catalogs");
const { catalogs, moderator } = unwrap(await api.console.catalogs.get());
return (
<>
<PageHeader title="Catalogs">{moderator ? "Every registered catalog." : "Catalogs you're on the team of."}</PageHeader>
<ConsoleNav />
<Card>
<CardContent>
{catalogs.length === 0 ? (
<p className="text-sm text-muted-foreground">None registered.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Address</TableHead>
<TableHead>Access</TableHead>
<TableHead>Registered</TableHead>
<TableHead>Status</TableHead>
{moderator && <TableHead />}
</TableRow>
</TableHeader>
<TableBody>
{catalogs.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">
<Link href={`/console/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
{c.name}
</Link>{" "}
{c.mine ? <Tag>owner</Tag> : c.team ? <Tag>team</Tag> : null}
</TableCell>
<TableCell className="font-mono text-xs">{c.url}</TableCell>
<TableCell className="text-muted-foreground">
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={c.createdAt} dateOnly />
</TableCell>
<TableCell>
<CatalogStatus status={c.status} />
</TableCell>
{moderator && (
<TableCell className="text-right">
{(c.status === "pending" || c.status === "rejected") && (
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
<div className="flex justify-end gap-2">
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
{c.status !== "rejected" && (
<SubmitButton
size="sm"
variant="outline"
name="decision"
value="reject"
pendingLabel="Rejecting…"
confirm={`Reject ${c.name}?`}
>
Reject
</SubmitButton>
)}
</div>
</ActionForm>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
);
}
+44 -6
View File
@@ -1,8 +1,9 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { cache } from "react";
import { NavLink } from "@/components/header-client"; import { NavLink } from "@/components/header-client";
import { getMe, isModerator, type Person } from "@/lib/api"; import { api, getMe, isModerator, unwrap, type ConsoleData, type Person } from "@/lib/api";
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */ /** The signed-in moderator, or off to sign in; others get not found (these pages aren't for them). */
export async function requireModerator(path: string): Promise<Person> { export async function requireModerator(path: string): Promise<Person> {
const me = await getMe(); const me = await getMe();
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`); if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
@@ -10,13 +11,50 @@ export async function requireModerator(path: string): Promise<Person> {
return me; return me;
} }
export function ConsoleNav() { /** The console as this person sees it (once per request); undefined if it isn't for them. */
const loadConsole = cache(async (): Promise<ConsoleData | undefined> => {
const answer = await api.console.get();
if (answer.error) return undefined;
return unwrap(answer);
});
/**
* The signed-in person and their console, or off to sign in. People who manage nothing get not found: the console
* is for those controlling a product, catalog or room.
*/
export async function requireConsole(path: string): Promise<{ me: Person; console: ConsoleData }> {
const me = await getMe();
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
const data = await loadConsole();
if (!data) notFound();
return { me, console: data };
}
type Section = ConsoleData["sections"][number];
// Settings are on the overview.
const tabs: { section: Section; href: string; label: string }[] = [
{ section: "overview", href: "/console", label: "Overview" },
{ section: "products", href: "/console/products", label: "Products" },
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
{ section: "cases", href: "/console/cases", label: "Cases" },
{ section: "people", href: "/console/people", label: "People" },
{ section: "blocks", href: "/console/blocks", label: "Blocks" },
{ section: "audit", href: "/console/audit", label: "Audit" },
];
/** The console's sections this person may see. */
export async function ConsoleNav() {
const sections = new Set<Section>((await loadConsole())?.sections ?? ["overview"]);
return ( return (
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm"> <nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
<NavLink href="/console" exact> {tabs
Overview .filter((t) => sections.has(t.section))
.map((t) => (
<NavLink key={t.section} href={t.href} exact={t.section === "overview"}>
{t.label}
</NavLink> </NavLink>
<NavLink href="/console/people">People</NavLink> ))}
</nav> </nav>
); );
} }
+96 -57
View File
@@ -1,59 +1,107 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { OnlineList } from "@/components/online-list"; import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status"; import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { api, unwrap, type AuditEntry } from "@/lib/api"; import { api, unwrap } from "@/lib/api";
import { ConsoleNav, requireModerator } from "./console-nav"; import { setRegistration } from "./actions";
import { AuditTable } from "./audit-table";
import { ConsoleNav, requireConsole } from "./console-nav";
export const metadata: Metadata = { title: "Console" }; export const metadata: Metadata = { title: "Console" };
type Stat = { label: string; value: number; note: string; href?: string };
export default async function Console() { export default async function Console() {
await requireModerator("/console"); const { me, console: data } = await requireConsole("/console");
const data = unwrap(await api.console.get()); const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
const stats = [
{ label: "People", value: data.counts.people, note: "accounts" }, const todo: Stat[] = [
{ label: "IMVU accounts", value: data.counts.links, note: "linked" }, { label: "Cases", value: data.todo.cases, note: "open, for you to decide", href: "/console/cases" },
{ label: "Computers", value: data.counts.computers, note: "signed in" }, { label: "Drafts", value: data.todo.drafts, note: "to review and publish", href: "/console/products?status=draft" },
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
]; ];
if (data.moderation) {
todo.push(
{ label: "Catalogs", value: data.todo.pendingCatalogs, note: "waiting for approval", href: "/console/catalogs" },
{ label: "Accounts", value: data.todo.waitingPeople, note: "waiting for approval", href: "/console/people?waiting=true" },
);
}
const mine: Stat[] = [
{ label: "Your Products", value: data.mine.products, note: "you created", href: "/console/products" },
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
];
const hub: Stat[] = data.moderation
? [
{ label: "People", value: data.moderation.counts.people, note: "accounts" },
{ label: "IMVU Accounts", value: data.moderation.counts.links, note: "linked" },
{ label: "Computers", value: data.moderation.counts.computers, note: "signed in" },
]
: [];
return ( return (
<> <>
<PageHeader title="Console">Moderation.</PageHeader> <PageHeader title="Console">What you manage on T5DE.</PageHeader>
<ConsoleNav /> <ConsoleNav />
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3"> <StatGrid stats={todo.filter((s) => s.value > 0 || data.moderation)} />
{stats.map((s) => ( <StatGrid stats={mine.filter((s) => s.value > 0)} />
<Card key={s.label}> {hub.length > 0 && <StatGrid stats={hub} />}
<CardHeader>
<CardDescription>{s.label}</CardDescription>
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">{s.note}</CardContent>
</Card>
))}
</div>
{data.moderation && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Online now</CardTitle> <CardTitle>Online Now</CardTitle>
<CardDescription> <CardDescription>
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded {data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when
the page loaded
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<OnlineList initial={data.online} moderator /> <OnlineList initial={data.moderation.online} moderator />
</CardContent> </CardContent>
</Card> </Card>
)}
{settings && (
<Card id="settings">
<CardHeader>
<CardTitle>Registration</CardTitle>
<CardDescription>
{settings.registration === "closed"
? "Closed: new accounts can sign in, but wait for a moderator's approval."
: "Open: new accounts can use the hub at once."}
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={setRegistration}>
<div className="flex gap-2">
<NativeSelect name="registration" defaultValue={settings.registration} aria-label="Registration">
<NativeSelectOption value="open">Open</NativeSelectOption>
<NativeSelectOption value="closed">Closed</NativeSelectOption>
</NativeSelect>
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Audit log</CardTitle> <CardTitle>Recent Changes</CardTitle>
<CardDescription>The latest 50 changes.</CardDescription> <CardDescription>
{data.moderation ? "Across the hub." : "To what you manage, and by you."}{" "}
<Link href="/console/audit" className="underline-offset-4 hover:underline">
Full audit log
</Link>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AuditLog entries={data.audit} /> <AuditTable entries={data.audit} />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -61,36 +109,27 @@ export default async function Console() {
); );
} }
function AuditLog({ entries }: { entries: AuditEntry[] }) { function StatGrid({ stats }: { stats: Stat[] }) {
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>; if (stats.length === 0) return null;
return ( return (
<Table> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<TableHeader> {stats.map((s) => (
<TableRow> <Card key={s.label}>
<TableHead>When</TableHead> <CardHeader>
<TableHead>Who</TableHead> <CardDescription>{s.label}</CardDescription>
<TableHead>What</TableHead> <CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
<TableHead>Target</TableHead> </CardHeader>
<TableHead>Detail</TableHead> <CardContent className="text-sm text-muted-foreground">
</TableRow> {s.href ? (
</TableHeader> <Link href={s.href} className="underline-offset-4 hover:underline">
<TableBody> {s.note}
{entries.map((e, i) => ( </Link>
<TableRow key={i}> ) : (
<TableCell className="text-muted-foreground"> s.note
<DateTime iso={e.at} /> )}
</TableCell> </CardContent>
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell> </Card>
<TableCell className="font-mono text-xs">{e.action}</TableCell>
<TableCell>{e.target}</TableCell>
<TableCell className="text-xs whitespace-normal text-muted-foreground">
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(", ")}
</TableCell>
</TableRow>
))} ))}
</TableBody> </div>
</Table>
); );
} }
+31 -1
View File
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, ApiError, unwrap } from "@/lib/api"; import { api, ApiError, unwrap } from "@/lib/api";
import { ban, setLinkCap, setRole, unban } from "../../actions"; import { ban, decideAccount, setLinkCap, setRole, unban } from "../../actions";
import { ConsoleNav, requireModerator } from "../../console-nav"; import { ConsoleNav, requireModerator } from "../../console-nav";
export const metadata: Metadata = { title: "Person" }; export const metadata: Metadata = { title: "Person" };
@@ -35,11 +35,41 @@ export default async function PersonPage({ params }: { params: Promise<{ id: str
joined <DateTime iso={person.createdAt} dateOnly /> joined <DateTime iso={person.createdAt} dateOnly />
</span> </span>
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>} {person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
{!person.approved && <Tag tone="strong">waiting for approval</Tag>}
</span> </span>
</PageHeader> </PageHeader>
<ConsoleNav /> <ConsoleNav />
<div className="space-y-6"> <div className="space-y-6">
{!person.approved && (
<Card>
<CardHeader>
<CardTitle>Waiting for Approval</CardTitle>
<CardDescription>
{person.email}. Until approved, they can only sign in. Declining deletes the account.
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={decideAccount} fields={{ id: person.id }}>
<div className="flex gap-2">
<SubmitButton name="decision" value="approve" pendingLabel="Approving…">
Approve
</SubmitButton>
<SubmitButton
name="decision"
value="decline"
variant="outline"
pendingLabel="Declining…"
confirm={`Decline ${person.username}? Their account is deleted.`}
>
Decline
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>IMVU accounts</CardTitle> <CardTitle>IMVU accounts</CardTitle>
+25 -8
View File
@@ -11,11 +11,17 @@ import { ConsoleNav, requireModerator } from "../console-nav";
export const metadata: Metadata = { title: "People" }; export const metadata: Metadata = { title: "People" };
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) { export default async function People({
const raw = (await searchParams).q; searchParams,
const q = typeof raw === "string" ? raw.slice(0, 64) : ""; }: {
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people"); searchParams: Promise<{ q?: string | string[]; waiting?: string | string[] }>;
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} })); }) {
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.slice(0, 64) : "";
const waiting = params.waiting === "true";
const query = new URLSearchParams({ ...(q ? { q } : {}), ...(waiting ? { waiting: "true" } : {}) }).toString();
await requireModerator(`/console/people${query ? `?${query}` : ""}`);
const { people } = unwrap(await api.console.people.get({ query: { ...(q ? { q } : {}), ...(waiting ? { waiting: true } : {}) } }));
return ( return (
<> <>
@@ -23,14 +29,23 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<ConsoleNav /> <ConsoleNav />
<Card> <Card>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<form action="/console/people" className="flex max-w-md gap-2"> <div className="flex flex-wrap items-center gap-4">
<form action="/console/people" className="flex max-w-md flex-1 gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" /> <Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
{waiting && <input type="hidden" name="waiting" value="true" />}
<Button type="submit" variant="outline"> <Button type="submit" variant="outline">
Search Search
</Button> </Button>
</form> </form>
<Link
href={waiting ? "/console/people" : "/console/people?waiting=true"}
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
{waiting ? "Show everyone" : "Waiting for approval"}
</Link>
</div>
{people.length === 0 ? ( {people.length === 0 ? (
<p className="text-sm text-muted-foreground">Nobody found.</p> <p className="text-sm text-muted-foreground">{waiting ? "Nobody is waiting." : "Nobody found."}</p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -55,7 +70,9 @@ export default async function People({ searchParams }: { searchParams: Promise<{
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
<DateTime iso={p.createdAt} dateOnly /> <DateTime iso={p.createdAt} dateOnly />
</TableCell> </TableCell>
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell> <TableCell className="text-right">
{p.banned ? <Tag tone="danger">banned</Tag> : !p.approved && <Tag tone="strong">waiting</Tag>}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
+143
View File
@@ -0,0 +1,143 @@
import type { Metadata } from "next";
import Link from "next/link";
import { RatingTag, StateTags } from "@/components/products";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ActionForm, SubmitButton } from "@/components/forms";
import { api, unwrap } from "@/lib/api";
import { restoreProduct } from "../../products/actions";
import { ConsoleNav, requireConsole } from "../console-nav";
export const metadata: Metadata = { title: "Products" };
const statuses = [
["", "Any status"],
["draft", "Drafts"],
["published", "Published"],
["unlisted", "Unlisted"],
["locked", "Locked"],
["deleted", "Deleted"],
["removed", "Removed"],
] as const;
type Status = Exclude<(typeof statuses)[number][0], "">;
export default async function ConsoleProducts({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
await requireConsole("/console/products");
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.trim().slice(0, 100) : "";
const status = statuses.find(([s]) => s && s === params.status)?.[0] as Status | undefined;
const all = params.all === "1";
const data = unwrap(
await api.console.products.get({
query: { ...(q ? { q } : {}), ...(status ? { status } : {}), ...(all ? { all: "1" as const } : {}) },
}),
);
const filtered = q !== "" || status !== undefined;
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">
{data.all ? "Everyone's products, drafts and removed ones included." : "Your products, drafts included."}
</PageHeader>
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product
</Link>
</div>
<ConsoleNav />
<form action="/console/products" className="mb-6 flex flex-wrap gap-2">
<Input
name="q"
defaultValue={q}
placeholder={data.all ? "Name, ID or creator" : "Name or ID"}
aria-label="Search"
className="max-w-xs"
/>
<NativeSelect name="status" defaultValue={status ?? ""} aria-label="Status" className="w-40">
{statuses.map(([value, label]) => (
<NativeSelectOption key={value} value={value}>
{label}
</NativeSelectOption>
))}
</NativeSelect>
{data.moderator && (
<NativeSelect name="all" defaultValue={all ? "1" : ""} aria-label="Whose" className="w-40">
<NativeSelectOption value="">Yours</NativeSelectOption>
<NativeSelectOption value="1">Everyone&apos;s</NativeSelectOption>
</NativeSelect>
)}
<Button type="submit">Filter</Button>
{(filtered || all) && (
<Link href="/console/products" className={buttonVariants({ variant: "outline" })}>
Clear
</Link>
)}
</form>
<Card>
<CardContent>
{data.products.length === 0 ? (
<p className="text-sm text-muted-foreground">{filtered ? "Nothing matches." : "None yet."}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{data.all && <TableHead>Creator</TableHead>}
<TableHead>State</TableHead>
<TableHead className="text-right">Libraries</TableHead>
<TableHead>Changed</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{data.products.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
{p.name}
</Link>
</TableCell>
{data.all && <TableCell className="text-muted-foreground">{p.creator}</TableCell>}
<TableCell>
<span className="flex flex-wrap gap-1">
<StateTags product={p} />
<RatingTag rating={p.rating} />
</span>
</TableCell>
<TableCell className="text-right tabular-nums">{p.libraries}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={p.updatedAt} dateOnly />
</TableCell>
<TableCell className="text-right">
{!p.removed ? (
<Link href={`/products/${p.id}/edit`} className={buttonVariants({ size: "sm", variant: "outline" })}>
{p.status === "draft" ? "Review" : "Edit"}
</Link>
) : (
data.moderator && (
<ActionForm action={restoreProduct} fields={{ id: p.id }}>
<div className="flex justify-end gap-2">
<Input name="reason" placeholder="Reason" aria-label="Reason" maxLength={500} className="h-8 w-36" />
<SubmitButton size="sm" variant="outline" pendingLabel="Restoring…" confirm={`Restore ${p.name}?`}>
Restore
</SubmitButton>
</div>
</ActionForm>
)
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
// Where creating a product used to be.
export default function Create() {
redirect("/products/new");
}
+33
View File
@@ -129,3 +129,36 @@
@apply font-sans; @apply font-sans;
} }
} }
/* Product descriptions: creators' HTML (cleaned by the API), laid out plainly. */
@layer components {
.product-description {
@apply space-y-3 text-sm leading-relaxed break-words;
}
.product-description :where(h1, h2, h3, h4, h5, h6) {
@apply font-semibold;
}
.product-description :where(h1) {
@apply text-xl;
}
.product-description :where(h2) {
@apply text-lg;
}
.product-description :where(a) {
@apply underline underline-offset-4;
}
.product-description :where(ul) {
@apply list-disc pl-5;
}
.product-description :where(ol) {
@apply list-decimal pl-5;
}
.product-description :where(img) {
@apply inline-block h-auto max-w-full;
}
.product-description :where(td, th) {
@apply border px-2 py-1;
}
.product-description :where(hr) {
@apply my-4;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
rect { fill: #171717; } text { fill: #fafafa; }
@media (prefers-color-scheme: dark) { rect { fill: #fafafa; } text { fill: #171717; } }
</style>
<rect width="32" height="32" rx="7"/>
<text x="16" y="21.5" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="700">T5</text>
</svg>

Before

Width:  |  Height:  |  Size: 397 B

+16 -2
View File
@@ -1,6 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import { NarrowCard } from "@/components/narrow-card";
import { StagingBanner } from "@/components/staging-banner";
import { getMe } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import "./globals.css"; import "./globals.css";
@@ -12,12 +15,23 @@ export const metadata: Metadata = {
description: "One account for all your IMVU avatars.", description: "One account for all your IMVU avatars.",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const me = await getMe();
return ( return (
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}> <html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
<body className="flex min-h-dvh flex-col"> <body className="flex min-h-dvh flex-col">
<StagingBanner />
<Header /> <Header />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main> <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">
{me && !me.approved ? (
<NarrowCard
title="Waiting for Approval"
description={`Thanks for signing up, ${me.username}. A moderator will approve your account soon; until then you can't use the hub.`}
/>
) : (
children
)}
</main>
<footer className="border-t"> <footer className="border-t">
<div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6"> <div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6">
T5DE is unofficial: not made by or affiliated with IMVU. T5DE is unofficial: not made by or affiliated with IMVU.
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { Empty, ProductCard } from "@/components/products";
import { PageHeader } from "@/components/status";
import { api, getMe, unwrap } from "@/lib/api";
import { libraryAction } from "../products/actions";
export const metadata: Metadata = { title: "Library" };
export default async function Library() {
const me = await getMe();
if (!me) redirect("/signin?next=/library");
const { library } = await api.library.get().then(unwrap);
return (
<>
<PageHeader title="Your library">Products your T5DE uses, on all your IMVU accounts.</PageHeader>
{library.length === 0 ? (
<Empty>
Nothing yet.{" "}
<Link href="/products" className="text-foreground underline underline-offset-4">
Browse products
</Link>
.
</Empty>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{library.map(({ product, stayOn }) => (
<li key={product.id}>
<ProductCard product={product}>
{stayOn !== null && <p className="text-xs">Staying on revision {stayOn}</p>}
<ActionForm action={libraryAction} fields={{ id: product.id }} className="pt-1">
<SubmitButton size="sm" variant="outline" name="intent" value="remove" pendingLabel="Removing…">
Remove
</SubmitButton>
</ActionForm>
</ProductCard>
</li>
))}
</ul>
)}
</>
);
}
+117
View File
@@ -0,0 +1,117 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { caseKinds } from "@/components/cases";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { api, getMe, unwrap, type NotificationEntry } from "@/lib/api";
import { cn } from "@/lib/utils";
import { markAllRead } from "../console/cases/actions";
export const metadata: Metadata = { title: "Notifications" };
export default async function Notifications({ searchParams }: { searchParams: Promise<{ before?: string | string[] }> }) {
const me = await getMe();
if (!me) redirect("/signin?next=/notifications");
const raw = (await searchParams).before;
const before = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : undefined;
const data = unwrap(await api.notifications.get({ query: before ? { before } : {} }));
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Notifications">
{data.unread > 0 ? `${data.unread} unread.` : "You're up to date."}
</PageHeader>
{data.unread > 0 && (
<ActionForm action={markAllRead}>
<SubmitButton variant="outline" pendingLabel="Marking…">
Mark all as read
</SubmitButton>
</ActionForm>
)}
</div>
<Card>
<CardContent>
{data.notifications.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing yet.</p>
) : (
<ul className="divide-y">
{data.notifications.map((n) => {
const { text, href } = describe(n);
return (
<li key={n.id} className={cn("flex flex-wrap items-baseline gap-x-3 gap-y-1 py-3 first:pt-0 last:pb-0", n.read && "text-muted-foreground")}>
{!n.read && <span className="size-2 shrink-0 self-center rounded-full bg-foreground" aria-label="Unread" />}
<span className="min-w-0 flex-1 text-sm">
{href ? (
<Link href={href} className="underline-offset-4 hover:underline">
{text}
</Link>
) : (
text
)}
</span>
<span className="text-xs text-muted-foreground">
<DateTime iso={n.at} />
</span>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
{data.before && (
<div className="mt-4">
<Link href={`/notifications?before=${data.before}`} className={buttonVariants({ variant: "outline" })}>
Older
</Link>
</div>
)}
</>
);
}
/** A notification in words, and where it leads. */
function describe(n: NotificationEntry): { text: string; href?: string } {
const d = n.data as Record<string, unknown>;
const s = (k: string) => (typeof d[k] === "string" ? (d[k] as string) : "");
const why = s("reason") ? `: “${s("reason")}”` : "";
switch (n.type) {
case "product_edited": {
const what =
s("kind") === "lock"
? "changed the locks on"
: s("kind") === "revert"
? "undid a change to"
: `changed the ${(d.fields as string[] | undefined)?.join(", ") ?? "info"} of`;
return { text: `${s("by") || "A moderator"} ${what} ${s("name")}${why}`, href: `/products/${s("product")}/edit` };
}
case "product_removed":
return { text: `Moderation removed ${s("name")}${why}. You can appeal it from your audit log.`, href: `/console/audit?product=${s("product")}` };
case "product_restored":
return { text: `Moderation restored ${s("name")}${why}`, href: `/products/${s("product")}` };
case "case_decided": {
const status = s("status") === "resolved" ? "resolved" : "declined";
const res = s("resolution") ? `: “${s("resolution")}”` : "";
return { text: `Your ${caseKinds[s("kind")]?.toLowerCase() ?? "case"} was ${status}${res}`, href: `/console/cases/${d.case}` };
}
case "case_note":
return { text: `${s("by") || "Someone"} replied on your ${caseKinds[s("kind")]?.toLowerCase() ?? "case"}`, href: `/console/cases/${d.case}` };
case "membership_decided": {
const status = s("status");
const text =
status === "member"
? `You're a member of ${s("name")}`
: status === "banned"
? `${s("name")} banned you`
: `${s("name")} declined your request to join`;
return { text, href: `/catalogs/${s("catalog")}` };
}
default:
return { text: n.type };
}
}
+331
View File
@@ -0,0 +1,331 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { StateTags } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { UploadCard } from "@/components/upload-card";
import { api, getMe, unwrap, type InfoEntry } from "@/lib/api";
import { publishProduct, revertInfo, saveLocks, saveProduct } from "../../actions";
import { loadProduct } from "../../load";
export const metadata: Metadata = { title: "Edit product" };
const fieldNames: Record<string, string> = {
name: "Name",
description: "Description",
rating: "Rating",
tags: "Tags",
listed: "Listed",
category: "Category",
locked: "Locks",
status: "Status",
};
export default async function EditProduct({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const me = await getMe();
if (!me) redirect(`/signin?next=/products/${id}/edit`);
const { product, mine, permissions } = await loadProduct(id);
const can = (p: string) => (permissions as string[]).includes(p);
if (!can("product:edit")) notFound();
if (mine && product.deleted) redirect(`/products/${id}`);
const [{ entries }, { choices, parent }] = await Promise.all([
api.products({ id }).history.get().then(unwrap),
api.products({ id }).categories.get().then(unwrap),
]);
const path = (c: { name: string }[]) => c.map((x) => x.name).join(" › ");
const editLocked = can("product:edit-locked");
const locked = (f: string) => product.locked.includes(f) && !editLocked;
const draft = product.status === "draft";
return (
<div className="space-y-6">
<PageHeader title={draft ? `Review ${product.name}` : `Edit ${product.name}`}>
<span className="flex flex-wrap items-center gap-2">
<StateTags product={product} />
<Link href={`/products/${id}`} className="underline-offset-4 hover:underline">
{draft ? "Preview its page" : "Back to the product"}
</Link>
</span>
</PageHeader>
{draft && (
<Card>
<CardHeader>
<CardTitle>Draft</CardTitle>
<CardDescription>
Only you and moderators see it. Check its info below, then publish it so people can get it. Drafts not
published within 30 days are deleted.
</CardDescription>
</CardHeader>
{can("product:publish") && (
<CardContent>
<ActionForm action={publishProduct} fields={{ id }}>
<SubmitButton pendingLabel="Publishing…">Publish</SubmitButton>
</ActionForm>
</CardContent>
)}
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Info</CardTitle>
{!mine && <CardDescription>You&apos;re editing someone else&apos;s product: they see your reason.</CardDescription>}
</CardHeader>
<CardContent>
<ActionForm action={saveProduct} fields={{ id }}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input
id="name"
name="name"
defaultValue={product.name}
required
maxLength={64}
disabled={locked("name")}
className="max-w-md"
/>
{locked("name") && <LockedNote />}
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea
id="description"
name="description"
defaultValue={product.description}
maxLength={20000}
rows={8}
disabled={locked("description")}
className="font-mono text-xs md:text-xs"
/>
{locked("description") ? (
<LockedNote />
) : (
<FieldDescription>HTML, as on IMVU. Scripts and unsafe tags are removed.</FieldDescription>
)}
</Field>
<Field>
<FieldLabel htmlFor="tags">Tags</FieldLabel>
<Input
id="tags"
name="tags"
defaultValue={product.tags.join(", ")}
maxLength={300}
disabled={locked("tags")}
placeholder="formal, wool, winter"
className="max-w-md"
/>
{locked("tags") ? <LockedNote /> : <FieldDescription>Up to 10, separated by commas.</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="category">Category</FieldLabel>
{locked("category") || choices.length === 0 ? (
<p className="text-sm">{product.categories.length ? path(product.categories) : "None known"}</p>
) : (
<NativeSelect
id="category"
name="category"
defaultValue={String(product.category?.at(-1)?.id ?? "")}
className="w-full max-w-md"
>
<NativeSelectOption value="">
{parent.length ? `Its parent's: ${path(parent)}` : "None"}
</NativeSelectOption>
{choices
.filter((c) => c.at(-1)!.id !== parent.at(-1)?.id)
.map((c) => (
<NativeSelectOption key={c.at(-1)!.id} value={String(c.at(-1)!.id)}>
{path(c)}
</NativeSelectOption>
))}
</NativeSelect>
)}
{locked("category") ? (
<LockedNote />
) : (
<FieldDescription>
Where it&apos;s filed in the shop and inventory: any category T5DE has seen, within its parent&apos;s
kind of product.
</FieldDescription>
)}
</Field>
<div className="grid gap-6 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
{locked("rating") ? (
<p className="text-sm capitalize">{product.rating}</p>
) : (
<NativeSelect id="rating" name="rating" defaultValue={product.rating} className="w-48">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult">Adult</NativeSelectOption>
</NativeSelect>
)}
{locked("rating") ? (
<LockedNote />
) : (
<FieldDescription>
Raising it to adult is immediate; {can("product:rate-lower") ? "lowering it is too." : "lowering it asks moderators."}
</FieldDescription>
)}
</Field>
<Field>
<FieldLabel htmlFor="listed">Listing</FieldLabel>
{locked("listed") ? (
<p className="text-sm">{product.listed ? "Listed" : "Unlisted"}</p>
) : (
<NativeSelect id="listed" name="listed" defaultValue={product.listed ? "yes" : "no"} className="w-48">
<NativeSelectOption value="yes">Listed</NativeSelectOption>
<NativeSelectOption value="no">Unlisted</NativeSelectOption>
</NativeSelect>
)}
{locked("listed") ? (
<LockedNote />
) : (
<FieldDescription>Unlisted: left out of search and lists; its link still works.</FieldDescription>
)}
</Field>
</div>
{!mine && (
<Field>
<FieldLabel htmlFor="reason">Reason</FieldLabel>
<Input id="reason" name="reason" required maxLength={500} className="max-w-md" />
<FieldDescription>Shown to the creator in the product&apos;s history.</FieldDescription>
</Field>
)}
<div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
</CardContent>
</Card>
{can("product:lock") && (
<Card>
<CardHeader>
<CardTitle>Locks</CardTitle>
<CardDescription>Locked fields can only be changed by moderators.</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={saveLocks} fields={{ id }}>
<div className="flex flex-wrap gap-4">
{(["name", "description", "rating", "tags", "listed", "category"] as const).map((f) => (
<label key={f} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="locked"
value={f}
defaultChecked={product.locked.includes(f)}
className="size-4 accent-foreground"
/>
{fieldNames[f]}
</label>
))}
</div>
<div className="flex flex-wrap gap-2">
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
<SubmitButton variant="outline" pendingLabel="Saving…">
Save locks
</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>History</CardTitle>
<CardDescription>Every change to its info, with who made it and why.</CardDescription>
</CardHeader>
<CardContent>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">No changes yet.</p>
) : (
<ul className="divide-y">
{entries.map((e) => (
<HistoryRow key={e.id} id={id} entry={e} mine={mine} canRevert={can("product:edit")} />
))}
</ul>
)}
</CardContent>
</Card>
{mine && <UploadCard title="Upload a new revision" product={id} />}
</div>
);
}
function LockedNote() {
return <FieldDescription>Locked by a moderator.</FieldDescription>;
}
/** A value as the history shows it. */
function shown(field: string, v: unknown): string {
if (field === "listed") return v ? "listed" : "unlisted";
if (field === "category")
return Array.isArray(v) ? v.map((c: { name?: string }) => c.name ?? "").join(" › ") : "its parent's";
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
if (typeof v === "string") return v || "empty";
return String(v);
}
function HistoryRow({ id, entry: e, mine, canRevert }: { id: string; entry: InfoEntry; mine: boolean; canRevert: boolean }) {
const what =
e.kind === "publish" ? "published it" : e.kind === "lock" ? "changed the locks" : e.kind === "revert" ? "undid a change" : "edited";
return (
<li className="space-y-2 py-4 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium">{e.by || "Someone"}</span>
{!e.byCreator && <Tag>Moderator</Tag>}
<span className="text-muted-foreground">{what}</span>
<span className="text-muted-foreground">
<DateTime iso={e.at} />
</span>
</div>
{e.reason && <p className="text-sm">Reason: {e.reason}</p>}
{e.kind !== "publish" && (
<ul className="space-y-1 text-sm text-muted-foreground">
{Object.entries(e.changes).map(([field, c]) => (
<li key={field}>
<span className="text-foreground">{fieldNames[field] ?? field}</span>
{field === "description" ? (
<details className="mt-1">
<summary className="cursor-pointer hover:text-foreground">Before and after</summary>
<pre className="mt-1 max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
{shown(field, c.from)}
</pre>
<pre className="mt-1 max-h-48 overflow-auto rounded-md bg-muted p-2 text-xs whitespace-pre-wrap">
{shown(field, c.to)}
</pre>
</details>
) : (
<>
: {shown(field, c.from)} → {shown(field, c.to)}
</>
)}
</li>
))}
</ul>
)}
{canRevert && (e.kind === "edit" || e.kind === "revert") && (
<ActionForm action={revertInfo} fields={{ id, entry: e.id }} className="flex flex-wrap items-center gap-2 space-y-0">
{!mine && <Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="h-8 max-w-xs" />}
<SubmitButton size="sm" variant="outline" pendingLabel="Undoing…">
Undo
</SubmitButton>
</ActionForm>
)}
</li>
);
}
+294
View File
@@ -0,0 +1,294 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { ReportForm } from "@/components/cases";
import { formatSize, ProductImage, RatingTag, StateTags } from "@/components/products";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { getMe, isModerator, type Revision } from "@/lib/api";
import { deleteProduct, libraryAction, removeProduct, retract } from "../actions";
import { loadProduct } from "../load";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { product } = await loadProduct((await params).id);
return { title: product.name };
}
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
const { product, revisions, inLibrary, stayOn, mine, available, catalogs, permissions } = data;
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
const draft = product.status === "draft";
return (
<div className="space-y-6">
<PageHeader title={product.name}>
<span className="flex flex-wrap items-center gap-2">
{product.creator && <span>by {product.creator}</span>}
<RatingTag rating={product.rating} />
{/* Its state (draft, unlisted, locks) is for those who manage it; anyone may see that it's deleted. */}
{permissions.includes("product:edit") ? (
<StateTags product={product} />
) : (
product.deleted && <Tag tone="danger">Deleted</Tag>
)}
</span>
</PageHeader>
{draft && (
<p className="text-sm text-muted-foreground">
A draft: only {mine ? "you" : "its creator"} and moderators see it, and nobody can get it until it&apos;s
published.{" "}
{permissions.includes("product:publish") && (
<Link href={`/products/${id}/edit`} className="font-medium text-foreground underline-offset-4 hover:underline">
Review and publish
</Link>
)}
</p>
)}
{!draft && !product.listed && mine && (
<p className="text-sm text-muted-foreground">Unlisted: it&apos;s not in search or lists, but anyone with its link can get it.</p>
)}
{product.deleted && (
<p className="text-sm text-muted-foreground">
{mine ? "You deleted this product." : "Its creator deleted this product."} People who have it keep it.
</p>
)}
{(product.image || product.categories.length > 0) && (
<div className="flex flex-wrap items-center gap-4">
{product.image && (
<div className="w-40 overflow-hidden rounded-xl ring-1 ring-foreground/10">
<ProductImage src={product.image} />
</div>
)}
{product.categories.length > 0 && (
<p className="text-sm text-muted-foreground">{product.categories.map((c) => c.name).join(" › ")}</p>
)}
</div>
)}
{product.tags.length > 0 && (
<ul className="flex flex-wrap gap-1.5" aria-label="Tags">
{product.tags.map((t) => (
<li key={t}>
<Link href={`/products?tag=${encodeURIComponent(t)}`} className="hover:opacity-80">
<Tag>{t}</Tag>
</Link>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-2">
{draft || (product.deleted && !inLibrary) ? null : me ? (
<ActionForm action={libraryAction} fields={{ id }} className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
{inLibrary ? (
<>
<Tag tone="strong">In your library</Tag>
<SubmitButton variant="outline" name="intent" value="remove" pendingLabel="Removing…">
Remove
</SubmitButton>
</>
) : available ? (
<SubmitButton name="intent" value="get" pendingLabel="Getting…">
Get
</SubmitButton>
) : (
<span className="text-sm text-muted-foreground">
Members only. Join{" "}
{catalogs.map((c, i) => (
<span key={c.id}>
{i > 0 && (i === catalogs.length - 1 ? " or " : ", ")}
<Link href={`/catalogs/${c.id}`} className="font-medium text-foreground underline-offset-4 hover:underline">
{c.name}
</Link>
{c.joined === "requested" && " (asked)"}
</span>
))}{" "}
to get it.
</span>
)}
</div>
</ActionForm>
) : (
<Link href={`/signin?next=/products/${id}`} className={buttonVariants()}>
Sign in to get it
</Link>
)}
{permissions.includes("product:edit") && !(mine && product.deleted) && (
<Link href={`/products/${id}/edit`} className={buttonVariants({ variant: "outline" })}>
{mine ? "Edit" : "Edit info"}
</Link>
)}
{mine && !product.deleted && (
<>
<ActionForm action={deleteProduct} fields={{ id }}>
<SubmitButton
variant="outline"
pendingLabel="Deleting…"
confirm="Delete this product? Nobody new can get it. People who have it keep it."
>
Delete
</SubmitButton>
</ActionForm>
</>
)}
</div>
{product.description && (
<Card>
<CardContent>
{/* Cleaned by the API's allowlist when saved; the page's CSP blocks scripts besides. */}
<div className="product-description" dangerouslySetInnerHTML={{ __html: product.description }} />
</CardContent>
</Card>
)}
{me && !mine && !draft && <ReportForm about="product" id={id} />}
{isModerator(me) && (
<Card>
<CardHeader>
<CardTitle>Moderation</CardTitle>
</CardHeader>
<CardContent>
<ActionForm action={removeProduct} fields={{ id }} className="flex flex-wrap items-center gap-2">
<Input name="reason" required maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
<SubmitButton
variant="destructive"
pendingLabel="Removing…"
confirm="Remove this product everywhere? Its page, files and library entries go."
>
Remove
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Revisions</CardTitle>
</CardHeader>
<CardContent>
<ul className="divide-y">
{revisions.map((r) => (
<RevisionRow
key={r.number}
id={id}
revision={r}
latest={r.number === product.latest}
inLibrary={inLibrary}
stayOn={stayOn}
using={using === r.number}
mine={mine}
canBlock={permissions.includes("product:remove")}
/>
))}
</ul>
</CardContent>
</Card>
</div>
);
}
/** The revision T5DE uses (as the API's revisionFor): want, else the nearest earlier one not retracted, else later. */
function inUse(revisions: Revision[], want: number): number | undefined {
const usable = revisions.filter((r) => !r.retracted); // newest first
return (usable.find((r) => r.number === want) ?? usable.find((r) => r.number < want) ?? usable.at(-1))?.number;
}
function RevisionRow({
id,
revision: r,
latest,
inLibrary,
stayOn,
using,
mine,
canBlock,
}: {
id: string;
revision: Revision;
latest: boolean;
inLibrary: boolean;
stayOn: number | null;
using: boolean;
mine: boolean;
/** Moderators: each file links to blocking it. */
canBlock: boolean;
}) {
const facts = [
formatSize(r.size),
`${r.files.length} ${r.files.length === 1 ? "file" : "files"}`,
r.catalogs.length > 0 ? r.catalogs.join(", ") : "no catalog",
];
return (
<li className="space-y-2 py-4 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">Revision {r.number}</span>
<span className="text-sm text-muted-foreground">
<DateTime iso={r.createdAt} dateOnly />
</span>
{latest && <Tag>latest</Tag>}
<RatingTag rating={r.rating} />
{r.retracted && <Tag tone="danger">retracted</Tag>}
{using && <Tag tone="strong">{stayOn === r.number ? "staying on this" : "in use"}</Tag>}
</div>
{r.notes && <p className="text-sm whitespace-pre-line">{r.notes}</p>}
<p className="text-xs text-muted-foreground">{facts.join(" · ")}</p>
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Files</summary>
<ul className="mt-2 space-y-1 font-mono text-xs">
{r.files.map((f) => (
<li key={f.name} className="flex gap-3">
<span className="min-w-0 flex-1 truncate">{f.name}</span>
<span className="text-muted-foreground tabular-nums">{formatSize(f.size)}</span>
{canBlock && (
<Link href={`/console/blocks/${f.sha256}`} className="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
Block
</Link>
)}
</li>
))}
</ul>
</details>
{((inLibrary && !r.retracted) || (mine && !r.retracted)) && (
<div className="flex flex-wrap gap-2">
{inLibrary && !r.retracted && (
<ActionForm action={libraryAction} fields={{ id, revision: r.number }}>
{stayOn === r.number ? (
<SubmitButton size="sm" variant="outline" name="intent" value="follow" pendingLabel="Saving…">
Follow latest
</SubmitButton>
) : (
<SubmitButton size="sm" variant="outline" name="intent" value="stay" pendingLabel="Saving…">
Stay on this revision
</SubmitButton>
)}
</ActionForm>
)}
{mine && !r.retracted && (
<ActionForm action={retract} fields={{ id, revision: r.number }}>
<SubmitButton
size="sm"
variant="outline"
pendingLabel="Retracting…"
confirm={`Retract revision ${r.number}? T5DE stops using it.`}
>
Retract
</SubmitButton>
</ActionForm>
)}
</div>
)}
</li>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, ApiError, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const done = (id: string) => {
revalidatePath(`/products/${id}`);
revalidatePath("/library");
};
/** The person's library and a product: get it, remove it, stay on a revision or follow the latest. */
export async function libraryAction(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const lib = api.library({ id });
switch (field(form, "intent")) {
case "get":
unwrap(await lib.put());
break;
case "remove":
unwrap(await lib.delete());
break;
case "stay":
unwrap(await lib.patch({ stayOn: Number(field(form, "revision")) }));
break;
case "follow":
unwrap(await lib.patch({ stayOn: null }));
break;
}
done(id);
});
}
/** Its creator deletes the product. */
export async function deleteProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).delete());
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** Moderation removes the product. */
export async function removeProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).remove.post({ reason: field(form, "reason") }));
done(id);
revalidatePath("/products");
});
if (state?.error) return state;
redirect("/products");
}
/** Moderation restores a removed product. */
export async function restoreProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
unwrap(await api.products({ id }).unremove.post({ reason: field(form, "reason") }));
done(id);
revalidatePath("/products");
revalidatePath("/console/products");
return "Restored.";
});
}
export async function retract(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
unwrap(await api.products({ id }).revisions({ n: Number(field(form, "revision")) }).retract.post());
done(id);
});
}
/** Saves a product's info. Fields left out of the form (locked ones are disabled) aren't changed. */
export async function saveProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const has = (name: string) => form.has(name);
const rating = field(form, "rating");
const listed = field(form, "listed");
return attempt(async () => {
const { fields, review } = unwrap(
await api.products({ id }).patch({
...(has("name") ? { name: field(form, "name") } : {}),
...(has("description") ? { description: String(form.get("description") ?? "") } : {}),
...(rating === "adult" || rating === "general" ? { rating } : {}),
...(has("tags")
? {
tags: field(form, "tags")
.split(",")
.map((t) => t.trim())
.filter(Boolean),
}
: {}),
...(listed === "yes" || listed === "no" ? { listed: listed === "yes" } : {}),
...(has("category") ? { category: Number(field(form, "category")) || null } : {}),
...(field(form, "reason") ? { reason: field(form, "reason") } : {}),
}),
);
done(id);
revalidatePath("/products");
revalidatePath(`/products/${id}/edit`);
if (review) return `${fields.length ? "Saved. " : ""}Moderators will review lowering the rating (case #${review}).`;
return fields.length ? "Saved." : "Nothing changed.";
});
}
export async function publishProduct(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const state = await attempt(async () => {
unwrap(await api.products({ id }).publish.post());
done(id);
revalidatePath("/products");
revalidatePath("/console/products");
});
if (state?.error) return state;
redirect(`/products/${id}`);
}
/** Moderators lock fields: the checked ones are locked, the rest unlocked. */
export async function saveLocks(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
const fields = form
.getAll("locked")
.filter((v): v is "name" | "description" | "rating" | "tags" | "listed" | "category" =>
["name", "description", "rating", "tags", "listed", "category"].includes(String(v)),
);
return attempt(async () => {
unwrap(await api.products({ id }).locks.put({ fields, reason: field(form, "reason") }));
done(id);
revalidatePath(`/products/${id}/edit`);
return "Locks saved.";
});
}
/** Puts back what a history entry changed. */
export async function revertInfo(_: ActionState, form: FormData): Promise<ActionState> {
const id = field(form, "id");
return attempt(async () => {
const reason = field(form, "reason");
unwrap(
await api
.products({ id })
.history({ entry: Number(field(form, "entry")) })
.revert.post(reason ? { reason } : {}),
);
done(id);
revalidatePath(`/products/${id}/edit`);
return "Undone.";
});
}
/** An upload token and the catalog's upload address, for the browser to send the file there itself. */
export async function startUpload(
catalog: string,
product?: string,
): Promise<{ token: string; url: string } | { error: string }> {
try {
return unwrap(await api.products.upload.post({ catalog, ...(product ? { product } : {}) }));
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
+14
View File
@@ -0,0 +1,14 @@
import "server-only";
import { notFound } from "next/navigation";
import { api, ApiError, unwrap, type ProductPage } from "@/lib/api";
/** The product, or not found (also when it's adult and the visitor can't see adult products, or it was removed). */
export async function loadProduct(id: string): Promise<ProductPage> {
if (!/^[a-z2-7]{10}$/.test(id)) notFound();
try {
return unwrap(await api.products({ id }).get());
} catch (e) {
if (e instanceof ApiError && (e.status === 404 || e.status === 410)) notFound();
throw e;
}
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { PageHeader } from "@/components/status";
import { UploadCard } from "@/components/upload-card";
import { getMe } from "@/lib/api";
export const metadata: Metadata = { title: "New product" };
export default async function NewProduct() {
const me = await getMe();
if (!me) redirect("/signin?next=/products/new");
return (
<div className="space-y-6">
<PageHeader title="New product">
Upload it to a catalog. Then check its info and publish it.{" "}
<Link href="/products" className="underline-offset-4 hover:underline">
Back to products
</Link>
</PageHeader>
<UploadCard title="Upload" />
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { Empty, ProductCard } from "@/components/products";
import Link from "next/link";
import { PageHeader } from "@/components/status";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api, getMe, unwrap } from "@/lib/api";
export const metadata: Metadata = { title: "Products" };
export default async function Products({
searchParams,
}: {
searchParams: Promise<{ q?: string | string[]; creator?: string | string[]; tag?: string | string[] }>;
}) {
const params = await searchParams;
const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
const q = one(params.q, 100);
const creator = one(params.creator, 64);
const tag = one(params.tag, 24);
const [me, { products }] = await Promise.all([
getMe(),
api.products
.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}), ...(tag ? { tag } : {}) } })
.then(unwrap),
]);
return (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Products">
{[creator && `By ${creator}.`, tag && `Tagged “${tag}”.`].filter(Boolean).join(" ") || "Community products for T5DE."}
</PageHeader>
{me && (
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
New product
</Link>
)}
</div>
<form action="/products" className="mb-6 flex w-full max-w-md gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
{creator && <input type="hidden" name="creator" value={creator} />}
{tag && <input type="hidden" name="tag" value={tag} />}
<Button type="submit" variant="outline">
Search
</Button>
</form>
{products.length === 0 ? (
<Empty>{q || tag || creator ? "Nothing found." : "No products yet."}</Empty>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{products.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>
)}
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FieldGroup } from "@/components/ui/field";
import { api, getMe, unwrap } from "@/lib/api";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { closeRoom, removeImage, saveImage, saveRoom } from "../../actions";
import { RoomFields } from "../../room-fields";
export const metadata: Metadata = { title: "Edit room" };
export default async function EditRoom({ params }: { params: Promise<{ n: string }> }) {
const { n } = await params;
if (!/^[0-9]{1,9}$/.test(n)) notFound();
const me = await getMe();
if (!me) redirect(`/signin?next=/rooms/${n}/edit`);
const [{ rooms }, { links }] = await Promise.all([
api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap),
api.account.get().then(unwrap),
]);
const room = rooms.find((r) => String(r.n) === n);
if (!room) notFound();
return (
<div className="space-y-6">
<PageHeader title={`Edit ${room.name}`}>
<Link href="/rooms" className="underline-offset-4 hover:underline">
Back to rooms
</Link>
</PageHeader>
<Card>
<CardHeader>
<CardDescription>
Owned by {room.ownerName} · {room.occupancy} here now
</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={saveRoom} fields={{ n: room.n }}>
<FieldGroup>
<RoomFields room={room} canAdult={links.some((l) => l.adult)} moderators />
<div>
<SubmitButton pendingLabel="Saving…">Save</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Image</CardTitle>
<CardDescription>Shown in room lists.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{room.image && (
// The room's image, from the hub.
<img src={room.image} alt="" className="aspect-[332/281] w-full max-w-xs rounded-md bg-muted object-cover" />
)}
<ActionForm action={saveImage} fields={{ n: room.n }}>
<Field>
<FieldLabel htmlFor="image">{room.image ? "Replace" : "Upload"}</FieldLabel>
<Input id="image" name="image" type="file" accept="image/png,image/jpeg" required className="max-w-md" />
<FieldDescription>PNG or JPEG, up to 512 KB.</FieldDescription>
</Field>
<div>
<SubmitButton pendingLabel="Uploading…">Save image</SubmitButton>
</div>
</ActionForm>
{room.image && (
<ActionForm action={removeImage} fields={{ n: room.n }}>
<SubmitButton variant="outline" pendingLabel="Removing…">
Remove image
</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Close room</CardTitle>
<CardDescription>Everyone in it is sent out. It can&apos;t be opened again.</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={closeRoom} fields={{ n: room.n }}>
<SubmitButton variant="destructive" pendingLabel="Closing…" confirm={`Close ${room.name}?`}>
Close room
</SubmitButton>
</ActionForm>
</CardContent>
</Card>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const rating = (form: FormData) => (field(form, "rating") === "adult" ? ("adult" as const) : ("general" as const));
const privacy = (form: FormData) => (field(form, "privacy") === "private" ? ("private" as const) : ("public" as const));
const moderators = (form: FormData) =>
field(form, "moderators")
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
/** Makes a hosted room, then goes back to the list. */
export async function createRoom(_: ActionState, form: FormData): Promise<ActionState> {
const state = await attempt(async () => {
unwrap(
await api["hosted-rooms"].post({
name: field(form, "name"),
description: field(form, "description"),
roomPid: Number(field(form, "roomPid")),
rating: rating(form),
maxUsers: Number(field(form, "maxUsers")),
imvuId: Number(field(form, "imvuId")),
privacy: privacy(form),
}),
);
revalidatePath("/rooms");
});
if (state?.error) return state;
redirect("/rooms");
}
/** The owner's changes to a room. */
export async function saveRoom(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
const state = await attempt(async () => {
unwrap(
await api["hosted-rooms"]({ n }).patch({
name: field(form, "name"),
description: field(form, "description"),
roomPid: Number(field(form, "roomPid")),
rating: rating(form),
maxUsers: Number(field(form, "maxUsers")),
privacy: privacy(form),
moderators: moderators(form),
}),
);
revalidatePath("/rooms");
});
if (state?.error) return state;
redirect("/rooms");
}
/** The room's image: a PNG or JPEG, up to 512 KB. */
export async function saveImage(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
const image = form.get("image");
if (!(image instanceof File) || image.size === 0) return { error: "Choose an image." };
if (image.size > 512 * 1024) return { error: "An image is up to 512 KB." };
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).image.put({ image }));
revalidatePath("/rooms");
revalidatePath(`/rooms/${n}/edit`);
return "Image saved.";
});
}
export async function removeImage(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).image.delete());
revalidatePath("/rooms");
revalidatePath(`/rooms/${n}/edit`);
});
}
/** Stars a room, or (favorite=0) unstars it. */
export async function setFavorite(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
const room = api["hosted-rooms"]({ n }).favorite;
unwrap(await (field(form, "favorite") === "1" ? room.put() : room.delete()));
revalidatePath("/rooms");
});
}
export async function rateRoom(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
return attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).rating.put({ score: Number(field(form, "score")) }));
revalidatePath("/rooms");
});
}
/** Closes a room: everyone in it is sent out. */
export async function closeRoom(_: ActionState, form: FormData): Promise<ActionState> {
const n = field(form, "n");
const state = await attempt(async () => {
unwrap(await api["hosted-rooms"]({ n }).delete());
revalidatePath("/rooms");
});
if (state?.error) return state;
redirect("/rooms");
}
+61
View File
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader } from "@/components/status";
import { Card, CardContent } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { api, getMe, unwrap } from "@/lib/api";
import { createRoom } from "../actions";
import { RoomFields } from "../room-fields";
export const metadata: Metadata = { title: "New room" };
export default async function NewRoom() {
const me = await getMe();
if (!me) redirect("/signin?next=/rooms/new");
const { links } = await api.account.get().then(unwrap);
return (
<div className="space-y-6">
<PageHeader title="New room">
<Link href="/rooms" className="underline-offset-4 hover:underline">
Back to rooms
</Link>
</PageHeader>
<Card>
<CardContent>
{links.length === 0 ? (
<p className="py-2 text-sm text-muted-foreground">
<Link href="/account" className="text-foreground underline underline-offset-4">
Link an IMVU account
</Link>{" "}
first: it&apos;s shown as the room&apos;s owner.
</p>
) : (
<ActionForm action={createRoom}>
<FieldGroup>
<RoomFields canAdult={links.some((l) => l.adult)} />
<Field>
<FieldLabel htmlFor="imvuId">Owner</FieldLabel>
<NativeSelect id="imvuId" name="imvuId" defaultValue={String(links[0]!.imvuId)} className="w-64">
{links.map((l) => (
<NativeSelectOption key={l.imvuId} value={String(l.imvuId)}>
{l.username}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>The IMVU account shown as owner. It can decorate the room.</FieldDescription>
</Field>
<div>
<SubmitButton pendingLabel="Making…">Make room</SubmitButton>
</div>
</FieldGroup>
</ActionForm>
)}
</CardContent>
</Card>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader, Tag } from "@/components/status";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { api, getMe, unwrap, type HostedRoom } from "@/lib/api";
import { RoomActions } from "./room-actions";
export const metadata: Metadata = { title: "Rooms" };
export default async function Rooms() {
const me = await getMe();
const [{ rooms }, { rooms: hosted }, mine] = await Promise.all([
api.rooms.get().then(unwrap),
api["hosted-rooms"].get({ query: {} }).then(unwrap),
me ? api["hosted-rooms"].get({ query: { mine: "1" } }).then(unwrap) : { rooms: [] as HostedRoom[] },
]);
const own = new Set(mine.rooms.map((r) => r.n));
return (
<div className="space-y-10">
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader title="Rooms">Rooms on T5DE, and public IMVU rooms of T5DE users.</PageHeader>
{me && (
<Link href="/rooms/new" className={buttonVariants({ className: "px-4" })}>
New room
</Link>
)}
</div>
<section className="space-y-4">
<h2 className="text-lg font-semibold tracking-tight">On T5DE</h2>
{hosted.length === 0 ? (
<Card>
<CardContent>
<p className="py-6 text-sm text-muted-foreground">No rooms yet.</p>
</CardContent>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{hosted.map((r) => (
<li key={r.n}>
<HostedCard room={r} mine={own.has(r.n)} signedIn={!!me} />
</li>
))}
</ul>
)}
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold tracking-tight">On IMVU</h2>
{rooms.length === 0 ? (
<Card>
<CardContent>
<p className="py-6 text-sm text-muted-foreground">No rooms listed yet.</p>
</CardContent>
</Card>
) : (
<ul className="grid gap-4 sm:grid-cols-2">
{rooms.map((r) => (
<li key={r.instanceId}>
<Card className="h-full overflow-hidden py-0">
{r.imageURL && (
// IMVU's own image of the room, from IMVU.
<img src={r.imageURL} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
)}
<CardContent className="space-y-3 pb-6">
<div className="flex flex-wrap items-center gap-2">
<a href={r.url} className="font-medium underline-offset-4 hover:underline" rel="noreferrer">
{r.name}
</a>
{r.adult && <Tag tone="adult">AP</Tag>}
{r.t5de.length > 0 && (
<Tag tone="online" dot>
{r.t5de.length} on T5DE
</Tag>
)}
</div>
<p className="text-xs text-muted-foreground">
{r.owner.username} · {r.occupancy}/{r.capacity}
</p>
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
{r.t5de.length > 0 && (
<p className="text-xs text-muted-foreground">Here now: {r.t5de.map((u) => u.username).join(", ")}</p>
)}
</CardContent>
</Card>
</li>
))}
</ul>
)}
</section>
</div>
);
}
function HostedCard({ room: r, mine, signedIn }: { room: HostedRoom; mine: boolean; signedIn: boolean }) {
return (
<Card className="h-full overflow-hidden py-0">
{r.image && (
// The room's image, from the hub.
<img src={r.image} alt="" className="aspect-[332/281] w-full bg-muted object-cover" loading="lazy" />
)}
<CardContent className="space-y-3 py-6">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.name}</span>
{r.rating === "adult" && <Tag tone="adult">Adult</Tag>}
{r.privacy === "private" && <Tag>Private</Tag>}
{r.occupancy > 0 && (
<Tag tone="online" dot>
{r.occupancy} here
</Tag>
)}
{mine && (
<Link href={`/rooms/${r.n}/edit`} className={buttonVariants({ variant: "outline", size: "sm", className: "ml-auto" })}>
Edit
</Link>
)}
</div>
<p className="text-xs text-muted-foreground">
{r.ownerName} · {r.occupancy}/{r.maxUsers}
{r.ratings > 0 && ` · ★ ${r.score.toFixed(1)} (${r.ratings})`}
</p>
{r.description && <p className="line-clamp-3 text-sm whitespace-pre-line">{r.description}</p>}
{signedIn && <RoomActions n={r.n} favorite={r.favorite} canRate={!mine} />}
</CardContent>
</Card>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
// A hosted room card's favorite star and rating buttons.
import { Star } from "lucide-react";
import { ActionForm, SubmitButton } from "@/components/forms";
import { cn } from "@/lib/utils";
import { rateRoom, setFavorite } from "./actions";
export function RoomActions({ n, favorite, canRate }: { n: number; favorite: boolean; canRate: boolean }) {
return (
<div className="flex flex-wrap items-center gap-3">
<ActionForm action={setFavorite} fields={{ n, favorite: favorite ? "0" : "1" }} className="space-y-0">
<SubmitButton variant="ghost" size="icon" aria-label={favorite ? "Unstar" : "Star"} title={favorite ? "Unstar" : "Star"}>
<Star className={cn("size-4", favorite && "fill-current text-amber-500")} />
</SubmitButton>
</ActionForm>
{canRate && (
<ActionForm action={rateRoom} fields={{ n }} className="flex items-center gap-0.5 space-y-0">
<span className="mr-1 text-xs text-muted-foreground">Rate</span>
{[1, 2, 3, 4, 5].map((k) => (
<SubmitButton key={k} name="score" value={String(k)} variant="ghost" size="xs" aria-label={`${k} of 5`}>
{k}
</SubmitButton>
))}
</ActionForm>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
// The fields of a hosted room, for making one and for editing it.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import type { HostedRoom } from "@/lib/api";
/** The moderators as the edit field shows them: names where known, else IDs. */
const moderatorList = (room: HostedRoom) => room.moderators.map((id, i) => room.moderatorNames[i] || String(id)).join(", ");
export function RoomFields({ room, canAdult, moderators }: { room?: HostedRoom; canAdult: boolean; moderators?: boolean }) {
return (
<>
<Field>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" name="name" defaultValue={room?.name} required maxLength={64} className="max-w-md" />
</Field>
<Field>
<FieldLabel htmlFor="description">Description</FieldLabel>
<Textarea id="description" name="description" defaultValue={room?.description} maxLength={512} rows={3} />
</Field>
<div className="grid gap-4 sm:grid-cols-3">
<Field>
<FieldLabel htmlFor="roomPid">Room shell</FieldLabel>
<Input
id="roomPid"
name="roomPid"
type="number"
inputMode="numeric"
min={1}
required
defaultValue={room?.roomPid}
placeholder="Product ID"
/>
<FieldDescription>An IMVU room product&apos;s ID, from its shop page.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="rating">Rating</FieldLabel>
<NativeSelect id="rating" name="rating" defaultValue={room?.rating ?? "general"} className="w-full">
<NativeSelectOption value="general">General</NativeSelectOption>
<NativeSelectOption value="adult" disabled={!canAdult && room?.rating !== "adult"}>
Adult
</NativeSelectOption>
</NativeSelect>
{!canAdult && <FieldDescription>Adult needs an age-verified account.</FieldDescription>}
</Field>
<Field>
<FieldLabel htmlFor="maxUsers">Max people</FieldLabel>
<Input
id="maxUsers"
name="maxUsers"
type="number"
inputMode="numeric"
min={2}
max={30}
required
defaultValue={room?.maxUsers ?? 10}
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="privacy">Privacy</FieldLabel>
<NativeSelect id="privacy" name="privacy" defaultValue={room?.privacy ?? "public"} className="w-64">
<NativeSelectOption value="public">Public</NativeSelectOption>
<NativeSelectOption value="private">Private</NativeSelectOption>
</NativeSelect>
<FieldDescription>Private: unlisted; only you, moderators and people invited get in.</FieldDescription>
</Field>
{moderators && room && (
<Field>
<FieldLabel htmlFor="moderators">Moderators</FieldLabel>
<Input id="moderators" name="moderators" defaultValue={moderatorList(room)} placeholder="IMVU names or IDs" />
<FieldDescription>Up to 20, comma-separated. They can boot and ban.</FieldDescription>
</Field>
)}
</>
);
}
+7 -2
View File
@@ -1,15 +1,20 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { NarrowCard } from "@/components/narrow-card"; import { NarrowCard } from "@/components/narrow-card";
import { getMe } from "@/lib/api"; import { api, getMe, unwrap } from "@/lib/api";
import { SignUpForm } from "./signup-form"; import { SignUpForm } from "./signup-form";
export const metadata: Metadata = { title: "Create account" }; export const metadata: Metadata = { title: "Create account" };
export default async function SignUp() { export default async function SignUp() {
if (await getMe()) redirect("/account"); if (await getMe()) redirect("/account");
const { registration } = unwrap(await api.hub.get());
const description =
registration === "closed"
? "One T5DE account for all your IMVU accounts. New accounts need a moderator's approval."
: "One T5DE account for all your IMVU accounts.";
return ( return (
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts."> <NarrowCard title="Create account" description={description}>
<SignUpForm /> <SignUpForm />
</NarrowCard> </NarrowCard>
); );
+76
View File
@@ -0,0 +1,76 @@
// Pieces shared by the case pages and the places people open cases from.
import Link from "next/link";
import { ActionForm, SubmitButton } from "@/components/forms";
import { Tag } from "@/components/status";
import { Input } from "@/components/ui/input";
import { report } from "@/app/console/cases/actions";
export const caseKinds: Record<string, string> = {
report: "Report",
"rating-review": "Rating review",
"removal-request": "Removal request",
"catalog-deletion": "Catalog deletion",
appeal: "Appeal",
};
/** What resolving each kind of case does (its button). */
export const resolveLabel: Record<string, string> = {
report: "Resolve",
"rating-review": "Lower to general",
"removal-request": "Remove the product",
"catalog-deletion": "Delete the catalog",
appeal: "Uphold the appeal",
};
export function CaseStatus({ status }: { status: string }) {
if (status === "open") return <Tag tone="strong">Open</Tag>;
if (status === "resolved") return <Tag>Resolved</Tag>;
return <Tag>Declined</Tag>;
}
const tagLinks: Record<string, (id: string) => string> = {
catalog: (id) => `/catalogs/${id}`,
product: (id) => `/products/${id}`,
};
/** What a case is about, linked. */
export function CaseAbout({ tags, names }: { tags: Record<string, string | undefined>; names: Record<string, string> }) {
const parts = Object.entries(tags).filter((e): e is [string, string] => !!e[1]);
if (parts.length === 0) return <span className="text-muted-foreground">nothing</span>;
return (
<span className="space-x-2">
{parts.map(([kind, id]) => {
const href = tagLinks[kind]?.(id);
return (
<span key={kind}>
<span className="text-muted-foreground">{kind} </span>
{href ? (
<Link href={href} className="underline-offset-4 hover:underline">
{names[id] ?? id}
</Link>
) : (
(names[id] ?? id)
)}
</span>
);
})}
</span>
);
}
/** "Report": a reason, sent to moderators (and the hosting catalogs' teams, for products). */
export function ReportForm({ about, id, label = "Report" }: { about: "product" | "catalog" | "room" | "person"; id: string; label?: string }) {
return (
<details className="text-sm">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">{label}</summary>
<ActionForm action={report} fields={{ about, id }} className="mt-2">
<div className="flex max-w-xl flex-wrap gap-2">
<Input name="reason" required maxLength={2000} placeholder="What's wrong?" aria-label="What's wrong" className="min-w-60 flex-1" />
<SubmitButton variant="outline" pendingLabel="Sending…">
Send report
</SubmitButton>
</div>
</ActionForm>
</details>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Tag } from "@/components/status";
export const membership: Record<string, string> = { open: "Open", approval: "By approval", invite: "Invite only" };
/** A catalog's status: approved, pending, rejected, suspended or deleted. */
export function CatalogStatus({ status }: { status: string }) {
if (status === "approved") return <Tag tone="strong">approved</Tag>;
if (status === "rejected" || status === "suspended" || status === "deleted") return <Tag tone="danger">{status}</Tag>;
return <Tag>{status}</Tag>;
}
+56 -9
View File
@@ -2,7 +2,8 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Menu } from "@base-ui/react/menu";
import { ChevronDown } from "lucide-react";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -32,21 +33,67 @@ export function NavLink({
); );
} }
export function SignOutButton() { /** The signed-in person's menu: their picture and name, opening their pages and signing out. */
export function ProfileMenu({ username, picture, console }: { username: string; picture: string | null; console: boolean }) {
const router = useRouter(); const router = useRouter();
const [pending, setPending] = useState(false); const [signingOut, setSigningOut] = useState(false);
const item =
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[highlighted]:bg-muted";
return ( return (
<Button <Menu.Root>
variant="outline" <Menu.Trigger
disabled={pending} className="flex items-center gap-2 rounded-full py-1 pr-2.5 pl-1 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring data-[popup-open]:bg-muted"
aria-label="Your account"
>
<Avatar username={username} picture={picture} />
<span className="hidden max-w-40 truncate sm:inline">{username}</span>
<ChevronDown className="size-4 text-muted-foreground" aria-hidden />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner align="end" sideOffset={6} className="z-50">
<Menu.Popup className="min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none">
<Menu.LinkItem className={item} render={<Link href="/account" />}>
Account
</Menu.LinkItem>
<Menu.LinkItem className={item} render={<Link href="/library" />}>
Library
</Menu.LinkItem>
{console && (
<Menu.LinkItem className={item} render={<Link href="/console" />}>
Console
</Menu.LinkItem>
)}
<Menu.Separator className="-mx-1 my-1 h-px bg-border" />
<Menu.Item
className={item}
disabled={signingOut}
onClick={async () => { onClick={async () => {
setPending(true); setSigningOut(true);
await authClient.signOut(); await authClient.signOut();
router.push("/"); router.push("/");
router.refresh(); router.refresh();
}} }}
> >
{pending ? "Signing out…" : "Sign out"} {signingOut ? "Signing out…" : "Sign out"}
</Button> </Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
/** A person's picture (their first linked IMVU account's avatar), or their initial. */
function Avatar({ username, picture }: { username: string; picture: string | null }) {
const [failed, setFailed] = useState(false);
if (picture && !failed) {
return (
<img src={picture} alt="" onError={() => setFailed(true)} className="size-7 rounded-full bg-muted object-cover object-top" />
);
}
return (
<span className="grid size-7 place-items-center rounded-full bg-muted text-xs font-semibold uppercase" aria-hidden>
{username.slice(0, 1)}
</span>
); );
} }
+28 -18
View File
@@ -1,17 +1,13 @@
import Image from "next/image";
import { Bell } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getMe, isModerator } from "@/lib/api"; import { getMe } from "@/lib/api";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { NavLink, SignOutButton } from "./header-client"; import { NavLink, ProfileMenu } from "./header-client";
import logo from "./t5de.png";
export function Logo() { export function Logo({ className }: { className?: string }) {
return ( return <Image src={logo} alt="T5DE" priority className={className ?? "size-9"} />;
<span
className="grid size-7 place-items-center rounded-md bg-foreground text-[11px] font-bold tracking-tight text-background"
aria-hidden
>
T5
</span>
);
} }
export async function Header() { export async function Header() {
@@ -19,20 +15,34 @@ export async function Header() {
return ( return (
<header className="border-b"> <header className="border-b">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6"> <div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
<Link href="/" className="flex items-center gap-2 font-semibold"> <Link href="/" aria-label="T5DE Hub home" className="shrink-0">
<Logo /> <Logo />
T5DE Hub
</Link> </Link>
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto"> <nav className="order-last flex w-full flex-wrap items-center gap-1 text-sm sm:order-none sm:w-auto">
<NavLink href="/online">Online</NavLink> <NavLink href="/online">Online</NavLink>
{me && <NavLink href="/account">Account</NavLink>} <NavLink href="/rooms">Rooms</NavLink>
{isModerator(me) && <NavLink href="/console">Console</NavLink>} <NavLink href="/products">Products</NavLink>
<NavLink href="/catalogs">Catalogs</NavLink>
</nav> </nav>
<div className="ml-auto flex items-center gap-2 text-sm"> <div className="ml-auto flex items-center gap-2 text-sm">
<a href="https://t5de.dev/download" className={buttonVariants({ variant: "ghost" })}>
Download
</a>
{me ? ( {me ? (
<> <>
<span className="hidden text-muted-foreground sm:inline">{me.username}</span> <Link
<SignOutButton /> href="/notifications"
aria-label={me.unread ? `Notifications, ${me.unread} unread` : "Notifications"}
className={buttonVariants({ variant: "ghost", size: "icon", className: "relative" })}
>
<Bell className="size-4" />
{me.unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-[10px] font-semibold text-background tabular-nums">
{me.unread > 99 ? "99+" : me.unread}
</span>
)}
</Link>
<ProfileMenu username={me.username} picture={me.picture} console={me.console} />
</> </>
) : ( ) : (
<> <>
+93
View File
@@ -0,0 +1,93 @@
// Pieces shared by the product pages.
import Link from "next/link";
import { Tag } from "@/components/status";
import { Card, CardContent } from "@/components/ui/card";
import type { ProductSummary } from "@/lib/api";
export function RatingTag({ rating }: { rating: string }) {
return rating === "adult" ? <Tag tone="adult">Adult</Tag> : null;
}
/** A product's state, where it isn't plainly published and listed: draft, unlisted, deleted, removed, locked. */
export function StateTags({
product: p,
}: {
product: Pick<ProductSummary, "status" | "listed" | "deleted" | "removed" | "locked">;
}) {
return (
<>
{p.status === "draft" && <Tag tone="strong">Draft</Tag>}
{p.status === "published" && !p.listed && <Tag>Unlisted</Tag>}
{p.deleted && <Tag tone="danger">Deleted</Tag>}
{p.removed && <Tag tone="danger">Removed</Tag>}
{p.locked.length > 0 && <Tag>Locked: {p.locked.join(", ")}</Tag>}
</>
);
}
/** 1536 → "1.5 KB". */
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let n = bytes / 1024;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
/** The image of the IMVU product a product derives from (IMVU's, from IMVU), if it has one. */
export function ProductImage({ src, className }: { src: string | null | undefined; className?: string }) {
if (!src) return null;
return (
<img
src={src}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
className={`aspect-[5/4] w-full bg-muted object-contain ${className ?? ""}`}
/>
);
}
export function ProductCard({
product,
children,
}: {
product: Pick<ProductSummary, "id" | "name" | "creator" | "rating" | "latest" | "image" | "categories" | "listed">;
children?: React.ReactNode;
}) {
const category = product.categories.at(-1)?.name;
return (
<Card className="h-full">
<ProductImage src={product.image} />
<CardContent className="space-y-2">
<div className="flex items-start justify-between gap-2">
<Link href={`/products/${product.id}`} className="font-medium underline-offset-4 hover:underline">
{product.name}
</Link>
<span className="flex gap-1">
{!product.listed && <Tag>Unlisted</Tag>}
<RatingTag rating={product.rating} />
</span>
</div>
<p className="text-xs text-muted-foreground">
{[product.creator && `by ${product.creator}`, category, `revision ${product.latest}`].filter(Boolean).join(" · ")}
</p>
{children}
</CardContent>
</Card>
);
}
export function Empty({ children }: { children: React.ReactNode }) {
return (
<Card>
<CardContent>
<p className="py-6 text-sm text-muted-foreground">{children}</p>
</CardContent>
</Card>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { headers } from "next/headers";
// A slim bar on staging, so testers don't mistake it for T5DE's hub: shown when HUB_ENVIRONMENT is "staging", or
// (unset) when the site is served from a staging address, such as hub.staging.t5de.dev. Read per request, since
// staging and production run the same image.
export async function StagingBanner() {
const env = process.env.HUB_ENVIRONMENT;
const host = (await headers()).get("host") ?? "";
const staging = env ? env === "staging" : /(^|\.)staging\./.test(host);
if (!staging) return null;
return (
<div className="bg-amber-400 py-1 text-center text-xs font-medium text-amber-950">
Staging ·{" "}
<a href="https://hub.t5de.dev" className="underline underline-offset-2">
hub.t5de.dev
</a>
</div>
);
}
+2 -2
View File
@@ -75,8 +75,8 @@ export function PageHeader({ title, children }: { title: React.ReactNode; childr
} }
/** An IMVU account's age status: blue when it can see adult community products. */ /** An IMVU account's age status: blue when it can see adult community products. */
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) { export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass" | "adult"> }) {
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>; if (link.ageVerified || (link.adult && !link.accessPass)) return <Tag tone="adult">Age verified</Tag>;
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>; if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
return <Tag>Not age verified</Tag>; return <Tag>Not age verified</Tag>;
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+6 -3
View File
@@ -2,7 +2,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn" import { cn } from "cn"
const buttonVariants = cva( const variants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
@@ -39,16 +39,19 @@ const buttonVariants = cva(
} }
) )
// Merged, so a variant's border replaces the base's transparent one on links styled as buttons too.
const buttonVariants = (props?: Parameters<typeof variants>[0]) => cn(variants(props))
function Button({ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props & VariantProps<typeof variants>) {
return ( return (
<ButtonPrimitive <ButtonPrimitive
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={buttonVariants({ variant, size, className })}
{...props} {...props}
/> />
) )
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react"
import { cn } from "cn"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more