Compare commits

..
2 Commits
Author SHA1 Message Date
dhkatzandClaude Opus 5.5 7a06ba49c3 Rebuild the hub on Bun: Elysia API, Better Auth, Drizzle, Next.js website
CI / test (push) Successful in 30s
The hub becomes a Bun monorepo: apps/api (Elysia, Better Auth for accounts, roles, bans, proxy device
sign-in and JWT keys; tagline linking, presence and the console as before), packages/db (Drizzle; Postgres in
production, PGlite in development and tests), and the Next.js website from apps/web. Self-hostable with
docker compose; the website deploys to Vercel-style hosts, the API to any that runs Bun or containers. The
first version, in Go, is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:03:54 -07:00
dhkatzandClaude Opus 5.5 64c552330b Website: Next.js app for accounts, linking, who's online, device sign-in and the console
apps/web: Next.js 16 (App Router), Tailwind 4 and shadcn/ui, light and dark from the system. Pages render on the
server and call the API with Eden Treaty (typed by @t5de/api), forwarding the visitor's cookie; forms use server
actions; sign-up, sign-in and sign-out use Better Auth's React client. /api is rewritten to the API so its cookies
are first-party. Who's online follows the API's event stream (Next's gzip is off: it held the stream back).
Standalone output with a Dockerfile; deploys to Vercel as is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:02:00 -07:00
114 changed files with 6472 additions and 3521 deletions

No files matched your search

+15 -20
View File
@@ -1,4 +1,4 @@
# Tests the hub (against a real Postgres, a service container here) and builds it. # Type-checks, tests (the API against PGlite, an in-process Postgres) and builds the hub.
name: CI name: CI
on: on:
@@ -10,30 +10,25 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
GOPRIVATE: git.t5de.dev
# Jobs run as root, which embedded Postgres refuses: the tests use this service instead.
T5DE_TEST_DB: postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 5s --health-retries 30
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: oven-sh/setup-bun@v2
with: with:
go-version-file: go.mod bun-version: 1.4.4
- uses: actions/setup-node@v4
with:
node-version: 26
- name: Install
run: bun install --frozen-lockfile
- name: Check types
run: bun run check-types
- name: Test - name: Test
run: | run: bun run test
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then echo "Not gofmt-formatted: $unformatted"; exit 1; fi
go vet ./...
go test ./...
- name: Build - name: Build
run: CGO_ENABLED=0 go build -trimpath -o dist/t5de-hub ./cmd/t5de-hub run: bun run build
+8 -2
View File
@@ -1,2 +1,8 @@
/dist/ node_modules/
/t5de-hub-data/ .next/
.turbo/
dist/
apps/api/data/
*.tsbuildinfo
.env
.env.local
+16 -10
View File
@@ -1,7 +1,8 @@
# t5de-hub # t5de-hub
T5DE's hub (see docs/design.md, a draft under discussion with the user). Related repositories, usually checked out T5DE's hub (see README.md and docs/design.md). Related repositories, usually checked out beside this one: `t5de`
beside this one: `t5de` (patches), `t5de-proxy` (local proxy), later `t5de-catalog` and `t5de-api`. (patches), `t5de-proxy` (local proxy, Go; it signs in to the hub), `t5de-api` (shared definitions), later
`t5de-catalog`.
## Rules ## Rules
@@ -9,15 +10,20 @@ beside this one: `t5de` (patches), `t5de-proxy` (local proxy), later `t5de-catal
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.
- The user prioritizes backend performance (Go), and wants moderation centered on the hub's website.
- 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
- Go, net/http, html/template (server-rendered; htmx 2.0.4 vendored in `internal/web/static`, CSP without inline - Stack (the user's choice, 2026-10-29): Bun, Elysia, Better Auth, Drizzle (Postgres; PGlite in development and
scripts or styles), pgx with plain SQL (`internal/store`, migrations embedded), coder/websocket. tests), Next.js 16 with Tailwind 4 and shadcn/ui, Turborepo. Must stay self-hostable (docker compose) and deployable
- Dev: `go run ./cmd/t5de-hub -dev` (embedded Postgres on port 54321). Tests use port 54339 and a database each. to Vercel/Heroku-style hosts. Go was used first and replaced.
- The look: IMVU Classic's dialog palette (dark grays, gold buttons with black text), like T5DE's setup. Copy is - The website's look: modern and neutral, light/dark following the system, color only for status. Not IMVU Classic's
short and plain. style (T5DE's setup keeps that).
- Calls to IMVU (`internal/imvu`, its public user API) are rare and rate-limited: linking and age re-checks only. - Better Auth's tables must match its plugins: when changing plugins, compare with `getAuthTables` (Better Auth logs
"Drizzle schema mismatch" otherwise), then `bun x drizzle-kit generate` in packages/db.
- Device sign-in: the person's viewing a code (`deviceVerify`) claims it, then `deviceApprove`; the hub's
`/api/v1/device/approve` does both and logs it. Proxies' sessions are told apart by their User-Agent
(`t5de-proxy...`).
- On this VM, Bun and Node are portable installs in `%LOCALAPPDATA%\Programs\bun` and `...\nodejs` (not on PATH; no
`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.
+36 -20
View File
@@ -2,32 +2,48 @@
T5DE's hub: accounts linked to IMVU accounts, presence, and (to come) the registry of community products and T5DE's hub: accounts linked to IMVU accounts, presence, and (to come) the registry of community products and
catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.dev/t5de). Design: catalogs, T5DE-hosted rooms, and the moderation console. Part of T5DE (git.t5de.dev/t5de). Design:
[docs/design.md](docs/design.md); what a room needs from a server: [docs/imq-rooms.md](docs/imq-rooms.md). [docs/design.md](docs/design.md); the API: [docs/api.md](docs/api.md); what a room needs from a server:
[docs/imq-rooms.md](docs/imq-rooms.md).
## What works ## What works
- **Accounts**: sign-up and sign-in (Argon2id passwords), sessions with CSRF protection. The first account is an - **Accounts** (Better Auth): sign-up with email, username and password; sign-in by username. The first account is
admin. an admin; roles user, moderator, admin.
- **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). Link caps per person (default 10). API. The most recent proof wins (an account linked elsewhere moves, with a new pairwise ID). Link caps per person
- **Age**: an account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub. (default 10).
- **Proxy sign-in**: OAuth 2.0 device codes (`t5de-proxy signin` shows a code to enter at `/device`); Ed25519 JWT - **Age**: an IMVU account counts as adult if IMVU says it's age verified or has Access Pass, checked by the hub.
access tokens (keys at `/.well-known/jwks.json`) and rotating refresh tokens. - **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.
- **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 accounts that opted in, 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.
- **Console** (moderators): counts, who's online (live), people search, link caps, bans, roles, audit log. - **Console** (moderators): counts, who's online (live), people, link caps, bans, roles, the audit log.
## Layout
A Bun monorepo (workspaces, Turborepo):
| | |
| --- | --- |
| `apps/api` | The API: Elysia on Bun, Better Auth, the realtime WebSocket. Port 4101. |
| `apps/web` | The website: Next.js 16 (React server components), Tailwind CSS 4, shadcn/ui. Port 4100. Calls the API through its own `/api` (rewrites), typed end to end with Eden. |
| `packages/db` | Drizzle schema and migrations; Postgres in production, PGlite (in-process Postgres) for development and tests. |
## Development
Needs Bun (1.4) and Node (for Next.js).
bun install
bun run dev # the API on :4101 (database in apps/api/data) and the website on :4100
`bun run test` tests the API against PGlite; `bun run check-types`, `bun run build`.
## Running it ## Running it
go run ./cmd/t5de-hub -dev - **Self-hosted**: `AUTH_SECRET=$(openssl rand -base64 32) docker compose up -d` runs Postgres, the API and the
website (see `docker-compose.yml` for the public addresses).
- **Elsewhere**: the website deploys as is to Vercel and similar (`apps/web`, env `API_URL`); the API to any host
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).
runs the hub at http://127.0.0.1:8080 with its own Postgres (downloaded once, kept in `t5de-hub-data/`). In T5DE's proxies reach the API's address directly (sign-in and the WebSocket): `t5de-proxy signin -hub <API address>`.
production: `t5de-hub -db postgres://... -public-url https://hub.t5de.dev -listen :8080 -data /var/lib/t5de-hub`
(the data directory holds the token signing key).
To point a T5DE proxy at it: `t5de-proxy signin -hub http://127.0.0.1:8080`.
## Tests
`go test ./...` runs everything, the website and API against a real Postgres (embedded, downloaded on first run
and cached; `T5DE_PG_CACHE` sets where), or the Postgres at `T5DE_TEST_DB` (as CI does), and a fake IMVU API.
+18
View File
@@ -0,0 +1,18 @@
# The hub's API. Build from the repository root: docker build -f apps/api/Dockerfile .
FROM oven/bun:1.4.4-slim AS deps
WORKDIR /app
COPY package.json bun.lock ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/db/package.json packages/db/
RUN bun install --frozen-lockfile --production --filter @t5de/api
FROM oven/bun:1.4.4-slim
WORKDIR /app
ENV NODE_ENV=production PORT=4101
COPY --from=deps /app/node_modules ./node_modules
COPY packages/db ./packages/db
COPY apps/api ./apps/api
USER bun
EXPOSE 4101
CMD ["bun", "apps/api/src/index.ts"]
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@t5de/api",
"private": true,
"type": "module",
"exports": {
".": "./src/app.ts"
},
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts",
"test": "bun test",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@t5de/db": "workspace:*",
"better-auth": "1.7.14",
"elysia": "1.4.30"
},
"devDependencies": {
"@types/bun": "^1.4.4",
"typescript": "^7.0.2"
}
}
+581
View File
@@ -0,0 +1,581 @@
// The hub's API: Better Auth at /api/auth, the hub's own routes at /api/v1. The website calls it through its own
// origin (Next.js rewrites /api to here), so session cookies are first-party; T5DE's proxy calls it with a bearer
// token.
import { Elysia, t } from "elysia";
import { and, desc, eq, ilike, inArray, ne, or, sql, count, schema, type Database } from "@t5de/db";
import type { Auth } from "./auth";
import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
import { Realtime } from "./realtime";
const { user, session, imvuLink, linkChallenge, audit } = schema;
export type Deps = { db: Database; auth: Auth; imvu: ImvuClient; realtime?: Realtime };
/** An error shown to the person: the website displays message as is. */
export class Problem extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
type Person = typeof user.$inferSelect;
type Link = typeof imvuLink.$inferSelect;
const roles = ["user", "moderator", "admin"] as const;
const isModerator = (p: Person) => p.role === "moderator" || p.role === "admin";
// Link codes: "t5de-" and eight letters and digits without look-alikes.
function linkCode(): string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const bytes = crypto.getRandomValues(new Uint8Array(8));
const s = [...bytes].map((b) => alphabet[b % alphabet.length]).join("");
return `t5de-${s.slice(0, 4)}-${s.slice(4)}`;
}
function randomID(bytes = 16): string {
return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
}
const challengeLifetime = 24 * 60 * 60 * 1000;
/** Allows n attempts per key in a window (IMVU lookups, which cost IMVU a request each). */
class Limiter {
private hits = new Map<string, number[]>();
allow(key: string, n: number, windowMs: number): boolean {
const now = Date.now();
const recent = (this.hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= n) {
this.hits.set(key, recent);
return false;
}
recent.push(now);
this.hits.set(key, recent);
if (this.hits.size > 100_000) this.hits.clear();
return true;
}
}
export function createApp({ db, auth, imvu, realtime: rt }: Deps) {
const realtime =
rt ??
new Realtime(async (imvuId) => {
const [l] = await db.select({ userId: imvuLink.userId }).from(imvuLink).where(eq(imvuLink.imvuId, imvuId));
return l?.userId;
});
const limiter = new Limiter();
const personView = (p: Person) => ({
id: p.id,
username: p.displayUsername ?? p.username ?? p.name,
email: p.email,
role: (roles as readonly string[]).includes(p.role) ? (p.role as (typeof roles)[number]) : "user",
linkCap: p.linkCap,
createdAt: p.createdAt.toISOString(),
banned: p.banned === true,
banReason: p.banReason ?? "",
});
const linkView = (l: Link) => {
const live = realtime.online().find((o) => o.imvuId === l.imvuId);
return {
imvuId: l.imvuId,
username: l.username,
linkedAt: l.linkedAt.toISOString(),
ageVerified: l.ageVerified,
accessPass: l.accessPass,
adult: l.ageVerified || l.accessPass,
checkedAt: l.checkedAt.toISOString(),
showBadge: l.showBadge,
showPresence: l.showPresence,
online: live !== undefined,
outfit: live?.outfit ?? [],
};
};
const audited = (actorId: string | null, action: string, target: string, detail: Record<string, unknown> = {}) =>
db.insert(audit).values({ actorId, action, target, detail });
/** Who's online that viewer may see: accounts showing their presence (moderators see all). */
const visibleOnline = async (viewer: Person | undefined) => {
const online = realtime.online();
if (online.length === 0) return [];
const links = await db.select().from(imvuLink).where(inArray(imvuLink.imvuId, online.map((o) => o.imvuId)));
const mod = viewer !== undefined && isModerator(viewer);
return online.flatMap((o) => {
const l = links.find((l) => l.imvuId === o.imvuId);
if (!l || (!l.showPresence && !mod)) return [];
return [
{
imvuId: o.imvuId,
username: l.username,
badge: l.showBadge,
since: o.since.toISOString(),
outfit: o.outfit,
...(mod ? { hidden: !l.showPresence, personId: o.userId, adult: l.ageVerified || l.accessPass } : {}),
},
];
});
};
const imvuIdParam = t.Object({ imvu: t.Numeric({ minimum: 1 }) });
const ownLink = async (p: Person, imvuId: number) => {
const [l] = await db.select().from(imvuLink).where(and(eq(imvuLink.imvuId, imvuId), eq(imvuLink.userId, p.id)));
if (!l) throw new Problem(404, "not_found", "That account isn't linked to you.");
return l;
};
const imvuCall = async <T>(p: Person, call: () => Promise<T>): Promise<T> => {
if (!limiter.allow(`imvu:${p.id}`, 20, 60 * 60 * 1000))
throw new Problem(429, "rate_limited", "Too many lookups. Try again in a while.");
try {
return await call();
} catch (e) {
if (e instanceof NoSuchImvuUser) throw new Problem(404, "no_such_imvu_account", "IMVU has no account by that name.");
if (e instanceof ImvuUnavailable) throw new Problem(502, "imvu_unavailable", "IMVU didn't answer. Try again in a moment.");
throw e;
}
};
const personById = async (id: string) => {
const [p] = await db.select().from(user).where(eq(user.id, id));
if (!p) throw new Problem(404, "not_found", "No such person.");
return p;
};
const api = new Elysia({ prefix: "/api/v1" })
.error({ Problem })
.onError(({ code, error, set }) => {
if (code === "Problem") {
set.status = error.status;
return { error: error.slug, message: error.message };
}
if (code === "VALIDATION") {
set.status = 400;
return { error: "invalid", message: "Some of that isn't right. Check it and try again." };
}
if (code === "NOT_FOUND") {
set.status = 404;
return { error: "not_found", message: "Not found." };
}
})
.derive(async ({ request }) => {
const s = await auth.api.getSession({ headers: request.headers });
const person = s?.user ? ((await db.select().from(user).where(eq(user.id, s.user.id)))[0] ?? undefined) : undefined;
return { person: person && !person.banned ? person : undefined, sessionId: s?.session.id };
})
.macro({
signedIn: {
resolve: ({ person }) => {
if (!person) throw new Problem(401, "signed_out", "Sign in first.");
return { me: person };
},
},
moderator: {
resolve: ({ person }) => {
if (!person) throw new Problem(401, "signed_out", "Sign in first.");
if (!isModerator(person)) throw new Problem(403, "forbidden", "Only moderators can do this.");
return { me: person };
},
},
})
// Who's signed in, and the home page.
.get("/me", ({ me }) => ({ person: personView(me) }), { signedIn: true })
.get("/home", async ({ person }) => {
const online = (await visibleOnline(person)).length;
if (!person) return { onlineCount: online };
const [row] = await db.select({ n: count() }).from(imvuLink).where(eq(imvuLink.userId, person.id));
return { onlineCount: online, links: row?.n ?? 0 };
})
// Who's online.
.get("/online", async ({ person }) => ({ online: await visibleOnline(person) }))
.get("/online/events", ({ person, request }) => {
const encoder = new TextEncoder();
let stop = () => {};
const stream = new ReadableStream({
start(controller) {
const send = async () => {
const online = await visibleOnline(person);
try {
controller.enqueue(encoder.encode(`event: online\ndata: ${JSON.stringify({ online })}\n\n`));
} catch {}
};
const unsubscribe = realtime.subscribe(() => void send());
const keepAlive = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keep-alive\n\n"));
} catch {}
}, 25_000);
stop = () => {
unsubscribe();
clearInterval(keepAlive);
};
request.signal.addEventListener("abort", () => {
stop();
try {
controller.close();
} catch {}
});
void send();
},
cancel() {
stop();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" },
});
})
// Your account.
.get(
"/account",
async ({ me }) => {
const links = await db.select().from(imvuLink).where(eq(imvuLink.userId, me.id)).orderBy(imvuLink.linkedAt);
const challenges = await db
.select()
.from(linkChallenge)
.where(and(eq(linkChallenge.userId, me.id), sql`${linkChallenge.createdAt} > now() - interval '1 day'`))
.orderBy(linkChallenge.createdAt);
const [computers] = await db
.select({ n: count() })
.from(session)
.where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
return {
person: personView(me),
links: links.map(linkView),
challenges: challenges.map((c) => ({
imvuId: c.imvuId,
username: c.username,
code: c.code,
createdAt: c.createdAt.toISOString(),
expiresAt: new Date(c.createdAt.getTime() + challengeLifetime).toISOString(),
})),
computers: computers?.n ?? 0,
canLink: links.length < me.linkCap,
};
},
{ signedIn: true },
)
.post(
"/account/links",
async ({ me, body, set }) => {
const found = await imvuCall(me, () => imvu.byUsername(body.username));
const [existing] = await db.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id));
if (existing?.userId === me.id)
throw new Problem(409, "already_linked", `${found.username} is already linked.`);
const code = linkCode();
const [c] = await db
.insert(linkChallenge)
.values({ userId: me.id, imvuId: found.id, username: found.username, code })
.onConflictDoUpdate({
target: [linkChallenge.userId, linkChallenge.imvuId],
set: { code, username: found.username, createdAt: new Date() },
})
.returning();
set.status = 201;
return {
challenge: {
imvuId: c!.imvuId,
username: c!.username,
code: c!.code,
createdAt: c!.createdAt.toISOString(),
expiresAt: new Date(c!.createdAt.getTime() + challengeLifetime).toISOString(),
},
};
},
{ signedIn: true, body: t.Object({ username: t.String({ minLength: 1, maxLength: 64 }) }) },
)
.post(
"/account/links/:imvu/check",
async ({ me, params }) => {
const [c] = await db
.select()
.from(linkChallenge)
.where(
and(
eq(linkChallenge.userId, me.id),
eq(linkChallenge.imvuId, params.imvu),
sql`${linkChallenge.createdAt} > now() - interval '1 day'`,
),
);
if (!c) throw new Problem(410, "expired", "That code expired. Start again.");
const found = await imvuCall(me, () => imvu.byId(params.imvu));
if (!found.tagline.toLowerCase().includes(c.code.toLowerCase()))
throw new Problem(
409,
"code_missing",
`${found.username}'s tagline doesn't have the code yet. IMVU can take a minute to show a new tagline.`,
);
const result = await db.transaction(async (tx) => {
const [{ n } = { n: 0 }] = await tx
.select({ n: count() })
.from(imvuLink)
.where(and(eq(imvuLink.userId, me.id), ne(imvuLink.imvuId, found.id)));
if (n >= me.linkCap)
throw new Problem(403, "link_cap", `You can link up to ${me.linkCap} IMVU accounts. Ask a moderator if you need more.`);
const [prev] = await tx.select().from(imvuLink).where(eq(imvuLink.imvuId, found.id));
const moved = prev !== undefined && prev.userId !== me.id;
const values = {
userId: me.id,
username: found.username,
ageVerified: found.ageVerified,
accessPass: found.accessPass,
checkedAt: new Date(),
};
let link: Link;
if (!prev) {
[link] = (await tx.insert(imvuLink).values({ imvuId: found.id, pairwiseId: randomID(), ...values }).returning()) as [Link];
} else if (moved) {
// A moved account gets a new pairwise ID and starts with nothing shown: nothing connects the new owner
// to the old one.
[link] = (await tx
.update(imvuLink)
.set({ ...values, pairwiseId: randomID(), linkedAt: new Date(), showBadge: false, showPresence: false })
.where(eq(imvuLink.imvuId, found.id))
.returning()) as [Link];
} else {
[link] = (await tx.update(imvuLink).set(values).where(eq(imvuLink.imvuId, found.id)).returning()) as [Link];
}
await tx.delete(linkChallenge).where(eq(linkChallenge.imvuId, found.id));
await tx.insert(audit).values({
actorId: me.id,
action: "link.add",
target: String(found.id),
detail: moved ? { imvu: found.username, movedFrom: prev!.userId } : { imvu: found.username },
});
return { link, moved };
});
return { link: linkView(result.link), moved: result.moved };
},
{ signedIn: true, params: imvuIdParam },
)
.delete(
"/account/challenges/:imvu",
async ({ me, params, set }) => {
await db.delete(linkChallenge).where(and(eq(linkChallenge.userId, me.id), eq(linkChallenge.imvuId, params.imvu)));
set.status = 204;
},
{ signedIn: true, params: imvuIdParam },
)
.patch(
"/account/links/:imvu",
async ({ me, params, body }) => {
await ownLink(me, params.imvu);
const [l] = await db
.update(imvuLink)
.set({
...(body.showBadge !== undefined ? { showBadge: body.showBadge } : {}),
...(body.showPresence !== undefined ? { showPresence: body.showPresence } : {}),
})
.where(eq(imvuLink.imvuId, params.imvu))
.returning();
return { link: linkView(l!) };
},
{
signedIn: true,
params: imvuIdParam,
body: t.Object({ showBadge: t.Optional(t.Boolean()), showPresence: t.Optional(t.Boolean()) }),
},
)
.post(
"/account/links/:imvu/recheck",
async ({ me, params }) => {
await ownLink(me, params.imvu);
const found = await imvuCall(me, () => imvu.byId(params.imvu));
const [l] = await db
.update(imvuLink)
.set({ username: found.username, ageVerified: found.ageVerified, accessPass: found.accessPass, checkedAt: new Date() })
.where(eq(imvuLink.imvuId, params.imvu))
.returning();
return { link: linkView(l!) };
},
{ signedIn: true, params: imvuIdParam },
)
.delete(
"/account/links/:imvu",
async ({ me, params, set }) => {
const l = await ownLink(me, params.imvu);
await db.delete(imvuLink).where(eq(imvuLink.imvuId, l.imvuId));
await audited(me.id, "link.remove", String(l.imvuId), { imvu: l.username });
set.status = 204;
},
{ signedIn: true, params: imvuIdParam },
)
.post(
"/account/signout-computers",
async ({ me, set }) => {
await db.delete(session).where(and(eq(session.userId, me.id), ilike(session.userAgent, "t5de-proxy%")));
realtime.disconnect(me.id);
set.status = 204;
},
{ signedIn: true },
)
// A proxy's sign-in code, approved by the person on the website.
.post(
"/device/approve",
async ({ me, body, request, set }) => {
const raw = body.code.toUpperCase().replace(/[\s-]/g, "");
try {
// Viewing the code claims it for this person; then they approve it.
await auth.api.deviceVerify({ query: { user_code: raw }, headers: request.headers });
await auth.api.deviceApprove({ body: { userCode: raw }, headers: request.headers });
} catch {
throw new Problem(404, "not_found", "That code isn't right, or it expired. Check the code T5DE shows.");
}
await audited(me.id, "proxy.signin", "");
set.status = 204;
},
{ signedIn: true, body: t.Object({ code: t.String({ minLength: 4, maxLength: 32 }) }) },
)
// The console.
.get(
"/console",
async ({ me }) => {
const [counts] = await db
.select({
people: sql<number>`(select count(*) from ${user})::int`,
links: sql<number>`(select count(*) from ${imvuLink})::int`,
computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`,
})
.from(sql`(select 1) as one`);
const log = await db
.select({ at: audit.at, actor: user.username, action: audit.action, target: audit.target, detail: audit.detail })
.from(audit)
.leftJoin(user, eq(user.id, audit.actorId))
.orderBy(desc(audit.id))
.limit(50);
return {
counts: counts!,
connections: realtime.connections(),
online: await visibleOnline(me),
audit: log.map((e) => ({ ...e, at: e.at.toISOString(), actor: e.actor ?? "" })),
};
},
{ moderator: true },
)
.get(
"/console/people",
async ({ query }) => {
const q = (query.q ?? "").trim();
const like = `%${q.replace(/[%_\\]/g, (c) => "\\" + c)}%`;
const rows = await db
.select()
.from(user)
.where(
q
? or(
ilike(user.username, like),
inArray(user.id, db.select({ id: imvuLink.userId }).from(imvuLink).where(ilike(imvuLink.username, like))),
)
: undefined,
)
.orderBy(desc(user.createdAt))
.limit(100);
return { people: rows.map(personView) };
},
{ moderator: true, query: t.Object({ q: t.Optional(t.String({ maxLength: 64 })) }) },
)
.get(
"/console/people/:id",
async ({ params }) => {
const p = await personById(params.id);
const links = await db.select().from(imvuLink).where(eq(imvuLink.userId, p.id)).orderBy(imvuLink.linkedAt);
const [computers] = await db
.select({ n: count() })
.from(session)
.where(and(eq(session.userId, p.id), ilike(session.userAgent, "t5de-proxy%"), sql`${session.expiresAt} > now()`));
return { person: personView(p), links: links.map(linkView), computers: computers?.n ?? 0 };
},
{ moderator: true },
)
.post(
"/console/people/:id/link-cap",
async ({ me, params, body }) => {
const p = await personById(params.id);
const [updated] = await db.update(user).set({ linkCap: body.linkCap }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.link_cap", p.username ?? p.id, { from: p.linkCap, to: body.linkCap });
return { person: personView(updated!) };
},
{ moderator: true, body: t.Object({ linkCap: t.Integer({ minimum: 0, maximum: 1000 }) }) },
)
.post(
"/console/people/:id/ban",
async ({ me, params, body }) => {
const p = await personById(params.id);
if (p.id === me.id) throw new Problem(400, "invalid", "You can't ban yourself.");
if (isModerator(p) && me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can ban moderators.");
const [updated] = await db.update(user).set({ banned: true, banReason: body.reason }).where(eq(user.id, p.id)).returning();
await db.delete(session).where(eq(session.userId, p.id));
realtime.disconnect(p.id);
await audited(me.id, "person.ban", p.username ?? p.id, { reason: body.reason });
return { person: personView(updated!) };
},
{ moderator: true, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
)
.post(
"/console/people/:id/unban",
async ({ me, params }) => {
const p = await personById(params.id);
const [updated] = await db.update(user).set({ banned: false, banReason: null }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.unban", p.username ?? p.id);
return { person: personView(updated!) };
},
{ moderator: true },
)
.post(
"/console/people/:id/role",
async ({ me, params, body }) => {
if (me.role !== "admin") throw new Problem(403, "forbidden", "Only admins can change roles.");
const p = await personById(params.id);
if (p.id === me.id) throw new Problem(400, "invalid", "Another admin has to change your role.");
const [updated] = await db.update(user).set({ role: body.role }).where(eq(user.id, p.id)).returning();
await audited(me.id, "person.role", p.username ?? p.id, { from: p.role, to: body.role });
return { person: personView(updated!) };
},
{
moderator: true,
body: t.Object({ role: t.Union([t.Literal("user"), t.Literal("moderator"), t.Literal("admin")]) }),
},
)
// T5DE's proxy: the live connection.
.ws("/realtime", {
async beforeHandle({ person, set }) {
if (!person) {
set.status = 401;
return { error: "signed_out", message: "Sign in first." };
}
},
open(ws) {
const { person } = ws.data as unknown as { person: Person };
realtime.open(ws.id, person.id, () => ws.close(4001, "signed out"));
ws.send(JSON.stringify({ type: "welcome" }));
},
async message(ws, message) {
const reply = await realtime.message(ws.id, message);
if (reply) ws.send(JSON.stringify(reply));
},
close(ws) {
realtime.close(ws.id);
},
});
const app = new Elysia()
.all("/api/auth/*", ({ request }) => auth.handler(request))
.get("/.well-known/jwks.json", async () => auth.api.getJwks())
.get("/health", () => ({ ok: true }))
.use(api);
return { app, realtime };
}
export type App = ReturnType<typeof createApp>["app"];
+70
View File
@@ -0,0 +1,70 @@
// Accounts, sessions and proxy sign-in, by Better Auth: email and password with usernames, roles and bans (admin),
// device sign-in for T5DE's proxy (RFC 8628), bearer tokens for the proxy's requests, and JWTs with published keys
// (for catalogs later).
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, bearer, deviceAuthorization, jwt, username } from "better-auth/plugins";
import { count, schema, type Database } from "@t5de/db";
export type AuthConfig = {
/** The API's own address, e.g. https://hub-api.t5de.dev. */
apiURL: string;
/** The website's address, e.g. https://hub.t5de.dev: trusted origin, and where /device is. */
webURL: string;
/** At least 32 random characters; signs sessions. */
secret: string;
/** Rate limits on sign-up, sign-in and the rest (on unless false: tests turn them off). */
rateLimit?: boolean;
};
/** The proxy's client ID in device sign-in. */
export const proxyClientID = "t5de-proxy";
export function createAuth(db: Database, config: AuthConfig) {
return betterAuth({
baseURL: config.apiURL,
basePath: "/api/auth",
secret: config.secret,
trustedOrigins: [config.webURL],
database: drizzleAdapter(db, { provider: "pg", schema }),
emailAndPassword: { enabled: true, minPasswordLength: 10, maxPasswordLength: 256 },
user: {
additionalFields: {
linkCap: { type: "number", defaultValue: 10, input: false },
},
},
// Proxies stay signed in for months; a session is extended when it's used.
session: { expiresIn: 60 * 60 * 24 * 60, updateAge: 60 * 60 * 24 },
rateLimit: { enabled: config.rateLimit !== false, window: 60, max: 100 },
advanced: { ipAddress: { ipAddressHeaders: ["cf-connecting-ip", "x-forwarded-for"] } },
databaseHooks: {
user: {
create: {
// The first account runs the hub.
before: async (u) => {
const [row] = await db.select({ n: count() }).from(schema.user);
return { data: { ...u, role: (row?.n ?? 0) === 0 ? "admin" : "user" } };
},
},
},
},
plugins: [
username({
minUsernameLength: 3,
maxUsernameLength: 32,
usernameValidator: (name) => /^[A-Za-z0-9_]+$/.test(name),
}),
admin({ defaultRole: "user", adminRoles: ["admin"] }),
deviceAuthorization({
verificationUri: `${config.webURL}/device`,
expiresIn: "10m",
interval: "3s",
validateClient: (clientId) => clientId === proxyClientID,
}),
bearer(),
jwt({ jwks: { keyPairConfig: { alg: "EdDSA", crv: "Ed25519" } } }),
],
});
}
export type Auth = ReturnType<typeof createAuth>;
+81
View File
@@ -0,0 +1,81 @@
// IMVU's public user API: who an account is, its tagline (for linking) and whether it's age verified or has Access
// Pass. The hub calls it rarely (linking, and age checks) and depends on it for nothing else.
export type ImvuUser = {
id: number;
username: string;
tagline: string;
ageVerified: boolean;
accessPass: boolean;
};
export class NoSuchImvuUser extends Error {
constructor() {
super("no such IMVU account");
}
}
export class ImvuUnavailable extends Error {}
// Calls are spaced out so the hub never floods IMVU.
const minInterval = 200;
export class ImvuClient {
private next = 0;
constructor(
readonly base = "https://api.imvu.com",
private readonly fetchFn: typeof fetch = fetch,
) {}
byUsername(username: string): Promise<ImvuUser> {
const name = username.trim();
if (!name || name.length > 64) return Promise.reject(new NoSuchImvuUser());
return this.get(`/user?username=${encodeURIComponent(name)}`);
}
byId(id: number): Promise<ImvuUser> {
return this.get(`/user/user-${id}`);
}
private async wait() {
const now = Date.now();
const at = Math.max(now, this.next);
this.next = at + minInterval;
if (at > now) await Bun.sleep(at - now);
}
private async get(path: string): Promise<ImvuUser> {
await this.wait();
let response: Response;
try {
response = await this.fetchFn(this.base + path, {
headers: { "User-Agent": "t5de-hub (https://git.t5de.dev/t5de/t5de-hub)" },
signal: AbortSignal.timeout(15_000),
});
} catch (e) {
throw new ImvuUnavailable(String(e));
}
if (response.status === 404) throw new NoSuchImvuUser();
if (!response.ok) throw new ImvuUnavailable(`IMVU answered ${response.status}`);
return parse(await response.json());
}
}
// IMVU's "denormalized" answers: the user is the one /user/user-<id> entry.
export function parse(answer: unknown): ImvuUser {
const den = (answer as { denormalized?: Record<string, { data?: Record<string, unknown> }> })?.denormalized ?? {};
const users = Object.entries(den).filter(([key]) => key.includes("/user/user-"));
if (users.length !== 1) throw new NoSuchImvuUser();
const data = users[0]![1].data ?? {};
const id = Number(data.legacy_cid);
const username = typeof data.username === "string" ? data.username : "";
if (!Number.isSafeInteger(id) || id <= 0 || !username) throw new NoSuchImvuUser();
return {
id,
username,
tagline: typeof data.tagline === "string" ? data.tagline : "",
ageVerified: data.is_ageverified === true,
accessPass: data.is_ap === true,
};
}
+27
View File
@@ -0,0 +1,27 @@
// Runs the hub's API. Configuration (environment):
// DATABASE_URL postgres://... (default: PGlite files in ./data/db, for development)
// AUTH_SECRET at least 32 random characters (required unless NODE_ENV is development)
// 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)
// PORT default 4101
import { openDatabase } from "@t5de/db";
import { createApp } from "./app";
import { createAuth } from "./auth";
import { ImvuClient } from "./imvu";
const env = process.env;
const dev = env.NODE_ENV !== "production";
const port = Number(env.PORT ?? 4101);
const secret = env.AUTH_SECRET ?? (dev ? "development-secret-not-for-production-use" : "");
if (secret.length < 32) {
console.error("AUTH_SECRET must be at least 32 characters");
process.exit(1);
}
const apiURL = env.API_URL ?? `http://localhost:${port}`;
const webURL = env.WEB_URL ?? "http://localhost:4100";
const { db } = await openDatabase(env.DATABASE_URL ?? "./data/db");
const auth = createAuth(db, { apiURL, webURL, secret });
const { app } = createApp({ db, auth, imvu: new ImvuClient() });
app.listen({ port, hostname: env.HOST ?? "0.0.0.0" });
console.log(`t5de-hub API on ${apiURL} (website ${webURL})`);
+106
View File
@@ -0,0 +1,106 @@
// The live connection with signed-in proxies: which IMVU account each client is using (presence), and the
// community products it wears. Later, room channels.
//
// Protocol (JSON text messages over the WebSocket at /api/v1/realtime, authorized with the proxy's session token as
// a bearer token):
// 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
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
export type Item = { id: string; name: string };
export type Online = { userId: string; imvuId: number; since: Date; outfit: Item[] };
type Session = { userId: string; imvuId: number; since: Date; outfit: Item[]; close: () => void };
export type Owners = (imvuId: number) => Promise<string | undefined>;
export class Realtime {
private sessions = new Map<string, Session>();
private listeners = new Set<() => void>();
constructor(private readonly owner: Owners) {}
/** Calls fn on every change of presence or outfits; returns a function that stops it. */
subscribe(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private changed() {
for (const fn of this.listeners) fn();
}
/** The IMVU accounts in use now, one entry each (the most recent session), oldest first. */
online(): Online[] {
const byId = new Map<number, Online>();
for (const s of this.sessions.values()) {
if (!s.imvuId) continue;
const prev = byId.get(s.imvuId);
if (prev && prev.since > s.since) continue;
byId.set(s.imvuId, { userId: s.userId, imvuId: s.imvuId, since: s.since, outfit: [...s.outfit] });
}
return [...byId.values()].sort((a, b) => a.since.getTime() - b.since.getTime());
}
connections(): number {
return this.sessions.size;
}
open(id: string, userId: string, close: () => void) {
this.sessions.set(id, { userId, imvuId: 0, since: new Date(), outfit: [], close });
}
close(id: string) {
const s = this.sessions.get(id);
this.sessions.delete(id);
if (s?.imvuId) this.changed();
}
/** Closes a person's connections (banned, or signed out of their computers). */
disconnect(userId: string) {
for (const s of this.sessions.values()) if (s.userId === userId) s.close();
}
/** Handles a message from a proxy; returns the reply to send, if any. */
async message(id: string, raw: unknown): Promise<object | undefined> {
const s = this.sessions.get(id);
if (!s) return;
const m = (typeof raw === "string" ? safeJSON(raw) : raw) as { type?: unknown; imvu_id?: unknown; items?: unknown };
if (!m || typeof m !== "object") return { type: "error", message: "not a message" };
if (m.type === "session") {
const imvuId = Number(m.imvu_id);
const owner = Number.isSafeInteger(imvuId) && imvuId > 0 ? await this.owner(imvuId) : undefined;
if (owner !== s.userId) {
// Not this person's account: no presence, no outfit for it.
s.imvuId = 0;
s.outfit = [];
this.changed();
return { type: "error", message: "not_linked" };
}
s.imvuId = imvuId;
s.since = new Date();
s.outfit = [];
this.changed();
return;
}
if (m.type === "outfit") {
if (!Array.isArray(m.items) || m.items.length > 64) return { type: "error", message: "too many items" };
s.outfit = m.items
.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)
.map((it) => ({ id: it.id, name: it.name }));
this.changed();
return;
}
return { type: "error", message: "unknown message type" };
}
}
function safeJSON(s: string): unknown {
try {
return JSON.parse(s);
} catch {
return undefined;
}
}
+249
View File
@@ -0,0 +1,249 @@
// The whole API against an in-process Postgres (PGlite) and a fake IMVU.
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { openDatabase, type Database } from "@t5de/db";
import { createApp } from "../src/app";
import { createAuth, proxyClientID } from "../src/auth";
import { ImvuClient, type ImvuUser } from "../src/imvu";
const webURL = "http://web.test";
const accounts = new Map<number, ImvuUser>();
// IMVU's public user API, answering from accounts.
const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => {
const url = new URL(String(input));
const found = [...accounts.values()].find(
(u) =>
url.pathname === `/user/user-${u.id}` ||
(url.pathname === "/user" && url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase()),
);
const denormalized = found
? {
[`https://api.imvu.com/user/user-${found.id}`]: {
data: {
legacy_cid: found.id,
username: found.username,
tagline: found.tagline,
is_ageverified: found.ageVerified,
is_ap: found.accessPass,
},
},
}
: {};
return Response.json({ status: "success", denormalized });
}) as typeof fetch;
let db: Database;
let close: () => Promise<void>;
let hub: ReturnType<typeof createApp>;
let server: { port: number; stop: () => void } | undefined;
let base: string;
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 });
hub = createApp({ db, auth, imvu: new ImvuClient("http://imvu.test", fakeIMVU) });
hub.app.listen({ port: 0, hostname: "127.0.0.1" });
server = hub.app.server as unknown as { port: number; stop: () => void };
base = `http://127.0.0.1:${server.port}`;
});
afterAll(async () => {
server?.stop();
await close();
});
/** A person using the website: keeps the session cookie. */
class Browser {
cookie = "";
async call(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" } : {}),
...(this.cookie ? { Cookie: this.cookie } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const set = res.headers.getSetCookie();
if (set.length) this.cookie = set.map((c) => c.split(";")[0]).join("; ");
const text = await res.text();
let json: any;
try {
json = JSON.parse(text);
} catch {
json = text;
}
return { status: res.status, json };
}
async signup(username: string) {
const r = await this.call("POST", "/api/auth/sign-up/email", {
email: `${username.toLowerCase()}@example.test`,
password: "a long password",
name: username,
username,
});
expect(r.status).toBe(200);
return r;
}
}
/** Links an IMVU account through the API, putting the code in its fake tagline. */
async function link(b: Browser, u: Omit<ImvuUser, "tagline">) {
accounts.set(u.id, { ...u, tagline: "" });
const started = await b.call("POST", "/api/v1/account/links", { username: u.username });
expect(started.status).toBe(201);
accounts.set(u.id, { ...u, tagline: `hi ${started.json.challenge.code.toLowerCase()}` });
return b.call("POST", `/api/v1/account/links/${u.id}/check`);
}
describe("accounts", () => {
test("the first account is the admin; usernames sign in", async () => {
const alice = new Browser();
await alice.signup("alice");
expect((await alice.call("GET", "/api/v1/me")).json.person.role).toBe("admin");
const bob = new Browser();
await bob.signup("bob");
expect((await bob.call("GET", "/api/v1/me")).json.person.role).toBe("user");
expect((await bob.call("GET", "/api/v1/console")).status).toBe(403);
const again = new Browser();
const wrong = await again.call("POST", "/api/auth/sign-in/username", { username: "bob", password: "nope nope nope" });
expect(wrong.status).toBe(401);
const right = await again.call("POST", "/api/auth/sign-in/username", { username: "BOB", password: "a long password" });
expect(right.status).toBe(200);
expect((await again.call("GET", "/api/v1/me")).json.person.username).toBe("bob");
const taken = await new Browser().call("POST", "/api/auth/sign-up/email", {
email: "[email protected]",
password: "a long password",
name: "Alice",
username: "ALICE",
});
expect(taken.status).toBeGreaterThanOrEqual(400);
expect((await new Browser().call("GET", "/api/v1/me")).status).toBe(401);
});
});
describe("linking IMVU accounts", () => {
test("by tagline; the most recent proof wins; link caps", async () => {
const carol = new Browser();
await carol.signup("carol");
accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false });
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}$/);
const missing = await carol.call("POST", "/api/v1/account/links/42/check");
expect(missing.status).toBe(409);
expect(missing.json.error).toBe("code_missing");
const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false });
expect(linked.status).toBe(200);
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(
"no_such_imvu_account",
);
// Dave proves the same account is his: it moves, with a new pairwise ID.
const before = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
const dave = new Browser();
await dave.signup("dave");
const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false });
expect(moved.json.moved).toBe(true);
const after = (await db.query.imvuLink.findFirst({ where: (l, { eq }) => eq(l.imvuId, 42) }))!;
expect(after.userId).not.toBe(before.userId);
expect(after.pairwiseId).not.toBe(before.pairwiseId);
expect((await carol.call("GET", "/api/v1/account")).json.links).toHaveLength(0);
// A moderator lowers Dave's link cap to 1: a second account is refused.
const admin = new Browser();
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;
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 });
expect(over.status).toBe(403);
expect(over.json.error).toBe("link_cap");
});
});
describe("T5DE's proxy", () => {
test("signs in with a device code, connects, shows presence; a ban ends it", async () => {
const erin = new Browser();
await erin.signup("erin");
await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false });
// The proxy asks for a code; until it's approved, the token request waits.
const codeRes = await fetch(`${base}/api/auth/device/code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: proxyClientID }),
});
const device = await codeRes.json();
expect(device.verification_uri).toBe(`${webURL}/device`);
const poll = async () =>
(
await fetch(`${base}/api/auth/device/token`, {
method: "POST",
headers: { "Content-Type": "application/json", "User-Agent": "t5de-proxy/test" },
body: JSON.stringify({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: device.device_code,
client_id: proxyClientID,
}),
})
).json();
expect((await poll()).error).toBe("authorization_pending");
const userCode = device.user_code as string;
const approved = await erin.call("POST", "/api/v1/device/approve", {
code: userCode.toLowerCase().slice(0, 4) + "-" + userCode.toLowerCase().slice(4),
});
expect(approved.status).toBe(204);
await Bun.sleep(3100); // the proxy keeps to the polling interval
const tokens = await poll();
expect(tokens.access_token).toBeString();
const bearer = { Authorization: `Bearer ${tokens.access_token}` };
const me = await fetch(`${base}/api/v1/me`, { headers: bearer });
expect((await me.json()).person.username).toBe("erin");
// The live connection: another person's account is refused; Erin's shows online with her outfit, to moderators,
// and to everyone once she opts in.
const ws = new WebSocket(`${base.replace("http", "ws")}/api/v1/realtime`, { headers: bearer } as any);
const messages: any[] = [];
ws.onmessage = (e) => messages.push(JSON.parse(String(e.data)));
await new Promise((r) => (ws.onopen = r));
ws.send(JSON.stringify({ type: "session", imvu_id: 42 }));
await waitFor(() => messages.some((m) => m.message === "not_linked"));
ws.send(JSON.stringify({ type: "session", imvu_id: 77 }));
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));
const visitor = new Browser();
expect((await visitor.call("GET", "/api/v1/online")).json.online).toHaveLength(0);
const admin = new Browser();
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;
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
expect(consoleView.counts.computers).toBe(1);
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
expect(online).toEqual([
{ imvuId: 77, username: "ErinAv", badge: true, since: expect.any(String), outfit: [{ id: "blue-coat", name: "Blue Coat" }] },
]);
// A ban closes the connection and ends the proxy's session.
const erinId = (await erin.call("GET", "/api/v1/me")).json.person.id;
const closed = new Promise((r) => (ws.onclose = r));
expect((await admin.call("POST", `/api/v1/console/people/${erinId}/ban`, { reason: "testing" })).status).toBe(200);
await closed;
expect((await fetch(`${base}/api/v1/me`, { headers: bearer })).status).toBe(401);
expect((await admin.call("GET", "/api/v1/console")).json.audit.map((e: any) => e.action)).toContain("person.ban");
}, 20_000);
});
async function waitFor(ok: () => boolean) {
for (let i = 0; i < 200; i++) {
if (ok()) return;
await Bun.sleep(10);
}
throw new Error("timed out");
}
+7
View File
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true,
"noEmit": true, "skipLibCheck": true, "types": ["bun"], "noUncheckedIndexedAccess": true
},
"include": ["src", "test"]
}
+4
View File
@@ -0,0 +1,4 @@
/.next/
/node_modules/
next-env.d.ts
*.tsbuildinfo
+37
View File
@@ -0,0 +1,37 @@
# The hub's website as a standalone Next.js server. Build from the repository root (the app uses the workspace's
# packages for the API's types):
#
# docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web .
# docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web
#
# API_URL is needed at build time too: Next fixes the /api rewrite when it builds.
FROM oven/bun:1.4.4 AS bun
FROM node:26-slim AS build
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /repo
ENV NEXT_TELEMETRY_DISABLED=1
# Dependencies first, so they're cached while only source changes.
COPY package.json bun.lock ./
COPY apps/web/package.json apps/web/
COPY apps/api/package.json apps/api/
COPY packages/db/package.json packages/db/
RUN bun install --frozen-lockfile
COPY packages/db packages/db
COPY apps/api apps/api
COPY apps/web apps/web
ARG API_URL=http://api:4101
ENV API_URL=$API_URL
RUN cd apps/web && bun run build
FROM node:26-slim
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4100 HOSTNAME=0.0.0.0
ARG API_URL=http://api:4101
ENV API_URL=$API_URL
COPY --from=build --chown=node:node /repo/apps/web/.next/standalone ./
COPY --from=build --chown=node:node /repo/apps/web/.next/static ./apps/web/.next/static
USER node
EXPOSE 4100
CMD ["node", "apps/web/server.js"]
+7
View File
@@ -0,0 +1,7 @@
**/node_modules
**/.next
**/.turbo
**/data
**/*.tsbuildinfo
.git
dist
+52
View File
@@ -0,0 +1,52 @@
# T5DE Hub website
The hub's website: accounts, linked IMVU accounts, who's online, signing T5DE in, and the moderation console.
Next.js (App Router), Tailwind CSS and shadcn/ui. Pages are rendered on the server, which calls the hub's API
(`apps/api`) with the visitor's cookie; forms use server actions. The browser only talks to this app: `/api/*` is
rewritten to the API, so the API's session cookies belong to the website's origin.
## Develop
From the repository root, `bun install`, then start the API (`cd apps/api && bun src/index.ts`) and the website:
```sh
cd apps/web
bun run dev # http://localhost:4100
bun run check-types
bun run build
```
The first account created is the hub's admin.
## Configuration
| Variable | Default | What |
| --------- | ----------------------- | -------------------------------------------------------------------------------------- |
| `API_URL` | `http://localhost:4101` | The API's address as this app reaches it. Needed at build time and at run time (below). |
| `PORT` | `4100` | The standalone server's port (`next start` and `bun run dev` use 4100). |
The API's `WEB_URL` must be this website's public address (it's where Better Auth trusts requests from, and where
T5DE's sign-in code page is).
`API_URL` is read in two places: by server code on every request, and by `next.config.ts`'s `/api` rewrite, which
Next fixes when it builds. So build with the same `API_URL` the app will run with.
## Deploy
### Container
The `Dockerfile` builds a standalone server (`output: "standalone"`). Build it from the repository root:
```sh
docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web .
docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web
```
Next's own gzip is off (it held back the API's live event stream), so put a reverse proxy that compresses in front,
and don't let it buffer `text/event-stream` responses (`/api/v1/online/events`).
### Vercel
Import the repository with the project's root directory set to `apps/web` (Vercel installs the Bun workspace from the
root). Set `API_URL` to the API's public address for both builds and runtime. Nothing else is needed: there's no
custom server.
+60
View File
@@ -0,0 +1,60 @@
"use server";
import { revalidatePath } from "next/cache";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const done = () => revalidatePath("/account");
/** Starts linking an IMVU account: the API makes a code to put in its tagline. */
export async function linkAccount(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
unwrap(await api.account.links.post({ username: field(form, "username") }));
done();
});
}
/** A pending link: check the tagline for the code, or cancel. */
export async function challengeAction(_: ActionState, form: FormData): Promise<ActionState> {
const imvu = field(form, "imvu");
return attempt(async () => {
if (field(form, "intent") === "cancel") {
unwrap(await api.account.challenges({ imvu }).delete());
} else {
unwrap(await api.account.links({ imvu }).check.post());
}
done();
});
}
/** A linked account: check its age status with IMVU again, or unlink it. */
export async function linkAction(_: ActionState, form: FormData): Promise<ActionState> {
const imvu = field(form, "imvu");
return attempt(async () => {
if (field(form, "intent") === "unlink") {
unwrap(await api.account.links({ imvu }).delete());
done();
return;
}
unwrap(await api.account.links({ imvu }).recheck.post());
done();
return "Checked with IMVU just now.";
});
}
export async function saveSettings(
imvu: number,
settings: { showBadge?: boolean; showPresence?: boolean },
): Promise<ActionState> {
return attempt(async () => {
unwrap(await api.account.links({ imvu }).patch(settings));
done();
});
}
export async function signOutComputers(_: ActionState, _form: FormData): Promise<ActionState> {
return attempt(async () => {
unwrap(await api.account["signout-computers"].post());
done();
return "Signed out everywhere.";
});
}
+58
View File
@@ -0,0 +1,58 @@
import { CopyButton } from "@/components/copy-button";
import { ActionForm, SubmitButton } from "@/components/forms";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { Challenge } from "@/lib/api";
import { challengeAction } from "./actions";
/** A link in progress: the code to put in the tagline, then Check. */
export function ChallengeCard({ challenge: c }: { challenge: Challenge }) {
return (
<Card id={`link-${c.imvuId}`}>
<CardHeader>
<CardTitle>Link {c.username}</CardTitle>
<CardDescription>To prove {c.username} is yours:</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={challengeAction} fields={{ imvu: c.imvuId }} className="space-y-6">
<ol className="space-y-5 text-sm">
<Step n={1}>
<p>
Put this code anywhere in {c.username}&apos;s IMVU tagline (on your avatar card in the client, or your
profile):
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<code className="rounded-lg border bg-muted/50 px-4 py-2.5 font-mono text-xl font-semibold tracking-wider select-all sm:text-2xl">
{c.code}
</code>
<CopyButton text={c.code} />
</div>
</Step>
<Step n={2}>
<p>Press Check. You can take the code out again once it&apos;s linked.</p>
</Step>
</ol>
<div className="flex flex-wrap items-center gap-3">
<SubmitButton name="intent" value="check" pendingLabel="Checking…">
Check
</SubmitButton>
<SubmitButton variant="outline" name="intent" value="cancel" pendingLabel="Cancelling…">
Cancel
</SubmitButton>
<span className="text-sm text-muted-foreground">The code works for a day.</span>
</div>
</ActionForm>
</CardContent>
</Card>
);
}
function Step({ n, children }: { n: number; children: React.ReactNode }) {
return (
<li className="flex gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full border text-xs font-medium text-muted-foreground">
{n}
</span>
<div className="min-w-0 flex-1 pt-0.5">{children}</div>
</li>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { ActionForm, SubmitButton } from "@/components/forms";
import { AgeTag, Initial, Outfit, Tag } from "@/components/status";
import { TimeAgo } from "@/components/time";
import type { Link } from "@/lib/api";
import { linkAction } from "./actions";
import { LinkSettings } from "./link-settings";
/** One linked IMVU account. */
export function LinkCard({ link }: { link: Link }) {
return (
<li className="flex gap-4 py-6 first:pt-0 last:pb-0">
<Initial name={link.username} />
<div className="flex min-w-0 flex-1 flex-col gap-5 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1 space-y-4">
<div className="space-y-2">
<div className="font-medium">{link.username}</div>
<div className="flex flex-wrap items-center gap-2">
{link.online && (
<Tag tone="online" dot>
Online in T5DE
</Tag>
)}
<AgeTag link={link} />
<span className="text-xs text-muted-foreground">
checked <TimeAgo iso={link.checkedAt} />
</span>
</div>
</div>
<Outfit items={link.outfit} />
<LinkSettings imvuId={link.imvuId} showBadge={link.showBadge} showPresence={link.showPresence} />
</div>
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
<div className="flex gap-2 sm:justify-end">
<SubmitButton
variant="outline"
name="intent"
value="recheck"
pendingLabel="Checking…"
title="Check age verification with IMVU again"
>
Check again
</SubmitButton>
<SubmitButton
variant="outline"
name="intent"
value="unlink"
pendingLabel="Unlinking…"
confirm={`Unlink ${link.username}? To link it again, you'll need a new code in its tagline.`}
>
Unlink
</SubmitButton>
</div>
</ActionForm>
</div>
</li>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
// A linked account's two settings, saved as soon as they're switched.
import { useOptimistic, useState, useTransition } from "react";
import { FormMessage } from "@/components/forms";
import { Switch } from "@/components/ui/switch";
import type { ActionState } from "@/lib/actions";
import { saveSettings } from "./actions";
type Settings = { showBadge: boolean; showPresence: boolean };
export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) {
const [settings, setOptimistic] = useOptimistic(saved);
const [state, setState] = useState<ActionState>(null);
const [pending, startTransition] = useTransition();
const change = (key: keyof Settings) => (value: boolean) =>
startTransition(async () => {
setOptimistic({ ...settings, [key]: value });
setState(await saveSettings(imvuId, { [key]: value }));
});
return (
<div className="space-y-3">
<label className="flex items-center gap-3 text-sm">
<Switch checked={settings.showBadge} onCheckedChange={change("showBadge")} disabled={pending} />
Show T5DE badge
</label>
<label className="flex items-center gap-3 text-sm">
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
Show when I&apos;m online in T5DE
</label>
<FormMessage state={state} />
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { PageHeader, Tag } from "@/components/status";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api, getMe, isModerator, unwrap } from "@/lib/api";
import { linkAccount, signOutComputers } from "./actions";
import { ChallengeCard } from "./challenge-card";
import { LinkCard } from "./link-card";
export const metadata: Metadata = { title: "Account" };
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) {
const me = await getMe();
if (!me) redirect("/signin?next=/account");
const account = unwrap(await api.account.get());
const { person, links, challenges, computers } = account;
const welcome = (await searchParams).welcome !== undefined;
return (
<div className="space-y-6">
<PageHeader title="Your account">
<span className="flex flex-wrap items-center gap-2">
Signed in as {person.username}
{isModerator(person) && <Tag tone="strong">{person.role}</Tag>}
</span>
</PageHeader>
{welcome && (
<Alert>
<AlertTitle>Welcome to T5DE, {person.username}.</AlertTitle>
<AlertDescription>Link an IMVU account below to get started.</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>IMVU accounts</CardTitle>
<CardDescription>
{links.length} of {person.linkCap}
</CardDescription>
</CardHeader>
<CardContent>
{links.length > 0 ? (
<ul className="divide-y">
{links.map((link) => (
<LinkCard key={link.imvuId} link={link} />
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">No IMVU accounts linked yet.</p>
)}
</CardContent>
</Card>
{challenges.map((c) => (
<ChallengeCard key={c.imvuId} challenge={c} />
))}
<Card>
<CardHeader>
<CardTitle>Link an IMVU account</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{account.canLink ? (
<ActionForm action={linkAccount}>
<div className="flex max-w-md gap-2">
<Input name="username" placeholder="IMVU username" required maxLength={64} aria-label="IMVU username" />
<SubmitButton pendingLabel="Looking…">Next</SubmitButton>
</div>
</ActionForm>
) : (
<p className="text-sm">
You&apos;ve linked as many IMVU accounts as you can ({person.linkCap}). Ask a moderator if you need more.
</p>
)}
<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)
can see adult community products; each account is checked on its own.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>T5DE on your computers</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{computers > 0
? `Signed in on ${computers} computer${computers === 1 ? "" : "s"}.`
: "Not signed in on any computer yet."}{" "}
To sign T5DE in, it shows a code to enter at{" "}
<Link href="/device" className="text-foreground underline underline-offset-4">
/device
</Link>
.
</p>
{computers > 0 && (
<ActionForm action={signOutComputers}>
<SubmitButton
variant="outline"
pendingLabel="Signing out…"
confirm="Sign T5DE out on all your computers?"
>
Sign out everywhere
</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use server";
import { revalidatePath } from "next/cache";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
const roles = ["user", "moderator", "admin"] as const;
/** Runs a change to a person from the console, then shows their page again. */
async function onPerson(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/people/${id}`);
return notice;
});
}
export async function setLinkCap(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => {
const linkCap = Number(field(form, "linkCap"));
const { person } = unwrap(await api.console.people({ id })["link-cap"].post({ linkCap }));
return `Link cap set to ${person.linkCap}.`;
});
}
export async function ban(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => {
unwrap(await api.console.people({ id }).ban.post({ reason: field(form, "reason") }));
});
}
export async function unban(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => {
unwrap(await api.console.people({ id }).unban.post());
});
}
export async function setRole(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => {
const role = roles.find((r) => r === field(form, "role")) ?? "user";
const { person } = unwrap(await api.console.people({ id }).role.post({ role }));
return `Role set to ${person.role}.`;
});
}
+22
View File
@@ -0,0 +1,22 @@
import { notFound, redirect } from "next/navigation";
import { NavLink } from "@/components/header-client";
import { getMe, isModerator, type Person } from "@/lib/api";
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */
export async function requireModerator(path: string): Promise<Person> {
const me = await getMe();
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
if (!isModerator(me)) notFound();
return me;
}
export function ConsoleNav() {
return (
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
<NavLink href="/console" exact>
Overview
</NavLink>
<NavLink href="/console/people">People</NavLink>
</nav>
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status";
import { DateTime } from "@/components/time";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, unwrap, type AuditEntry } from "@/lib/api";
import { ConsoleNav, requireModerator } from "./console-nav";
export const metadata: Metadata = { title: "Console" };
export default async function Console() {
await requireModerator("/console");
const data = unwrap(await api.console.get());
const stats = [
{ label: "People", value: data.counts.people, note: "accounts" },
{ label: "IMVU accounts", value: data.counts.links, note: "linked" },
{ label: "Computers", value: data.counts.computers, note: "signed in" },
];
return (
<>
<PageHeader title="Console">Moderation.</PageHeader>
<ConsoleNav />
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
{stats.map((s) => (
<Card key={s.label}>
<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>
<Card>
<CardHeader>
<CardTitle>Online now</CardTitle>
<CardDescription>
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
</CardDescription>
</CardHeader>
<CardContent>
<OnlineList initial={data.online} moderator />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Audit log</CardTitle>
<CardDescription>The latest 50 changes.</CardDescription>
</CardHeader>
<CardContent>
<AuditLog entries={data.audit} />
</CardContent>
</Card>
</div>
</>
);
}
function AuditLog({ entries }: { entries: AuditEntry[] }) {
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Who</TableHead>
<TableHead>What</TableHead>
<TableHead>Target</TableHead>
<TableHead>Detail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e, i) => (
<TableRow key={i}>
<TableCell className="text-muted-foreground">
<DateTime iso={e.at} />
</TableCell>
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell>
<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>
</Table>
);
}
+184
View File
@@ -0,0 +1,184 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { ActionForm, SubmitButton } from "@/components/forms";
import { AgeTag, 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 { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api, ApiError, unwrap } from "@/lib/api";
import { ban, setLinkCap, setRole, unban } from "../../actions";
import { ConsoleNav, requireModerator } from "../../console-nav";
export const metadata: Metadata = { title: "Person" };
export default async function PersonPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const me = await requireModerator(`/console/people/${id}`);
let data;
try {
data = unwrap(await api.console.people({ id }).get());
} catch (e) {
if (e instanceof ApiError && e.status === 404) notFound();
throw e;
}
const { person, links, computers } = data;
return (
<>
<PageHeader title={person.username}>
<span className="flex flex-wrap items-center gap-2">
<span className="capitalize">{person.role}</span>
<span aria-hidden>·</span>
<span>
joined <DateTime iso={person.createdAt} dateOnly />
</span>
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
</span>
</PageHeader>
<ConsoleNav />
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>IMVU accounts</CardTitle>
<CardDescription>
{links.length} of {person.linkCap}. Only moderators see which IMVU accounts belong to one person.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{links.length === 0 ? (
<p className="text-sm text-muted-foreground">None.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>IMVU account</TableHead>
<TableHead>ID</TableHead>
<TableHead>Linked</TableHead>
<TableHead>Age</TableHead>
<TableHead>Shows</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{links.map((l) => (
<TableRow key={l.imvuId}>
<TableCell className="font-medium">
<span className="flex items-center gap-2">
{l.username}
{l.online && (
<Tag tone="online" dot>
online
</Tag>
)}
</span>
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{l.imvuId}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={l.linkedAt} dateOnly />
</TableCell>
<TableCell>
<AgeTag link={l} />
</TableCell>
<TableCell className="text-muted-foreground">
{[l.showBadge && "badge", l.showPresence && "presence"].filter(Boolean).join(", ") || "nothing"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<p className="text-sm text-muted-foreground">
Signed in on {computers} computer{computers === 1 ? "" : "s"}.
</p>
</CardContent>
</Card>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Link cap</CardTitle>
<CardDescription>How many IMVU accounts they can link (normally 10).</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={setLinkCap} fields={{ id: person.id }}>
<div className="flex gap-2">
<Input
type="number"
name="linkCap"
defaultValue={person.linkCap}
min={0}
max={1000}
required
aria-label="Link cap"
className="w-28"
/>
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Ban</CardTitle>
<CardDescription>
{person.banned
? `Banned: ${person.banReason}`
: "Covers all their IMVU accounts, and signs them out everywhere."}
</CardDescription>
</CardHeader>
<CardContent>
{person.banned ? (
<ActionForm action={unban} fields={{ id: person.id }}>
<SubmitButton variant="outline" pendingLabel="Unbanning…">
Unban
</SubmitButton>
</ActionForm>
) : (
<ActionForm action={ban} fields={{ id: person.id }}>
<Input
name="reason"
placeholder="Reason (they see it)"
required
maxLength={500}
aria-label="Reason"
/>
<SubmitButton
className="bg-red-600 text-white hover:bg-red-600/90 dark:bg-red-600 dark:hover:bg-red-600/90"
pendingLabel="Banning…"
confirm={`Ban ${person.username}?`}
>
Ban
</SubmitButton>
</ActionForm>
)}
</CardContent>
</Card>
{me.role === "admin" && (
<Card>
<CardHeader>
<CardTitle>Role</CardTitle>
<CardDescription>Moderators use the console; admins also set roles.</CardDescription>
</CardHeader>
<CardContent>
<ActionForm action={setRole} fields={{ id: person.id }}>
<div className="flex gap-2">
<NativeSelect name="role" defaultValue={person.role} aria-label="Role">
<NativeSelectOption value="user">User</NativeSelectOption>
<NativeSelectOption value="moderator">Moderator</NativeSelectOption>
<NativeSelectOption value="admin">Admin</NativeSelectOption>
</NativeSelect>
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
</div>
</ActionForm>
</CardContent>
</Card>
)}
</div>
</div>
</>
);
}
+68
View File
@@ -0,0 +1,68 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader, Tag } from "@/components/status";
import { DateTime } from "@/components/time";
import { Button } 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, requireModerator } from "../console-nav";
export const metadata: Metadata = { title: "People" };
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
const raw = (await searchParams).q;
const q = typeof raw === "string" ? raw.slice(0, 64) : "";
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people");
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} }));
return (
<>
<PageHeader title="People" />
<ConsoleNav />
<Card>
<CardContent className="space-y-6">
<form action="/console/people" className="flex max-w-md gap-2">
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
<Button type="submit" variant="outline">
Search
</Button>
</form>
{people.length === 0 ? (
<p className="text-sm text-muted-foreground">Nobody found.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Role</TableHead>
<TableHead>Link cap</TableHead>
<TableHead>Joined</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{people.map((p) => (
<TableRow key={p.id}>
<TableCell>
<Link href={`/console/people/${p.id}`} className="font-medium underline-offset-4 hover:underline">
{p.username}
</Link>
</TableCell>
<TableCell className="capitalize">{p.role}</TableCell>
<TableCell className="tabular-nums">{p.linkCap}</TableCell>
<TableCell className="text-muted-foreground">
<DateTime iso={p.createdAt} dateOnly />
</TableCell>
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</>
);
}
+10
View File
@@ -0,0 +1,10 @@
"use server";
import { api, unwrap } from "@/lib/api";
import { attempt, field, type ActionState } from "@/lib/actions";
/** Approves the sign-in code T5DE shows, signing that computer in as this person. */
export async function approveDevice(_: ActionState, form: FormData): Promise<ActionState> {
return attempt(async () => {
unwrap(await api.device.approve.post({ code: field(form, "code") }));
});
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useActionState } from "react";
import { FormMessage, SubmitButton } from "@/components/forms";
import { NarrowCard } from "@/components/narrow-card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { approveDevice } from "./actions";
export function DeviceForm({ code, username }: { code: string; username: string }) {
const [state, action] = useActionState(approveDevice, null);
if (state && !state.error)
return <NarrowCard title="T5DE is signed in" description="You can close this page and go back to T5DE." />;
return (
<NarrowCard title="Sign T5DE in" description={`Enter the code T5DE shows, to sign it in as ${username}.`}>
<form action={action}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="code">Code</FieldLabel>
<Input
id="code"
name="code"
defaultValue={code}
placeholder="XXXX-XXXX"
required
autoFocus
autoComplete="off"
spellCheck={false}
className="h-11 text-center font-mono text-lg tracking-widest uppercase"
/>
</Field>
<FormMessage state={state} />
<Field>
<SubmitButton pendingLabel="Signing in…">Sign in</SubmitButton>
<FieldDescription>Only enter a code you got from T5DE yourself.</FieldDescription>
</Field>
</FieldGroup>
</form>
</NarrowCard>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getMe } from "@/lib/api";
import { DeviceForm } from "./device-form";
export const metadata: Metadata = { title: "Sign T5DE in" };
type Search = { user_code?: string | string[]; code?: string | string[] };
export default async function Device({ searchParams }: { searchParams: Promise<Search> }) {
const search = await searchParams;
// Better Auth's verification link carries user_code; ?code= is shorter to type.
const code = [search.user_code, search.code].find((c) => typeof c === "string") ?? "";
const me = await getMe();
if (!me) {
const here = code ? `/device?code=${encodeURIComponent(code)}` : "/device";
redirect(`/signin?next=${encodeURIComponent(here)}`);
}
return <DeviceForm code={code} username={me.username} />;
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import Link from "next/link";
import { NarrowCard } from "@/components/narrow-card";
import { Button, buttonVariants } from "@/components/ui/button";
export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<NarrowCard title="Sorry" description="Something went wrong on our side. Try again in a moment.">
<div className="flex gap-2">
<Button onClick={reset}>Try again</Button>
<Link href="/" className={buttonVariants({ variant: "outline" })}>
Home
</Link>
</div>
</NarrowCard>
);
}
+131
View File
@@ -0,0 +1,131 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (@media (prefers-color-scheme: dark));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@media (prefers-color-scheme: dark) {
:root {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+8
View File
@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 397 B

+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/header";
import { cn } from "@/lib/utils";
import "./globals.css";
const sans = Geist({ subsets: ["latin"], variable: "--font-sans" });
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
export const metadata: Metadata = {
title: { template: "%s · T5DE Hub", default: "T5DE Hub" },
description: "One account for all your IMVU avatars.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
<body className="flex min-h-dvh flex-col">
<Header />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main>
<footer className="border-t">
<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.
</div>
</footer>
</body>
</html>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Link from "next/link";
import { NarrowCard } from "@/components/narrow-card";
import { buttonVariants } from "@/components/ui/button";
export default function NotFound() {
return (
<NarrowCard title="Not found" description="There's nothing at this address.">
<Link href="/" className={buttonVariants({ variant: "outline" })}>
Home
</Link>
</NarrowCard>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { OnlineList } from "@/components/online-list";
import { PageHeader } from "@/components/status";
import { Card, CardContent } from "@/components/ui/card";
import { api, getMe, isModerator, unwrap } from "@/lib/api";
export const metadata: Metadata = { title: "Online" };
export default async function Online() {
const [me, { online }] = await Promise.all([getMe(), api.online.get().then(unwrap)]);
return (
<>
<PageHeader title="Online in T5DE">IMVU accounts in a T5DE client right now, that chose to show it.</PageHeader>
<Card>
<CardContent>
<OnlineList initial={online} moderator={isModerator(me)} />
</CardContent>
</Card>
</>
);
}
+72
View File
@@ -0,0 +1,72 @@
import Link from "next/link";
import { api, getMe, unwrap } from "@/lib/api";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default async function Home() {
const [me, home] = await Promise.all([getMe(), api.home.get().then(unwrap)]);
const links = "links" in home ? home.links : undefined;
return (
<div className="space-y-14">
<section className="max-w-2xl space-y-5 pt-4">
<p className="text-sm font-medium text-muted-foreground">T5DE Hub</p>
<h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
One account for all your IMVU avatars.
</h1>
<p className="text-lg text-pretty text-muted-foreground">
Link your IMVU accounts, see who&apos;s online in T5DE, and soon, community products.
</p>
{!me && (
<div className="flex flex-wrap gap-3 pt-2">
<Link href="/signup" className={buttonVariants({ size: "lg", className: "px-4" })}>
Create account
</Link>
<Link href="/signin" className={buttonVariants({ size: "lg", variant: "outline", className: "px-4" })}>
Sign in
</Link>
</div>
)}
</section>
<section className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardDescription>Online in T5DE</CardDescription>
<CardTitle className="text-4xl font-semibold tabular-nums">{home.onlineCount}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
people showing that they&apos;re online.{" "}
<Link href="/online" className="text-foreground underline underline-offset-4">
See who
</Link>
</CardContent>
</Card>
{links !== undefined ? (
<Card>
<CardHeader>
<CardDescription>Your IMVU accounts</CardDescription>
<CardTitle className="text-4xl font-semibold tabular-nums">{links}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
linked.{" "}
<Link href="/account" className="text-foreground underline underline-offset-4">
Manage
</Link>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>One account, all your avatars</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Link any of your IMVU accounts by putting a code in its tagline. Your accounts stay private: nobody else
can see they&apos;re yours.
</CardContent>
</Card>
)}
</section>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { NarrowCard } from "@/components/narrow-card";
import { getMe } from "@/lib/api";
import { safeNext } from "@/lib/paths";
import { SignInForm } from "./signin-form";
export const metadata: Metadata = { title: "Sign in" };
export default async function SignIn({ searchParams }: { searchParams: Promise<{ next?: string | string[] }> }) {
const next = safeNext((await searchParams).next);
if (await getMe()) redirect(next);
return (
<NarrowCard title="Sign in" description="to your T5DE account.">
<SignInForm next={next} />
</NarrowCard>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ErrorText } from "@/components/forms";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
/** Signs in with Better Auth, then goes to next (already checked to be a path on this site). */
export function SignInForm({ next }: { next: string }) {
const router = useRouter();
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
async function submit(form: FormData) {
setError("");
setPending(true);
const { error } = await authClient.signIn.username({
username: String(form.get("username")),
password: String(form.get("password")),
});
if (error) {
setError(error.message ?? "That didn't work. Try again.");
setPending(false);
return;
}
router.push(next);
router.refresh();
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
void submit(new FormData(e.currentTarget));
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="username">Username</FieldLabel>
<Input id="username" name="username" autoComplete="username" required autoFocus />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" name="password" type="password" autoComplete="current-password" required />
</Field>
{error && <ErrorText>{error}</ErrorText>}
<Field>
<Button type="submit" disabled={pending}>
{pending ? "Signing in…" : "Sign in"}
</Button>
<FieldDescription className="text-center">
No account? <Link href="/signup">Create one</Link>
</FieldDescription>
</Field>
</FieldGroup>
</form>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { NarrowCard } from "@/components/narrow-card";
import { getMe } from "@/lib/api";
import { SignUpForm } from "./signup-form";
export const metadata: Metadata = { title: "Create account" };
export default async function SignUp() {
if (await getMe()) redirect("/account");
return (
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts.">
<SignUpForm />
</NarrowCard>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ErrorText } from "@/components/forms";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
export function SignUpForm() {
const router = useRouter();
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
async function submit(form: FormData) {
const username = String(form.get("username"));
const password = String(form.get("password"));
if (password !== form.get("password2")) {
setError("The passwords don't match.");
return;
}
setError("");
setPending(true);
const { error } = await authClient.signUp.email({
username,
name: username,
email: String(form.get("email")),
password,
});
if (error) {
setError(error.message ?? "That didn't work. Try again.");
setPending(false);
return;
}
router.push("/account?welcome=1");
router.refresh();
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
void submit(new FormData(e.currentTarget));
}}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor="username">Username</FieldLabel>
<Input
id="username"
name="username"
autoComplete="username"
required
autoFocus
pattern="[A-Za-z0-9_]{3,32}"
title="3 to 32 letters, digits or underscores"
/>
</Field>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" name="email" type="email" autoComplete="email" required />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" name="password" type="password" autoComplete="new-password" required minLength={10} />
</Field>
<Field>
<FieldLabel htmlFor="password2">Password again</FieldLabel>
<Input id="password2" name="password2" type="password" autoComplete="new-password" required minLength={10} />
<FieldDescription>Not your IMVU password: use a new one.</FieldDescription>
</Field>
{error && <ErrorText>{error}</ErrorText>}
<Field>
<Button type="submit" disabled={pending}>
{pending ? "Creating account…" : "Create account"}
</Button>
<FieldDescription className="text-center">
Have one? <Link href="/signin">Sign in</Link>
</FieldDescription>
</Field>
</FieldGroup>
</form>
);
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? "Copied" : "Copy"}
</Button>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
// Forms that run a server action and show its answer, with buttons that show when they're working.
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";
import type { ActionState } from "@/lib/actions";
import { cn } from "@/lib/utils";
type Action = (state: ActionState, form: FormData) => Promise<ActionState>;
export function ActionForm({
action,
fields = {},
className,
children,
}: {
action: Action;
/** Hidden fields sent with the form. */
fields?: Record<string, string | number>;
className?: string;
children: React.ReactNode;
}) {
const [state, formAction] = useActionState(action, null);
return (
<form action={formAction} className={cn("space-y-3", className)}>
{Object.entries(fields).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
{children}
<FormMessage state={state} />
</form>
);
}
export function FormMessage({ state }: { state: ActionState }) {
if (state?.error) return <ErrorText>{state.error}</ErrorText>;
if (state?.notice)
return (
<p role="status" className="text-sm text-muted-foreground">
{state.notice}
</p>
);
return null;
}
export function ErrorText({ children }: { children: React.ReactNode }) {
return (
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
{children}
</p>
);
}
/**
* A submit button that shows pending while its form is being sent. With name and value, it's one of several
* buttons in the form, and only shows pending when it was the one pressed. With confirm, it asks first.
*/
export function SubmitButton({
name,
value,
pendingLabel,
confirm,
children,
...props
}: Omit<React.ComponentProps<typeof Button>, "type" | "name" | "value" | "onClick"> & {
name?: string;
value?: string;
pendingLabel?: string;
confirm?: string;
}) {
const status = useFormStatus();
const mine = status.pending && (name === undefined || status.data?.get(name) === value);
return (
<Button
type="submit"
name={name}
value={value}
disabled={status.pending}
onClick={(e) => {
if (confirm && !window.confirm(confirm)) e.preventDefault();
}}
{...props}
>
{mine ? (pendingLabel ?? children) : children}
</Button>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
/** A link in a navigation bar, marked when it's the current page (or, unless exact, a page under it). */
export function NavLink({
href,
exact = false,
children,
}: {
href: string;
exact?: boolean;
children: React.ReactNode;
}) {
const pathname = usePathname();
const here = pathname === href || (!exact && pathname.startsWith(href + "/"));
return (
<Link
href={href}
aria-current={here ? "page" : undefined}
className={cn(
"rounded-md px-2.5 py-1.5 text-muted-foreground transition-colors hover:text-foreground",
here && "bg-muted text-foreground",
)}
>
{children}
</Link>
);
}
export function SignOutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
return (
<Button
variant="outline"
disabled={pending}
onClick={async () => {
setPending(true);
await authClient.signOut();
router.push("/");
router.refresh();
}}
>
{pending ? "Signing out…" : "Sign out"}
</Button>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { getMe, isModerator } from "@/lib/api";
import { buttonVariants } from "@/components/ui/button";
import { NavLink, SignOutButton } from "./header-client";
export function Logo() {
return (
<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() {
const me = await getMe();
return (
<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">
<Link href="/" className="flex items-center gap-2 font-semibold">
<Logo />
T5DE Hub
</Link>
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
<NavLink href="/online">Online</NavLink>
{me && <NavLink href="/account">Account</NavLink>}
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
</nav>
<div className="ml-auto flex items-center gap-2 text-sm">
{me ? (
<>
<span className="hidden text-muted-foreground sm:inline">{me.username}</span>
<SignOutButton />
</>
) : (
<>
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
Sign in
</Link>
<Link href="/signup" className={buttonVariants()}>
Create account
</Link>
</>
)}
</div>
</div>
</header>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
/** A small centered card: sign-in, sign-up, device sign-in, errors. */
export function NarrowCard({
title,
description,
children,
}: {
title: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
}) {
return (
<Card className="mx-auto w-full max-w-sm">
<CardHeader>
<CardTitle className="text-xl">{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
{children && <CardContent>{children}</CardContent>}
</Card>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
// Who's online in T5DE, kept live: the page renders the list, then this follows the API's event stream.
import Link from "next/link";
import { useEffect, useState } from "react";
import { Initial, Outfit, Tag } from "@/components/status";
import { Since } from "@/components/time";
import type { OnlineEntry } from "@/lib/api";
export function OnlineList({ initial, moderator = false }: { initial: OnlineEntry[]; moderator?: boolean }) {
const [online, setOnline] = useState(initial);
const [live, setLive] = useState(false);
useEffect(() => {
const events = new EventSource("/api/v1/online/events");
events.addEventListener("online", (e) => {
setOnline((JSON.parse(e.data) as { online: OnlineEntry[] }).online);
setLive(true);
});
events.addEventListener("error", () => setLive(false));
return () => events.close();
}, []);
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span
className={live ? "size-2 rounded-full bg-emerald-500" : "size-2 rounded-full bg-muted-foreground/40"}
aria-hidden
/>
{live ? "Live" : "Connecting…"}
<span className="ml-auto tabular-nums">
{online.length} {online.length === 1 ? "account" : "accounts"}
</span>
</div>
{online.length === 0 ? (
<p className="py-6 text-sm text-muted-foreground">Nobody is showing that they&apos;re online right now.</p>
) : (
<ul className="divide-y">
{online.map((o) => (
<li key={o.imvuId} className="flex gap-4 py-4 first:pt-0 last:pb-0">
<Initial name={o.username} />
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{o.username}</span>
{o.badge && <Tag tone="strong">T5DE</Tag>}
<Tag tone="online" dot>
online since <Since iso={o.since} />
</Tag>
{o.hidden && <Tag>hidden: moderators only</Tag>}
{moderator && o.adult && <Tag tone="adult">adult</Tag>}
{moderator && o.personId && (
<Link
href={`/console/people/${o.personId}`}
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
>
person
</Link>
)}
</div>
<Outfit items={o.outfit} />
</div>
</li>
))}
</ul>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
// Small labels. Color only means something: green is online, blue is age verified or adult, red is banned.
import type { Link } from "@/lib/api";
import { cn } from "@/lib/utils";
const tones = {
neutral: "border-border bg-muted/60 text-muted-foreground",
strong: "border-foreground/15 bg-foreground/5 text-foreground",
online: "border-emerald-600/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
adult: "border-sky-600/20 bg-sky-500/10 text-sky-700 dark:text-sky-400",
danger: "border-red-600/20 bg-red-500/10 text-red-700 dark:text-red-400",
};
export function Tag({
tone = "neutral",
dot = false,
className,
children,
}: {
tone?: keyof typeof tones;
dot?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium whitespace-nowrap",
tones[tone],
className,
)}
>
{dot && <span className="size-1.5 rounded-full bg-current" aria-hidden />}
<span>{children}</span>
</span>
);
}
/** A round initial standing in for an avatar picture. */
export function Initial({ name }: { name: string }) {
return (
<span
className="grid size-10 shrink-0 place-items-center rounded-full border bg-muted text-sm font-semibold text-muted-foreground uppercase"
aria-hidden
>
{name.slice(0, 1)}
</span>
);
}
/** The community products an account wears. */
export function Outfit({ items }: { items: { id: string; name: string }[] }) {
if (items.length === 0) return null;
return (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">Wearing</div>
<ul className="flex flex-wrap gap-1.5">
{items.map((item) => (
<li key={item.id} className="rounded-md border bg-background px-2 py-0.5 text-xs">
{item.name}
</li>
))}
</ul>
</div>
);
}
/** A page's title and the line under it. */
export function PageHeader({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="mb-8 space-y-2">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
{children && <div className="text-muted-foreground">{children}</div>}
</div>
);
}
/** An IMVU account's age status: blue when it can see adult community products. */
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) {
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>;
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
return <Tag>Not age verified</Tag>;
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
// Times in the visitor's own time zone and language. The server doesn't know either, so it renders times in UTC and
// the browser replaces them once the page is running.
import { useEffect, useState, useSyncExternalStore } from "react";
const noSubscription = () => () => {};
/** False on the server and while hydrating, true after. */
function useInBrowser(): boolean {
return useSyncExternalStore(
noSubscription,
() => true,
() => false,
);
}
const units: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 365 * 24 * 3600],
["month", 30 * 24 * 3600],
["week", 7 * 24 * 3600],
["day", 24 * 3600],
["hour", 3600],
["minute", 60],
];
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
function ago(iso: string, now: number): string {
const seconds = (new Date(iso).getTime() - now) / 1000;
for (const [unit, size] of units) {
if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit);
}
return "just now";
}
/** "5 minutes ago", kept up to date. */
export function TimeAgo({ iso }: { iso: string }) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(timer);
}, []);
const local = useInBrowser();
return (
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
{local ? ago(iso, now) : format(iso, { dateStyle: "medium" }, false)}
</time>
);
}
function format(iso: string, options: Intl.DateTimeFormatOptions, local: boolean): string {
return new Date(iso).toLocaleString(local ? undefined : "en", local ? options : { ...options, timeZone: "UTC" });
}
/** A date (and time). */
export function DateTime({ iso, dateOnly = false }: { iso: string; dateOnly?: boolean }) {
const local = useInBrowser();
const options: Intl.DateTimeFormatOptions = dateOnly
? { dateStyle: "medium" }
: { dateStyle: "medium", timeStyle: "short" };
return <time dateTime={iso}>{format(iso, options, local)}</time>;
}
/** A time of day ("3:04 PM"), with the date too when it isn't today. */
export function Since({ iso }: { iso: string }) {
const local = useInBrowser();
const today = local && new Date(iso).toDateString() === new Date().toDateString();
const options: Intl.DateTimeFormatOptions = today
? { hour: "numeric", minute: "2-digit" }
: { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" };
return (
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
{format(iso, options, local)}
</time>
);
}
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+57
View File
@@ -0,0 +1,57 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const buttonVariants = 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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react"
import { cn } from "cn"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none 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 { Input }
+19
View File
@@ -0,0 +1,19 @@
"use client"
import * as React from "react"
import { cn } from "cn"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { cn } from "cn"
import { ChevronDownIcon } from "lucide-react"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default"
}
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "cn"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "cn"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+115
View File
@@ -0,0 +1,115 @@
"use client"
import * as React from "react"
import { cn } from "cn"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+22
View File
@@ -0,0 +1,22 @@
// Server actions answer forms with an ActionState: an error to show, or a notice.
import "server-only";
import { ApiError } from "./api";
export type ActionState = { error?: string; notice?: string } | null;
/** Runs fn, turning the API's errors into a message for the form; fn may return a notice. */
export async function attempt(fn: () => Promise<string | void>): Promise<ActionState> {
try {
const notice = await fn();
return notice ? { notice } : {};
} catch (e) {
if (e instanceof ApiError) return { error: e.message };
throw e;
}
}
/** A form field as a string ("" when missing). */
export function field(form: FormData, name: string): string {
const v = form.get(name);
return typeof v === "string" ? v.trim() : "";
}
+78
View File
@@ -0,0 +1,78 @@
// The hub's API, called from server components and server actions. Calls go to the API's internal address
// (API_URL) with the visitor's cookie, so the API sees the same session the browser has.
import "server-only";
import { treaty } from "@elysiajs/eden";
import type { App } from "@t5de/api";
import { headers } from "next/headers";
import { cache } from "react";
const apiURL = process.env.API_URL ?? "http://localhost:4101";
/** The visitor's cookie and address, to pass on to the API. */
async function forwarded(): Promise<Record<string, string>> {
const incoming = await headers();
const forward: Record<string, string> = {};
for (const name of ["cookie", "x-forwarded-for", "user-agent"]) {
const value = incoming.get(name);
if (value) forward[name] = value;
}
return forward;
}
/**
* The API's /api/v1 routes, as the person making this request. (Never await the client itself: it's a proxy, and
* awaiting it would call a route named "then".)
*/
export const api = treaty<App>(apiURL, { headers: forwarded }).api.v1;
type V1 = typeof api;
type Problem = { error: string; message: string };
/** What a route answers when it succeeds (the API's error shape is part of its declared answers). */
type Ok<T> = Exclude<T, Problem>;
type Data<Route extends (...args: any[]) => Promise<{ data: unknown }>> = Ok<
NonNullable<Awaited<ReturnType<Route>>["data"]>
>;
export type Person = Data<V1["me"]["get"]>["person"];
export type Account = Data<V1["account"]["get"]>;
export type Link = Account["links"][number];
export type Challenge = Account["challenges"][number];
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
export type ConsoleData = Data<V1["console"]["get"]>;
export type AuditEntry = ConsoleData["audit"][number];
/** An error the API answered: message is written to be shown as is. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly slug: string,
message: string,
) {
super(message);
}
}
type Answer<T> = { data: T; error: null } | { data: null; error: { status: unknown; value: unknown } };
/** The answer's data, or throws ApiError with the API's message. */
export function unwrap<T>(answer: Answer<T>): Ok<NonNullable<T>> {
if (answer.error) {
const status = typeof answer.error.status === "number" ? answer.error.status : 500;
const value = answer.error.value as { error?: unknown; message?: unknown } | null;
const slug = typeof value?.error === "string" ? value.error : "error";
const message =
typeof value?.message === "string" && value.message
? value.message
: "Something went wrong. Try again in a moment.";
throw new ApiError(status, slug, message);
}
return answer.data as Ok<NonNullable<T>>;
}
/** Who's signed in, or undefined. Once per request. */
export const getMe = cache(async (): Promise<Person | undefined> => {
const { data } = await api.me.get();
return data && "person" in data ? data.person : undefined;
});
export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin";
+10
View File
@@ -0,0 +1,10 @@
// Better Auth in the browser: sign-up, sign-in and sign-out. Its requests go to /api/auth on this site, which Next
// rewrites to the API, so the session cookie is set for this site.
"use client";
import { createAuthClient } from "better-auth/react";
import { usernameClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: typeof window === "undefined" ? undefined : window.location.origin,
plugins: [usernameClient()],
});
+6
View File
@@ -0,0 +1,6 @@
/** next if it's a path on this site, else fallback (so ?next= can't send people elsewhere). */
export function safeNext(next: string | string[] | undefined | null, fallback = "/account"): string {
if (typeof next !== "string") return fallback;
if (!next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return fallback;
return next;
}
+1
View File
@@ -0,0 +1 @@
export { cn } from "cn";
+25
View File
@@ -0,0 +1,25 @@
import path from "node:path";
import type { NextConfig } from "next";
// The API's internal address. The browser never calls it directly: /api is rewritten to it, so the API's session
// cookies are first-party on the website's origin. Rewrites are fixed when the app is built, so API_URL must be set
// at build time as well as at run time (server components call it directly).
const apiURL = process.env.API_URL ?? "http://localhost:4101";
// The monorepo's root: standalone output traces workspace packages from here.
const root = path.join(import.meta.dirname, "../..");
const config: NextConfig = {
output: "standalone",
outputFileTracingRoot: root,
turbopack: { root },
// Next's gzip holds back the API's event stream (who's online); compress in front of the app instead (Vercel and
// most reverse proxies do).
compress: false,
agentRules: false,
async rewrites() {
return [{ source: "/api/:path*", destination: `${apiURL}/api/:path*` }];
},
};
export default config;
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@t5de/web",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev -p 4100",
"build": "next build",
"start": "next start -p 4100",
"check-types": "next typegen && tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.9.0",
"@elysiajs/eden": "1.4.9",
"@t5de/api": "workspace:*",
"better-auth": "1.7.14",
"class-variance-authority": "^0.7.1",
"cn": "^0.5.0",
"lucide-react": "^1.64.0",
"next": "16.4.3",
"react": "19.3.1",
"react-dom": "19.3.1",
"server-only": "^0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.2.0",
"elysia": "1.4.30",
"shadcn": "^4.23.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^7.0.2"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { "@tailwindcss/postcss": {} } };
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+1042
View File
File diff suppressed because it is too large. Load diff
-106
View File
@@ -1,106 +0,0 @@
// Command t5de-hub runs T5DE's hub: the website, the moderation console, and the API proxies sign in to.
//
// t5de-hub -db postgres://... -public-url https://hub.t5de.dev [-listen :8080] [-data dir]
// t5de-hub -dev runs its own Postgres in <data>/postgres (for development)
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"git.t5de.dev/t5de/t5de-hub/internal/imvu"
"git.t5de.dev/t5de/t5de-hub/internal/realtime"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
"git.t5de.dev/t5de/t5de-hub/internal/web"
)
func main() {
listen := flag.String("listen", "127.0.0.1:8080", "address to listen on")
dbURL := flag.String("db", os.Getenv("T5DE_HUB_DB"), "Postgres URL (or $T5DE_HUB_DB)")
publicURL := flag.String("public-url", "", "the hub's address as people reach it (default: http://<listen>)")
data := flag.String("data", "t5de-hub-data", "where the hub keeps its signing key (and, with -dev, its database)")
dev := flag.Bool("dev", false, "run a Postgres of its own in <data>/postgres, for development")
flag.Parse()
logger := log.New(os.Stderr, "", log.LstdFlags)
if err := run(logger, *listen, *dbURL, *publicURL, *data, *dev); err != nil {
logger.Fatal(err)
}
}
func run(logger *log.Logger, listen, dbURL, publicURL, data string, dev bool) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if publicURL == "" {
publicURL = "http://" + strings.Replace(listen, "0.0.0.0", "localhost", 1)
}
publicURL = strings.TrimSuffix(publicURL, "/")
if dev {
pg, url, err := startDevPostgres(data)
if err != nil {
return fmt.Errorf("starting Postgres: %w", err)
}
defer pg.Stop()
dbURL = url
}
if dbURL == "" {
return errors.New("no database: give -db (or $T5DE_HUB_DB), or -dev")
}
st, err := store.Open(ctx, dbURL)
if err != nil {
return err
}
defer st.Close()
keys, err := token.LoadOrCreate(filepath.Join(data, "signing.key"), publicURL)
if err != nil {
return err
}
server := &web.Server{Store: st, IMVU: imvu.New(), Keys: keys, PublicURL: publicURL, Log: logger}
server.Realtime = realtime.New(web.Owners{Store: st}, logger)
handler, err := server.Handler()
if err != nil {
return err
}
srv := &http.Server{Addr: listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutdown)
}()
logger.Printf("t5de-hub listening on %s (%s)", listen, publicURL)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// startDevPostgres runs Postgres from data/postgres (downloaded once into data/postgres-cache).
func startDevPostgres(data string) (*embeddedpostgres.EmbeddedPostgres, string, error) {
abs, err := filepath.Abs(data)
if err != nil {
return nil, "", err
}
const port = 54321
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(port).Database("t5de_hub").
DataPath(filepath.Join(abs, "postgres", "data")).
RuntimePath(filepath.Join(abs, "postgres", "runtime")).
CachePath(filepath.Join(abs, "postgres-cache")).
StartTimeout(time.Minute))
if err := pg.Start(); err != nil {
return nil, "", err
}
return pg, fmt.Sprintf("postgres://postgres:postgres@localhost:%d/t5de_hub?sslmode=disable", port), nil
}
+37
View File
@@ -0,0 +1,37 @@
# Runs the whole hub: Postgres, the API (port 4101) and the website (port 4100). Set AUTH_SECRET, and the public
# addresses when they aren't localhost (API_PUBLIC_URL, WEB_PUBLIC_URL):
# AUTH_SECRET=$(openssl rand -base64 32) docker compose up -d
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: t5de
POSTGRES_PASSWORD: t5de
POSTGRES_DB: t5de_hub
volumes: [postgres:/var/lib/postgresql/data]
healthcheck:
test: ["CMD", "pg_isready", "-U", "t5de"]
interval: 2s
retries: 30
api:
build: { context: ., dockerfile: apps/api/Dockerfile }
environment:
DATABASE_URL: postgres://t5de:t5de@postgres:5432/t5de_hub
AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET}
# T5DE's proxies reach the API directly (sign-in and the WebSocket); the website reaches it through its own /api.
API_URL: ${API_PUBLIC_URL:-http://localhost:4101}
WEB_URL: ${WEB_PUBLIC_URL:-http://localhost:4100}
ports: ["4101:4101"]
depends_on:
postgres: { condition: service_healthy }
web:
build: { context: ., dockerfile: apps/web/Dockerfile }
environment:
API_URL: http://api:4101
ports: ["4100:4100"]
depends_on: [api]
volumes:
postgres:
+61
View File
@@ -0,0 +1,61 @@
# The hub's API
`apps/api` (Elysia on Bun). The route definitions in `apps/api/src/app.ts` are the reference: their schemas validate
requests and type the website's client (Eden). This is the overview.
- `/api/auth/*`: Better Auth (accounts, sessions, device sign-in, JWT keys).
- `/api/v1/*`: the hub's own routes.
The website reaches the API through its own origin (Next.js rewrites `/api` to it), so sessions are first-party
cookies. T5DE's proxy calls it directly with `Authorization: Bearer <session token>`.
Errors are `{"error": "<code>", "message": "<text for people>"}` with a 4xx/5xx status; `message` can be shown as
is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_account`, `already_linked`,
`code_missing`, `expired`, `link_cap`, `rate_limited`, `imvu_unavailable`.
## Accounts (Better Auth)
| | |
| --- | --- |
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. |
| `POST /api/auth/sign-in/username` | `{username, password}` |
| `POST /api/auth/sign-out` | |
| `GET /api/v1/me` | `{person}` |
## Proxies
| | |
| --- | --- |
| `POST /api/auth/device/code` | `{client_id: "t5de-proxy"}` → `{device_code, user_code, verification_uri, verification_uri_complete, interval, expires_in}` (RFC 8628). `verification_uri` is the website's `/device`. |
| `POST /api/auth/device/token` | `{grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code, client_id}` → `{access_token}` (a session token, used as a bearer token) or `{error: authorization_pending \| slow_down \| expired_token \| access_denied}` |
| `POST /api/v1/device/approve` | (website, signed in) `{code}`: signs the waiting proxy in as this person |
| `GET /api/v1/realtime` | WebSocket, bearer token. Protocol in `apps/api/src/realtime.ts`. |
| `GET /.well-known/jwks.json` | the Ed25519 keys JWTs are signed with |
## Website routes (signed in unless noted)
| | |
| --- | --- |
| `GET /api/v1/home` | (anyone) `{onlineCount, links?}` |
| `GET /api/v1/online` | (anyone) `{online}`: accounts showing they're online (moderators see all, with `hidden`) |
| `GET /api/v1/online/events` | (anyone) server-sent events: `online` with `{online}` now and on each change |
| `GET /api/v1/account` | `{person, links, challenges, computers, canLink}` |
| `POST /api/v1/account/links` | `{username}` → 201 `{challenge}` (the tagline code) |
| `POST /api/v1/account/links/:imvu/check` | `{link, moved}` |
| `DELETE /api/v1/account/challenges/:imvu` | cancel linking |
| `PATCH /api/v1/account/links/:imvu` | `{showBadge?, showPresence?}` → `{link}` |
| `POST /api/v1/account/links/:imvu/recheck` | `{link}` (age checked with IMVU again) |
| `DELETE /api/v1/account/links/:imvu` | unlink |
| `POST /api/v1/account/signout-computers` | signs T5DE out on all their computers |
## Console (moderators; roles: admins)
| | |
| --- | --- |
| `GET /api/v1/console` | `{counts, connections, online, audit}` |
| `GET /api/v1/console/people?q=` | `{people}` |
| `GET /api/v1/console/people/:id` | `{person, links, computers}` |
| `POST /api/v1/console/people/:id/link-cap` | `{linkCap}` (0-1000) |
| `POST /api/v1/console/people/:id/ban` | `{reason}`; only admins ban moderators |
| `POST /api/v1/console/people/:id/unban` | |
| `POST /api/v1/console/people/:id/role` | (admins) `{role}` |
+6 -2
View File
@@ -336,9 +336,13 @@ The proxy streams and adds no waiting.
## Status ## Status
- Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`. - Phase 1 (local packs) done: t5de-api, t5de-proxy `internal/products`.
- Phase 2 (hub identity) started: accounts, tagline linking, age, link caps, device sign-in, presence and badge, - Phase 2 (hub identity) under way: accounts, tagline linking, age, link caps, device sign-in, presence and badge,
console. Not yet: periodic age re-checks (only on demand), a sign-in UI in the client (the proxy's `signin` console. Not yet: periodic age re-checks (on demand only), a sign-in button in the client (the proxy's `signin`
command for now), deployment. command for now), deployment.
- **Stack changed** (2026-10-29, the user's choice): the hub is a Bun monorepo: Elysia API, Better Auth, Drizzle on
Postgres, Next.js website with Tailwind and shadcn/ui (README). It replaces the first version, in Go. Where this
document says Go or templ for the hub, read that; catalogs are still to be decided (a Bun program compiled to one
file would keep them easy to run).
## Phases ## Phases
-21
View File
@@ -1,21 +0,0 @@
module git.t5de.dev/t5de/t5de-hub
go 1.27.0
require (
github.com/coder/websocket v1.8.15
github.com/fergusstrange/embedded-postgres v1.34.0
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.58.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
)
-40
View File
@@ -1,40 +0,0 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s=
github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-144
View File
@@ -1,144 +0,0 @@
// Package imvu reads IMVU's public user API: who an account is, its tagline (for linking) and whether it's age
// verified or has Access Pass. The hub calls it rarely (linking, and age checks at most daily) and never depends on
// it for anything else.
package imvu
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// User is an IMVU account, as the public API shows it.
type User struct {
ID int64
Username string
Tagline string
AgeVerified bool
AccessPass bool
}
// ErrNoSuchUser means IMVU has no account by that name or ID.
var ErrNoSuchUser = errors.New("no such IMVU account")
// Client reads IMVU's API.
type Client struct {
// Base is the API's address; tests point it elsewhere.
Base string
HTTP *http.Client
mu sync.Mutex
last time.Time
}
// New is a client for api.imvu.com.
func New() *Client {
return &Client{Base: "https://api.imvu.com", HTTP: &http.Client{Timeout: 15 * time.Second}}
}
// minInterval spaces out calls to IMVU.
const minInterval = 200 * time.Millisecond
func (c *Client) wait(ctx context.Context) error {
c.mu.Lock()
next := c.last.Add(minInterval)
now := time.Now()
if next.Before(now) {
next = now
}
c.last = next
c.mu.Unlock()
select {
case <-time.After(time.Until(next)):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// ByUsername looks an account up by name.
func (c *Client) ByUsername(ctx context.Context, username string) (*User, error) {
username = strings.TrimSpace(username)
if username == "" || len(username) > 64 {
return nil, ErrNoSuchUser
}
return c.get(ctx, "/user?username="+url.QueryEscape(username))
}
// ByID reads an account (fresh, for checks).
func (c *Client) ByID(ctx context.Context, id int64) (*User, error) {
return c.get(ctx, "/user/user-"+strconv.FormatInt(id, 10))
}
func (c *Client) get(ctx context.Context, path string) (*User, error) {
if err := c.wait(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", c.Base+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "t5de-hub (https://git.t5de.dev/t5de/t5de-hub)")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
return nil, ErrNoSuchUser
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("IMVU answered %d", resp.StatusCode)
}
return parse(body)
}
// parse reads IMVU's "denormalized" answer: the user is the entry the answer's id points to, or the only user in it.
func parse(body []byte) (*User, error) {
var answer struct {
Status string `json:"status"`
ID string `json:"id"`
Denormalized map[string]struct {
Data json.RawMessage `json:"data"`
} `json:"denormalized"`
}
if err := json.Unmarshal(body, &answer); err != nil {
return nil, fmt.Errorf("IMVU's answer: %w", err)
}
var found []json.RawMessage
for key, entry := range answer.Denormalized {
if strings.Contains(key, "/user/user-") {
found = append(found, entry.Data)
}
}
if len(found) != 1 {
return nil, ErrNoSuchUser
}
var data struct {
LegacyCID json.Number `json:"legacy_cid"`
Username string `json:"username"`
Tagline string `json:"tagline"`
AgeVerified bool `json:"is_ageverified"`
AP bool `json:"is_ap"`
}
if err := json.Unmarshal(found[0], &data); err != nil {
return nil, fmt.Errorf("IMVU's user: %w", err)
}
id, err := data.LegacyCID.Int64()
if err != nil || id <= 0 || data.Username == "" {
return nil, ErrNoSuchUser
}
return &User{ID: id, Username: data.Username, Tagline: data.Tagline, AgeVerified: data.AgeVerified, AccessPass: data.AP}, nil
}
-30
View File
@@ -1,30 +0,0 @@
package imvu
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestByUsername(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("username") == "Nobody" {
w.Write([]byte(`{"status":"success","id":"x","denormalized":{}}`))
return
}
w.Write([]byte(`{"status":"success","id":"https://api.imvu.com/user?username=Someone","denormalized":{
"https://api.imvu.com/user/user-42":{"data":{"legacy_cid":42,"username":"Someone","tagline":"hi t5de-ABCD",
"is_ageverified":true,"is_ap":false}}}}`))
}))
defer srv.Close()
c := &Client{Base: srv.URL, HTTP: srv.Client()}
u, err := c.ByUsername(context.Background(), "Someone")
if err != nil || u.ID != 42 || u.Username != "Someone" || u.Tagline != "hi t5de-ABCD" || !u.AgeVerified || u.AccessPass {
t.Fatalf("%+v %v", u, err)
}
if _, err := c.ByUsername(context.Background(), "Nobody"); !errors.Is(err, ErrNoSuchUser) {
t.Fatalf("got %v", err)
}
}
-229
View File
@@ -1,229 +0,0 @@
// Package realtime is the hub's live connection with signed-in proxies: which IMVU account each client is using
// (presence), and the community products it's wearing. Later, room channels.
//
// Protocol (JSON text messages over a WebSocket at /api/v1/realtime, authorized with the proxy's access token):
//
// 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
// hub -> proxy {"type": "welcome"} | {"type": "error", "message": "..."}
package realtime
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"sort"
"sync"
"time"
"github.com/coder/websocket"
)
// Item is a community product someone wears.
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Online is an IMVU account in use in a T5DE client now.
type Online struct {
PersonID int64
IMVUID int64
Since time.Time
Outfit []Item
}
// Owners tells the hub which person an IMVU account is linked to.
type Owners interface {
LinkOwner(ctx context.Context, imvuID int64) (personID int64, ok bool, err error)
}
// Hub holds the connected proxies.
type Hub struct {
owners Owners
log *log.Logger
mu sync.Mutex
sessions map[*session]bool
changed chan struct{} // closed and replaced on every change, for live views
}
type session struct {
personID int64
conn *websocket.Conn
imvuID int64
since time.Time
outfit []Item
}
func New(owners Owners, logger *log.Logger) *Hub {
return &Hub{owners: owners, log: logger, sessions: map[*session]bool{}, changed: make(chan struct{})}
}
// Changed is closed the next time anyone's presence or outfit changes.
func (h *Hub) Changed() <-chan struct{} {
h.mu.Lock()
defer h.mu.Unlock()
return h.changed
}
func (h *Hub) notifyLocked() {
close(h.changed)
h.changed = make(chan struct{})
}
// Online lists the IMVU accounts in use now, one entry each (the most recent session), by IMVU ID.
func (h *Hub) Online() []Online {
h.mu.Lock()
defer h.mu.Unlock()
byID := map[int64]Online{}
for s := range h.sessions {
if s.imvuID == 0 {
continue
}
if prev, ok := byID[s.imvuID]; ok && prev.Since.After(s.since) {
continue
}
byID[s.imvuID] = Online{PersonID: s.personID, IMVUID: s.imvuID, Since: s.since, Outfit: append([]Item(nil), s.outfit...)}
}
out := make([]Online, 0, len(byID))
for _, o := range byID {
out = append(out, o)
}
sort.Slice(out, func(i, j int) bool { return out[i].Since.Before(out[j].Since) })
return out
}
// Connections counts connected proxies (signed in or not to an IMVU account yet).
func (h *Hub) Connections() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.sessions)
}
// Disconnect closes a person's connections (when they're banned or sign their proxies out).
func (h *Hub) Disconnect(personID int64) {
h.mu.Lock()
var conns []*websocket.Conn
for s := range h.sessions {
if s.personID == personID {
conns = append(conns, s.conn)
}
}
h.mu.Unlock()
for _, c := range conns {
c.Close(websocket.StatusPolicyViolation, "signed out")
}
}
type message struct {
Type string `json:"type"`
IMVUID int64 `json:"imvu_id,omitempty"`
Items []Item `json:"items,omitempty"`
Message string `json:"message,omitempty"`
}
// Serve runs one proxy's connection, already authorized as personID, until it closes.
func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, personID int64) {
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
conn.SetReadLimit(64 << 10)
s := &session{personID: personID, conn: conn, since: time.Now()}
h.mu.Lock()
h.sessions[s] = true
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.sessions, s)
if s.imvuID != 0 {
h.notifyLocked()
}
h.mu.Unlock()
conn.CloseNow()
}()
ctx := r.Context()
send := func(m message) error {
b, _ := json.Marshal(m)
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return conn.Write(wctx, websocket.MessageText, b)
}
if send(message{Type: "welcome"}) != nil {
return
}
// Keep the connection alive through proxies and NATs.
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pctx)
cancel()
if err != nil {
conn.CloseNow()
return
}
}
}
}()
for {
_, data, err := conn.Read(ctx)
if err != nil {
return
}
var m message
if json.Unmarshal(data, &m) != nil {
send(message{Type: "error", Message: "not a message"})
continue
}
switch m.Type {
case "session":
owner, ok, err := h.owners.LinkOwner(ctx, m.IMVUID)
if err != nil {
send(message{Type: "error", Message: "try again later"})
continue
}
if !ok || owner != personID {
// Not this person's account: no presence, no outfit for it.
h.set(s, func() { s.imvuID, s.outfit = 0, nil })
send(message{Type: "error", Message: "not_linked"})
continue
}
h.set(s, func() { s.imvuID, s.since, s.outfit = m.IMVUID, time.Now(), nil })
case "outfit":
if len(m.Items) > 64 {
send(message{Type: "error", Message: "too many items"})
continue
}
items := make([]Item, 0, len(m.Items))
for _, it := range m.Items {
if it.ID == "" || len(it.ID) > 128 || len(it.Name) > 128 {
continue
}
items = append(items, it)
}
h.set(s, func() { s.outfit = items })
default:
send(message{Type: "error", Message: "unknown message type"})
}
}
}
func (h *Hub) set(s *session, change func()) {
h.mu.Lock()
defer h.mu.Unlock()
change()
h.notifyLocked()
}
// ErrUnauthorized is returned by Authorize funcs for a missing or bad token.
var ErrUnauthorized = errors.New("unauthorized")
@@ -1,78 +0,0 @@
-- Accounts (people), their linked IMVU accounts, website sessions, sign-ins from proxies, and the audit log.
create table persons (
id bigserial primary key,
username text not null,
password_hash text not null,
role text not null default 'user' check (role in ('user', 'moderator', 'admin')),
-- How many IMVU accounts the person may link; moderators raise it on request.
link_cap integer not null default 10 check (link_cap >= 0),
created_at timestamptz not null default now(),
banned_at timestamptz,
ban_reason text not null default ''
);
create unique index persons_username on persons (lower(username));
create table sessions (
token_hash bytea primary key,
person_id bigint not null references persons on delete cascade,
csrf text not null,
created_at timestamptz not null default now(),
expires_at timestamptz not null
);
create index sessions_person on sessions (person_id);
-- A linked IMVU account. Linking it again from another person moves it (the most recent proof wins).
create table imvu_links (
imvu_id bigint primary key,
person_id bigint not null references persons on delete cascade,
-- What catalogs and rooms see instead of the person, so a person's accounts can't be connected.
pairwise_id text not null unique,
username text not null,
linked_at timestamptz not null default now(),
age_verified boolean not null default false,
access_pass boolean not null default false,
checked_at timestamptz not null default now(),
show_badge boolean not null default false,
show_presence boolean not null default false
);
create index imvu_links_person on imvu_links (person_id);
-- A code the person is to put in an IMVU account's tagline, proving it's theirs.
create table link_challenges (
person_id bigint not null references persons on delete cascade,
imvu_id bigint not null,
username text not null,
code text not null,
created_at timestamptz not null default now(),
primary key (person_id, imvu_id)
);
-- Signing a proxy in: the proxy shows user_code, the person approves it on the website.
create table device_codes (
device_hash bytea primary key,
user_code text not null unique,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
person_id bigint references persons on delete cascade,
consumed_at timestamptz
);
create table refresh_tokens (
token_hash bytea primary key,
person_id bigint not null references persons on delete cascade,
created_at timestamptz not null default now(),
last_used_at timestamptz not null default now(),
revoked_at timestamptz
);
create index refresh_tokens_person on refresh_tokens (person_id);
create table audit (
id bigserial primary key,
at timestamptz not null default now(),
actor_id bigint references persons on delete set null,
action text not null,
target text not null default '',
detail jsonb not null default '{}'
);
create index audit_at on audit (at desc);
-529
View File
@@ -1,529 +0,0 @@
// Package store is the hub's data in Postgres: people, their linked IMVU accounts, sessions, proxy sign-ins and
// the audit log. Plain SQL with pgx.
package store
import (
"context"
"crypto/sha256"
"embed"
"errors"
"fmt"
"io/fs"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrations embed.FS
// Store is the hub's database.
type Store struct {
db *pgxpool.Pool
}
// ErrNotFound means there's no such row.
var ErrNotFound = errors.New("not found")
// ErrTaken means a unique value (a username) is in use.
var ErrTaken = errors.New("taken")
// Open connects to Postgres and brings its schema up to date.
func Open(ctx context.Context, url string) (*Store, error) {
db, err := pgxpool.New(ctx, url)
if err != nil {
return nil, err
}
s := &Store{db: db}
if err := s.migrate(ctx); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() { s.db.Close() }
// migrate applies the migrations not applied yet, in name order, each in a transaction.
func (s *Store) migrate(ctx context.Context) error {
if _, err := s.db.Exec(ctx, `create table if not exists schema_migrations (name text primary key, applied_at timestamptz not null default now())`); err != nil {
return err
}
names, err := fs.Glob(migrations, "migrations/*.sql")
if err != nil {
return err
}
sort.Strings(names)
for _, name := range names {
var done bool
if err := s.db.QueryRow(ctx, `select exists (select 1 from schema_migrations where name = $1)`, name).Scan(&done); err != nil {
return err
}
if done {
continue
}
sql, _ := migrations.ReadFile(name)
err := pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, string(sql)); err != nil {
return err
}
_, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name)
return err
})
if err != nil {
return fmt.Errorf("migration %s: %w", name, err)
}
}
return nil
}
// Hash is how tokens are stored: never the token itself.
func Hash(token string) []byte {
h := sha256.Sum256([]byte(token))
return h[:]
}
func isUniqueViolation(err error) bool {
var pg *pgconn.PgError
return errors.As(err, &pg) && pg.Code == "23505"
}
// Person is a hub account: a person.
type Person struct {
ID int64
Username string
PasswordHash string
Role string
LinkCap int
CreatedAt time.Time
BannedAt *time.Time
BanReason string
}
func (p *Person) IsModerator() bool { return p.Role == "moderator" || p.Role == "admin" }
func (p *Person) IsAdmin() bool { return p.Role == "admin" }
func (p *Person) Banned() bool { return p.BannedAt != nil }
const personColumns = `id, username, password_hash, role, link_cap, created_at, banned_at, ban_reason`
func scanPerson(row pgx.Row) (*Person, error) {
var p Person
err := row.Scan(&p.ID, &p.Username, &p.PasswordHash, &p.Role, &p.LinkCap, &p.CreatedAt, &p.BannedAt, &p.BanReason)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &p, err
}
// CreatePerson makes an account. The first account is an admin.
func (s *Store) CreatePerson(ctx context.Context, username, passwordHash string) (*Person, error) {
p, err := scanPerson(s.db.QueryRow(ctx, `insert into persons (username, password_hash, role)
values ($1, $2, case when exists (select 1 from persons) then 'user' else 'admin' end)
returning `+personColumns, username, passwordHash))
if isUniqueViolation(err) {
return nil, ErrTaken
}
return p, err
}
func (s *Store) PersonByID(ctx context.Context, id int64) (*Person, error) {
return scanPerson(s.db.QueryRow(ctx, `select `+personColumns+` from persons where id = $1`, id))
}
func (s *Store) PersonByUsername(ctx context.Context, username string) (*Person, error) {
return scanPerson(s.db.QueryRow(ctx, `select `+personColumns+` from persons where lower(username) = lower($1)`, username))
}
// People lists accounts, newest first, optionally filtered by a username search.
func (s *Store) People(ctx context.Context, search string, limit int) ([]Person, error) {
rows, err := s.db.Query(ctx, `select `+personColumns+` from persons
where $1 = '' or lower(username) like '%' || lower($1) || '%'
or id in (select person_id from imvu_links where lower(username) like '%' || lower($1) || '%')
order by id desc limit $2`, search, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Person
for rows.Next() {
p, err := scanPerson(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
func (s *Store) SetRole(ctx context.Context, id int64, role string) error {
_, err := s.db.Exec(ctx, `update persons set role = $2 where id = $1`, id, role)
return err
}
func (s *Store) SetLinkCap(ctx context.Context, id int64, cap int) error {
_, err := s.db.Exec(ctx, `update persons set link_cap = $2 where id = $1`, id, cap)
return err
}
// SetBanned bans (with a reason) or unbans a person; a ban also ends their sessions and sign-ins.
func (s *Store) SetBanned(ctx context.Context, id int64, banned bool, reason string) error {
return pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
if !banned {
_, err := tx.Exec(ctx, `update persons set banned_at = null, ban_reason = '' where id = $1`, id)
return err
}
if _, err := tx.Exec(ctx, `update persons set banned_at = now(), ban_reason = $2 where id = $1`, id, reason); err != nil {
return err
}
if _, err := tx.Exec(ctx, `delete from sessions where person_id = $1`, id); err != nil {
return err
}
_, err := tx.Exec(ctx, `update refresh_tokens set revoked_at = now() where person_id = $1 and revoked_at is null`, id)
return err
})
}
// Sessions
func (s *Store) CreateSession(ctx context.Context, token string, personID int64, csrf string, expires time.Time) error {
_, err := s.db.Exec(ctx, `insert into sessions (token_hash, person_id, csrf, expires_at) values ($1, $2, $3, $4)`,
Hash(token), personID, csrf, expires)
return err
}
// Session is the person and CSRF token of a live session.
func (s *Store) Session(ctx context.Context, token string) (*Person, string, error) {
var csrf string
var p Person
err := s.db.QueryRow(ctx, `select s.csrf, `+prefixed("p", personColumns)+` from sessions s join persons p on p.id = s.person_id
where s.token_hash = $1 and s.expires_at > now()`, Hash(token)).
Scan(&csrf, &p.ID, &p.Username, &p.PasswordHash, &p.Role, &p.LinkCap, &p.CreatedAt, &p.BannedAt, &p.BanReason)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
return &p, csrf, err
}
func (s *Store) DeleteSession(ctx context.Context, token string) error {
_, err := s.db.Exec(ctx, `delete from sessions where token_hash = $1`, Hash(token))
return err
}
func prefixed(alias, columns string) string {
parts := strings.Split(columns, ", ")
for i := range parts {
parts[i] = alias + "." + parts[i]
}
return strings.Join(parts, ", ")
}
// Linked IMVU accounts
// Link is a linked IMVU account.
type Link struct {
IMVUID int64
PersonID int64
PairwiseID string
Username string
LinkedAt time.Time
AgeVerified bool
AccessPass bool
CheckedAt time.Time
ShowBadge bool
ShowPresence bool
}
// Adult reports whether the account may see adult content: age verified by IMVU, or with Access Pass.
func (l *Link) Adult() bool { return l.AgeVerified || l.AccessPass }
const linkColumns = `imvu_id, person_id, pairwise_id, username, linked_at, age_verified, access_pass, checked_at, show_badge, show_presence`
func scanLink(row pgx.Row) (*Link, error) {
var l Link
err := row.Scan(&l.IMVUID, &l.PersonID, &l.PairwiseID, &l.Username, &l.LinkedAt, &l.AgeVerified, &l.AccessPass, &l.CheckedAt, &l.ShowBadge, &l.ShowPresence)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &l, err
}
func (s *Store) Links(ctx context.Context, personID int64) ([]Link, error) {
rows, err := s.db.Query(ctx, `select `+linkColumns+` from imvu_links where person_id = $1 order by linked_at`, personID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Link
for rows.Next() {
l, err := scanLink(rows)
if err != nil {
return nil, err
}
out = append(out, *l)
}
return out, rows.Err()
}
func (s *Store) LinkByIMVUID(ctx context.Context, imvuID int64) (*Link, error) {
return scanLink(s.db.QueryRow(ctx, `select `+linkColumns+` from imvu_links where imvu_id = $1`, imvuID))
}
// ErrLinkCap means the person has linked as many accounts as they may.
var ErrLinkCap = errors.New("link cap reached")
// LinkAccount links an IMVU account to a person, moving it from whoever had it (the most recent proof wins).
// It returns the person it was moved from, if any.
func (s *Store) LinkAccount(ctx context.Context, personID, imvuID int64, username, pairwiseID string, ageVerified, accessPass bool) (movedFrom int64, err error) {
err = pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
var cap, count int
if err := tx.QueryRow(ctx, `select link_cap, (select count(*) from imvu_links where person_id = $1 and imvu_id <> $2)
from persons where id = $1 for update`, personID, imvuID).Scan(&cap, &count); err != nil {
return err
}
if count >= cap {
return ErrLinkCap
}
err := tx.QueryRow(ctx, `select person_id from imvu_links where imvu_id = $1`, imvuID).Scan(&movedFrom)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
if movedFrom == personID {
movedFrom = 0
}
// A moved account gets a new pairwise ID: nothing connects its new owner to the old one.
_, err = tx.Exec(ctx, `insert into imvu_links (imvu_id, person_id, pairwise_id, username, age_verified, access_pass)
values ($1, $2, $3, $4, $5, $6)
on conflict (imvu_id) do update set person_id = excluded.person_id,
pairwise_id = case when imvu_links.person_id = excluded.person_id then imvu_links.pairwise_id else excluded.pairwise_id end,
username = excluded.username, age_verified = excluded.age_verified, access_pass = excluded.access_pass,
checked_at = now(),
linked_at = case when imvu_links.person_id = excluded.person_id then imvu_links.linked_at else now() end,
show_badge = case when imvu_links.person_id = excluded.person_id then imvu_links.show_badge else false end,
show_presence = case when imvu_links.person_id = excluded.person_id then imvu_links.show_presence else false end`,
imvuID, personID, pairwiseID, username, ageVerified, accessPass)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `delete from link_challenges where imvu_id = $1`, imvuID)
return err
})
return movedFrom, err
}
func (s *Store) Unlink(ctx context.Context, personID, imvuID int64) error {
_, err := s.db.Exec(ctx, `delete from imvu_links where person_id = $1 and imvu_id = $2`, personID, imvuID)
return err
}
// UpdateAge records a fresh age check of a linked account.
func (s *Store) UpdateAge(ctx context.Context, imvuID int64, username string, ageVerified, accessPass bool) error {
_, err := s.db.Exec(ctx, `update imvu_links set username = $2, age_verified = $3, access_pass = $4, checked_at = now() where imvu_id = $1`,
imvuID, username, ageVerified, accessPass)
return err
}
func (s *Store) SetLinkSettings(ctx context.Context, personID, imvuID int64, badge, presence bool) error {
_, err := s.db.Exec(ctx, `update imvu_links set show_badge = $3, show_presence = $4 where person_id = $1 and imvu_id = $2`,
personID, imvuID, badge, presence)
return err
}
// Challenge is a pending tagline code.
type Challenge struct {
IMVUID int64
Username string
Code string
CreatedAt time.Time
}
// SetChallenge stores (or replaces) the code for linking an account.
func (s *Store) SetChallenge(ctx context.Context, personID, imvuID int64, username, code string) error {
_, err := s.db.Exec(ctx, `insert into link_challenges (person_id, imvu_id, username, code) values ($1, $2, $3, $4)
on conflict (person_id, imvu_id) do update set code = excluded.code, username = excluded.username, created_at = now()`,
personID, imvuID, username, code)
return err
}
func (s *Store) Challenges(ctx context.Context, personID int64) ([]Challenge, error) {
rows, err := s.db.Query(ctx, `select imvu_id, username, code, created_at from link_challenges
where person_id = $1 and created_at > now() - interval '1 day' order by created_at`, personID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Challenge
for rows.Next() {
var c Challenge
if err := rows.Scan(&c.IMVUID, &c.Username, &c.Code, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (s *Store) Challenge(ctx context.Context, personID, imvuID int64) (*Challenge, error) {
var c Challenge
err := s.db.QueryRow(ctx, `select imvu_id, username, code, created_at from link_challenges
where person_id = $1 and imvu_id = $2 and created_at > now() - interval '1 day'`, personID, imvuID).
Scan(&c.IMVUID, &c.Username, &c.Code, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return &c, err
}
func (s *Store) DeleteChallenge(ctx context.Context, personID, imvuID int64) error {
_, err := s.db.Exec(ctx, `delete from link_challenges where person_id = $1 and imvu_id = $2`, personID, imvuID)
return err
}
// Proxy sign-in (device codes) and refresh tokens
func (s *Store) CreateDeviceCode(ctx context.Context, deviceCode, userCode string, expires time.Time) error {
_, err := s.db.Exec(ctx, `insert into device_codes (device_hash, user_code, expires_at) values ($1, $2, $3)`,
Hash(deviceCode), userCode, expires)
if isUniqueViolation(err) {
return ErrTaken
}
return err
}
// ApproveDeviceCode lets the person's proxy waiting with userCode sign in.
func (s *Store) ApproveDeviceCode(ctx context.Context, userCode string, personID int64) error {
tag, err := s.db.Exec(ctx, `update device_codes set person_id = $2
where user_code = $1 and person_id is null and consumed_at is null and expires_at > now()`, userCode, personID)
if err == nil && tag.RowsAffected() == 0 {
return ErrNotFound
}
return err
}
// DeviceStatus is a waiting proxy's sign-in: approved (with the person), pending, or gone.
type DeviceStatus int
const (
DevicePending DeviceStatus = iota
DeviceApproved
DeviceGone
)
// ConsumeDeviceCode reports a device code's state; an approved one is used up by this call.
func (s *Store) ConsumeDeviceCode(ctx context.Context, deviceCode string) (DeviceStatus, int64, error) {
var personID *int64
var expires time.Time
var consumed *time.Time
err := s.db.QueryRow(ctx, `select person_id, expires_at, consumed_at from device_codes where device_hash = $1`, Hash(deviceCode)).
Scan(&personID, &expires, &consumed)
if errors.Is(err, pgx.ErrNoRows) || err == nil && (consumed != nil || time.Now().After(expires)) {
return DeviceGone, 0, nil
}
if err != nil {
return 0, 0, err
}
if personID == nil {
return DevicePending, 0, nil
}
tag, err := s.db.Exec(ctx, `update device_codes set consumed_at = now() where device_hash = $1 and consumed_at is null`, Hash(deviceCode))
if err != nil {
return 0, 0, err
}
if tag.RowsAffected() == 0 {
return DeviceGone, 0, nil
}
return DeviceApproved, *personID, nil
}
func (s *Store) CreateRefreshToken(ctx context.Context, token string, personID int64) error {
_, err := s.db.Exec(ctx, `insert into refresh_tokens (token_hash, person_id) values ($1, $2)`, Hash(token), personID)
return err
}
// RotateRefreshToken trades a live refresh token for a new one, returning the person it's for.
func (s *Store) RotateRefreshToken(ctx context.Context, old, replacement string) (int64, error) {
var personID int64
err := pgx.BeginFunc(ctx, s.db, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `update refresh_tokens set revoked_at = now()
where token_hash = $1 and revoked_at is null returning person_id`, Hash(old)).Scan(&personID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
_, err = tx.Exec(ctx, `insert into refresh_tokens (token_hash, person_id) values ($1, $2)`, Hash(replacement), personID)
return err
})
return personID, err
}
// Signins counts a person's signed-in proxies.
func (s *Store) Signins(ctx context.Context, personID int64) (int, error) {
var n int
err := s.db.QueryRow(ctx, `select count(*) from refresh_tokens where person_id = $1 and revoked_at is null`, personID).Scan(&n)
return n, err
}
// SignOutProxies ends all of a person's proxy sign-ins.
func (s *Store) SignOutProxies(ctx context.Context, personID int64) error {
_, err := s.db.Exec(ctx, `update refresh_tokens set revoked_at = now() where person_id = $1 and revoked_at is null`, personID)
return err
}
// Audit log
// Audit records something a person (or the hub, actor 0) did.
func (s *Store) Audit(ctx context.Context, actorID int64, action, target string, detail map[string]any) error {
var actor *int64
if actorID != 0 {
actor = &actorID
}
if detail == nil {
detail = map[string]any{}
}
_, err := s.db.Exec(ctx, `insert into audit (actor_id, action, target, detail) values ($1, $2, $3, $4)`, actor, action, target, detail)
return err
}
// AuditEntry is a line of the audit log.
type AuditEntry struct {
At time.Time
Actor string
Action string
Target string
Detail map[string]any
}
func (s *Store) AuditLog(ctx context.Context, limit int) ([]AuditEntry, error) {
rows, err := s.db.Query(ctx, `select a.at, coalesce(p.username, ''), a.action, a.target, a.detail
from audit a left join persons p on p.id = a.actor_id order by a.id desc limit $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(&e.At, &e.Actor, &e.Action, &e.Target, &e.Detail); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// Counts are totals for the console.
type Counts struct {
People, Links, Signins int
}
func (s *Store) Counts(ctx context.Context) (Counts, error) {
var c Counts
err := s.db.QueryRow(ctx, `select (select count(*) from persons), (select count(*) from imvu_links),
(select count(*) from refresh_tokens where revoked_at is null)`).Scan(&c.People, &c.Links, &c.Signins)
return c, err
}
-117
View File
@@ -1,117 +0,0 @@
// Package token issues and checks the hub's access tokens: JWTs signed with Ed25519 ("EdDSA"), short-lived. The
// public keys are published (JWKS), so catalogs can check tokens without asking the hub.
package token
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Claims are what a token says.
type Claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"` // "person:<id>" for the hub's own API
Audience string `json:"aud"`
IssuedAt int64 `json:"iat"`
Expires int64 `json:"exp"`
Scope string `json:"scope,omitempty"`
}
// Keys signs and checks tokens.
type Keys struct {
id string
private ed25519.PrivateKey
issuer string
}
// LoadOrCreate reads the signing key from path, making one if there's none.
func LoadOrCreate(path, issuer string) (*Keys, error) {
seed, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
seed = make([]byte, ed25519.SeedSize)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
if err := os.WriteFile(path, seed, 0o600); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, fmt.Errorf("%s: not an Ed25519 seed", path)
}
key := ed25519.NewKeyFromSeed(seed)
pub := key.Public().(ed25519.PublicKey)
return &Keys{id: b64(pub[:8]), private: key, issuer: issuer}, nil
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// Issue signs a token for subject and audience, valid for ttl.
func (k *Keys) Issue(subject, audience, scope string, ttl time.Duration) (string, error) {
now := time.Now()
header, _ := json.Marshal(map[string]string{"alg": "EdDSA", "typ": "JWT", "kid": k.id})
claims, _ := json.Marshal(Claims{Issuer: k.issuer, Subject: subject, Audience: audience, IssuedAt: now.Unix(),
Expires: now.Add(ttl).Unix(), Scope: scope})
signing := b64(header) + "." + b64(claims)
return signing + "." + b64(ed25519.Sign(k.private, []byte(signing))), nil
}
// ErrInvalid means a token is malformed, forged, for someone else, or expired.
var ErrInvalid = errors.New("invalid token")
// Check verifies a token for audience and returns its claims.
func (k *Keys) Check(token, audience string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrInvalid
}
headerJSON, err1 := base64.RawURLEncoding.DecodeString(parts[0])
claimsJSON, err2 := base64.RawURLEncoding.DecodeString(parts[1])
sig, err3 := base64.RawURLEncoding.DecodeString(parts[2])
if err1 != nil || err2 != nil || err3 != nil {
return nil, ErrInvalid
}
var header struct{ Alg, Kid string }
if json.Unmarshal(headerJSON, &header) != nil || header.Alg != "EdDSA" || header.Kid != k.id {
return nil, ErrInvalid
}
if !ed25519.Verify(k.private.Public().(ed25519.PublicKey), []byte(parts[0]+"."+parts[1]), sig) {
return nil, ErrInvalid
}
var c Claims
if json.Unmarshal(claimsJSON, &c) != nil || c.Issuer != k.issuer || c.Audience != audience || time.Now().Unix() >= c.Expires {
return nil, ErrInvalid
}
return &c, nil
}
// JWKS is the public key set, as JSON (RFC 8037 OKP keys).
func (k *Keys) JWKS() []byte {
pub := k.private.Public().(ed25519.PublicKey)
b, _ := json.Marshal(map[string]any{"keys": []map[string]string{{
"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": k.id, "x": b64(pub),
}}})
return b
}
// Random is a random URL-safe string of n bytes' entropy, for session and refresh tokens.
func Random(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b64(b)
}
-44
View File
@@ -1,44 +0,0 @@
package token
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
)
func TestIssueAndCheck(t *testing.T) {
path := filepath.Join(t.TempDir(), "key")
k, err := LoadOrCreate(path, "https://hub.test")
if err != nil {
t.Fatal(err)
}
tok, _ := k.Issue("person:1", "t5de-hub", "proxy", time.Minute)
c, err := k.Check(tok, "t5de-hub")
if err != nil || c.Subject != "person:1" || c.Scope != "proxy" {
t.Fatalf("%+v %v", c, err)
}
if _, err := k.Check(tok, "a-catalog"); err == nil {
t.Fatal("accepted for another audience")
}
// The same key comes back from the file.
k2, _ := LoadOrCreate(path, "https://hub.test")
if _, err := k2.Check(tok, "t5de-hub"); err != nil {
t.Fatal(err)
}
// Tampering and expiry are caught.
parts := strings.Split(tok, ".")
forged := parts[0] + "." + b64([]byte(`{"iss":"https://hub.test","sub":"person:2","aud":"t5de-hub","exp":9999999999}`)) + "." + parts[2]
if _, err := k.Check(forged, "t5de-hub"); err == nil {
t.Fatal("accepted a forged token")
}
old, _ := k.Issue("person:1", "t5de-hub", "", -time.Second)
if _, err := k.Check(old, "t5de-hub"); err == nil {
t.Fatal("accepted an expired token")
}
var jwks struct{ Keys []map[string]string }
if json.Unmarshal(k.JWKS(), &jwks) != nil || len(jwks.Keys) != 1 || jwks.Keys[0]["crv"] != "Ed25519" {
t.Fatalf("%s", k.JWKS())
}
}
-278
View File
@@ -1,278 +0,0 @@
package web
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.t5de.dev/t5de/t5de-hub/internal/imvu"
"git.t5de.dev/t5de/t5de-hub/internal/realtime"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
func (s *Server) home(q *request) error {
data := map[string]any{"OnlineCount": len(s.visibleOnline(q))}
if q.person != nil {
links, err := s.Store.Links(s.ctx(q), q.person.ID)
if err != nil {
return err
}
data["Links"] = links
}
s.render(q, "home", data)
return nil
}
// linkView is a linked account on the account page.
type linkView struct {
store.Link
Online bool
Outfit []realtime.Item
}
func (s *Server) account(q *request) error {
ctx := s.ctx(q)
links, err := s.Store.Links(ctx, q.person.ID)
if err != nil {
return err
}
challenges, err := s.Store.Challenges(ctx, q.person.ID)
if err != nil {
return err
}
signins, err := s.Store.Signins(ctx, q.person.ID)
if err != nil {
return err
}
online := map[int64]realtime.Online{}
for _, o := range s.Realtime.Online() {
online[o.IMVUID] = o
}
views := make([]linkView, len(links))
for i, l := range links {
o, ok := online[l.IMVUID]
views[i] = linkView{Link: l, Online: ok, Outfit: o.Outfit}
}
s.render(q, "account", map[string]any{
"Links": views, "Challenges": challenges, "Signins": signins,
"CanLink": len(links) < q.person.LinkCap, "Error": q.r.URL.Query().Get("error"),
})
return nil
}
// linkCode is what goes in the tagline: "t5de-" and eight letters and digits (no look-alikes).
func linkCode() string {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, 8)
rand.Read(b)
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return "t5de-" + string(b[:4]) + "-" + string(b[4:])
}
func (s *Server) startLink(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many lookups. Try again in a while."), "")
}
u, err := s.IMVU.ByUsername(s.ctx(q), username)
if errors.Is(err, imvu.ErrNoSuchUser) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("IMVU has no account named %q.", username)), "")
}
if err != nil {
s.Log.Printf("looking up %q on IMVU: %v", username, err)
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if l, err := s.Store.LinkByIMVUID(s.ctx(q), u.ID); err == nil && l.PersonID == q.person.ID {
return q.redirect("/account", u.Username+" is already linked.")
}
if err := s.Store.SetChallenge(s.ctx(q), q.person.ID, u.ID, u.Username, linkCode()); err != nil {
return err
}
return q.redirect("/account#link-"+strconv.FormatInt(u.ID, 10), "")
}
func imvuIDParam(q *request) (int64, error) {
id, err := strconv.ParseInt(q.r.PathValue("imvu"), 10, 64)
if err != nil || id <= 0 {
return 0, fail(http.StatusNotFound, "No such account.")
}
return id, nil
}
// checkLink reads the account's tagline from IMVU and links it if the code is there.
func (s *Server) checkLink(q *request) error {
ctx := s.ctx(q)
id, err := imvuIDParam(q)
if err != nil {
return err
}
c, err := s.Store.Challenge(ctx, q.person.ID, id)
if errors.Is(err, store.ErrNotFound) {
return q.redirect("/account?error="+queryEscape("That code expired. Start again."), "")
}
if err != nil {
return err
}
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many checks. Try again in a while."), "")
}
u, err := s.IMVU.ByID(ctx, id)
if err != nil {
s.Log.Printf("checking IMVU account %d: %v", id, err)
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if !strings.Contains(strings.ToLower(u.Tagline), strings.ToLower(c.Code)) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("%s's tagline doesn't have the code yet. IMVU can take a minute to show a new tagline.", u.Username))+"#link-"+strconv.FormatInt(id, 10), "")
}
moved, err := s.Store.LinkAccount(ctx, q.person.ID, u.ID, u.Username, token.Random(16), u.AgeVerified, u.AccessPass)
if errors.Is(err, store.ErrLinkCap) {
return q.redirect("/account?error="+queryEscape(fmt.Sprintf("You can link up to %d IMVU accounts. Ask a moderator if you need more.", q.person.LinkCap)), "")
}
if err != nil {
return err
}
detail := map[string]any{"imvu": u.Username}
if moved != 0 {
detail["moved_from"] = moved
}
s.Store.Audit(ctx, q.person.ID, "link.add", strconv.FormatInt(u.ID, 10), detail)
return q.redirect("/account", fmt.Sprintf("Linked %s. You can take the code out of your tagline now.", u.Username))
}
func (s *Server) cancelLink(q *request) error {
id, err := imvuIDParam(q)
if err != nil {
return err
}
if err := s.Store.DeleteChallenge(s.ctx(q), q.person.ID, id); err != nil {
return err
}
return q.redirect("/account", "")
}
func (s *Server) ownLink(q *request) (*store.Link, error) {
id, err := imvuIDParam(q)
if err != nil {
return nil, err
}
l, err := s.Store.LinkByIMVUID(s.ctx(q), id)
if errors.Is(err, store.ErrNotFound) || err == nil && l.PersonID != q.person.ID {
return nil, fail(http.StatusNotFound, "That account isn't linked to you.")
}
return l, err
}
func (s *Server) linkSettings(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
badge, presence := q.r.PostFormValue("badge") == "on", q.r.PostFormValue("presence") == "on"
if err := s.Store.SetLinkSettings(s.ctx(q), q.person.ID, l.IMVUID, badge, presence); err != nil {
return err
}
return q.redirect("/account", "Saved settings for "+l.Username+".")
}
// recheckLink reads the account's age status from IMVU again.
func (s *Server) recheckLink(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
if !s.limiter.allow("imvu:"+strconv.FormatInt(q.person.ID, 10), 20, time.Hour) {
return q.redirect("/account?error="+queryEscape("Too many checks. Try again in a while."), "")
}
u, err := s.IMVU.ByID(s.ctx(q), l.IMVUID)
if err != nil {
return q.redirect("/account?error="+queryEscape("IMVU didn't answer. Try again in a moment."), "")
}
if err := s.Store.UpdateAge(s.ctx(q), l.IMVUID, u.Username, u.AgeVerified, u.AccessPass); err != nil {
return err
}
return q.redirect("/account", "Checked "+u.Username+" with IMVU.")
}
func (s *Server) unlink(q *request) error {
l, err := s.ownLink(q)
if err != nil {
return err
}
if err := s.Store.Unlink(s.ctx(q), q.person.ID, l.IMVUID); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "link.remove", strconv.FormatInt(l.IMVUID, 10), map[string]any{"imvu": l.Username})
return q.redirect("/account", "Unlinked "+l.Username+".")
}
func (s *Server) signOutProxies(q *request) error {
if err := s.Store.SignOutProxies(s.ctx(q), q.person.ID); err != nil {
return err
}
s.Realtime.Disconnect(q.person.ID)
return q.redirect("/account", "Signed out of T5DE on all your computers.")
}
func (s *Server) deviceForm(q *request) error {
s.render(q, "device", map[string]any{"Code": strings.ToUpper(q.r.URL.Query().Get("code"))})
return nil
}
func (s *Server) approveDevice(q *request) error {
code := normalizeUserCode(q.r.PostFormValue("code"))
err := s.Store.ApproveDeviceCode(s.ctx(q), code, q.person.ID)
if errors.Is(err, store.ErrNotFound) {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "device", map[string]any{"Code": code, "Error": "That code isn't right, or it expired. Check the code T5DE shows."})
return nil
}
if err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "proxy.signin", "", nil)
s.render(q, "device", map[string]any{"Approved": true})
return nil
}
// visibleOnline is who the viewer may see online in T5DE: accounts that opted in to presence (moderators see all).
func (s *Server) visibleOnline(q *request) []onlineView {
var out []onlineView
for _, o := range s.Realtime.Online() {
l, err := s.Store.LinkByIMVUID(s.ctx(q), o.IMVUID)
if err != nil {
continue
}
mod := q.person != nil && q.person.IsModerator()
if !l.ShowPresence && !mod {
continue
}
out = append(out, onlineView{Link: *l, Online: o, Hidden: !l.ShowPresence})
}
return out
}
type onlineView struct {
Link store.Link
Online realtime.Online
Hidden bool // shown to a moderator only
}
func (s *Server) online(q *request) error {
s.render(q, "online", map[string]any{"Online": s.visibleOnline(q)})
return nil
}
func (s *Server) onlineList(q *request) error {
s.fragment(q, "online", "online-list", map[string]any{"Online": s.visibleOnline(q)})
return nil
}
func queryEscape(s string) string { return url.QueryEscape(s) }
-216
View File
@@ -1,216 +0,0 @@
package web
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
// The proxy signs in like a TV app (OAuth 2.0 device authorization, RFC 8628): it asks for a code, the person
// enters it on the website, and the proxy gets tokens: a short-lived access token (Ed25519 JWT) for the API, and
// a refresh token it trades for new ones.
const (
deviceLifetime = 10 * time.Minute
accessLifetime = 15 * time.Minute
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func apiError(w http.ResponseWriter, status int, code, description string) {
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
}
// userCode is what a person types: 8 letters, no look-alikes, shown as XXXX-XXXX.
func userCode() string {
const alphabet = "BCDFGHJKLMNPQRSTVWXZ"
b := make([]byte, 8)
rand.Read(b)
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return string(b[:4]) + "-" + string(b[4:])
}
func normalizeUserCode(code string) string {
code = strings.ToUpper(strings.Map(func(r rune) rune {
if r == ' ' || r == '-' {
return -1
}
return r
}, code))
if len(code) == 8 {
code = code[:4] + "-" + code[4:]
}
return code
}
func (s *Server) deviceCode(w http.ResponseWriter, r *http.Request) {
if !s.limiter.allow("device:"+clientIP(r), 20, time.Hour) {
apiError(w, http.StatusTooManyRequests, "slow_down", "too many sign-ins from here")
return
}
device := token.Random(32)
var code string
for range 5 {
code = userCode()
err := s.Store.CreateDeviceCode(r.Context(), device, code, time.Now().Add(deviceLifetime))
if err == nil {
break
}
if !errors.Is(err, store.ErrTaken) {
s.Log.Printf("device code: %v", err)
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
}
writeJSON(w, http.StatusOK, map[string]any{
"device_code": device, "user_code": code, "expires_in": int(deviceLifetime.Seconds()), "interval": 3,
"verification_uri": s.PublicURL + "/device",
"verification_uri_complete": s.PublicURL + "/device?code=" + code,
})
}
func (s *Server) issue(ctx context.Context, w http.ResponseWriter, personID int64, refresh string) {
access, err := s.Keys.Issue("person:"+strconv.FormatInt(personID, 10), Audience, "proxy", accessLifetime)
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
writeJSON(w, http.StatusOK, map[string]any{"access_token": access, "token_type": "Bearer",
"expires_in": int(accessLifetime.Seconds()), "refresh_token": refresh})
}
func (s *Server) deviceToken(w http.ResponseWriter, r *http.Request) {
status, personID, err := s.Store.ConsumeDeviceCode(r.Context(), r.PostFormValue("device_code"))
if err != nil {
s.Log.Printf("device token: %v", err)
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
switch status {
case store.DevicePending:
apiError(w, http.StatusBadRequest, "authorization_pending", "waiting for the code to be entered")
return
case store.DeviceGone:
apiError(w, http.StatusBadRequest, "expired_token", "the code expired or was used")
return
}
p, err := s.Store.PersonByID(r.Context(), personID)
if err != nil || p.Banned() {
apiError(w, http.StatusForbidden, "access_denied", "")
return
}
refresh := token.Random(32)
if err := s.Store.CreateRefreshToken(r.Context(), refresh, personID); err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
s.issue(r.Context(), w, personID, refresh)
}
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
replacement := token.Random(32)
personID, err := s.Store.RotateRefreshToken(r.Context(), r.PostFormValue("refresh_token"), replacement)
if errors.Is(err, store.ErrNotFound) {
apiError(w, http.StatusUnauthorized, "invalid_grant", "signed out")
return
}
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
p, err := s.Store.PersonByID(r.Context(), personID)
if err != nil || p.Banned() {
apiError(w, http.StatusForbidden, "access_denied", "")
return
}
s.issue(r.Context(), w, personID, replacement)
}
// bearer is the person an API request's access token is for.
func (s *Server) bearer(r *http.Request) (*store.Person, error) {
auth := r.Header.Get("Authorization")
tok, ok := strings.CutPrefix(auth, "Bearer ")
if !ok {
return nil, errors.New("no token")
}
c, err := s.Keys.Check(tok, Audience)
if err != nil {
return nil, err
}
id, err := strconv.ParseInt(strings.TrimPrefix(c.Subject, "person:"), 10, 64)
if err != nil {
return nil, err
}
p, err := s.Store.PersonByID(r.Context(), id)
if err != nil || p.Banned() {
return nil, errors.New("no such person")
}
return p, nil
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
p, err := s.bearer(r)
if err != nil {
apiError(w, http.StatusUnauthorized, "invalid_token", "")
return
}
links, err := s.Store.Links(r.Context(), p.ID)
if err != nil {
apiError(w, http.StatusInternalServerError, "server_error", "")
return
}
type link struct {
IMVUID int64 `json:"imvu_id"`
Username string `json:"username"`
Adult bool `json:"adult"`
}
out := []link{}
for _, l := range links {
out = append(out, link{l.IMVUID, l.Username, l.Adult()})
}
writeJSON(w, http.StatusOK, map[string]any{"username": p.Username, "links": out})
}
func (s *Server) realtime(w http.ResponseWriter, r *http.Request) {
p, err := s.bearer(r)
if err != nil {
apiError(w, http.StatusUnauthorized, "invalid_token", "")
return
}
s.Realtime.Serve(w, r, p.ID)
}
func (s *Server) jwks(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write(s.Keys.JWKS())
}
// LinkOwner implements realtime.Owners.
type Owners struct{ Store *store.Store }
func (o Owners) LinkOwner(ctx context.Context, imvuID int64) (int64, bool, error) {
l, err := o.Store.LinkByIMVUID(ctx, imvuID)
if errors.Is(err, store.ErrNotFound) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return l.PersonID, true, nil
}
-172
View File
@@ -1,172 +0,0 @@
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"unicode/utf8"
"golang.org/x/crypto/argon2"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
// Passwords are hashed with Argon2id (OWASP's minimum: 19 MiB, 2 passes).
const (
argonTime = 2
argonMemory = 19 * 1024
argonThreads = 1
argonKeyLen = 32
)
func hashPassword(password string) string {
salt := make([]byte, 16)
rand.Read(salt)
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
enc := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, enc(salt), enc(key))
}
func checkPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
if err1 != nil || err2 != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// dummyHash is checked against for unknown usernames, so sign-in takes as long either way.
var dummyHash = hashPassword("not a real password")
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,32}$`)
func (s *Server) signupForm(q *request) error {
if q.person != nil {
return q.redirect("/account", "")
}
s.render(q, "signup", nil)
return nil
}
func (s *Server) signup(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
password := q.r.PostFormValue("password")
problem := ""
switch {
case !usernamePattern.MatchString(username):
problem = "Usernames are 3 to 32 letters, digits or underscores."
case utf8.RuneCountInString(password) < 10:
problem = "Use a password of at least 10 characters."
case len(password) > 256:
problem = "That password is too long."
case password != q.r.PostFormValue("password2"):
problem = "The passwords don't match."
case !s.limiter.allow("signup:"+clientIP(q.r), 5, time.Hour):
problem = "Too many new accounts from here. Try again later."
}
if problem != "" {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signup", map[string]any{"Error": problem, "Username": username})
return nil
}
p, err := s.Store.CreatePerson(s.ctx(q), username, hashPassword(password))
if errors.Is(err, store.ErrTaken) {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signup", map[string]any{"Error": "That username is taken.", "Username": username})
return nil
}
if err != nil {
return err
}
s.Store.Audit(s.ctx(q), p.ID, "account.create", p.Username, nil)
if err := s.startSession(q, p.ID); err != nil {
return err
}
return q.redirect("/account", "Welcome to T5DE! Link your IMVU account to get started.")
}
func (s *Server) signinForm(q *request) error {
if q.person != nil {
return q.redirect("/account", "")
}
s.render(q, "signin", map[string]any{"Next": safeNext(q.r.URL.Query().Get("next"))})
return nil
}
func (s *Server) signin(q *request) error {
username := strings.TrimSpace(q.r.PostFormValue("username"))
password := q.r.PostFormValue("password")
next := safeNext(q.r.PostFormValue("next"))
again := func(problem string) error {
q.w.WriteHeader(http.StatusBadRequest)
s.render(q, "signin", map[string]any{"Error": problem, "Username": username, "Next": next})
return nil
}
if !s.limiter.allow("signin:"+clientIP(q.r), 20, 15*time.Minute) || !s.limiter.allow("signin-user:"+strings.ToLower(username), 10, 15*time.Minute) {
return again("Too many tries. Wait a few minutes and try again.")
}
p, err := s.Store.PersonByUsername(s.ctx(q), username)
if errors.Is(err, store.ErrNotFound) {
checkPassword(dummyHash, password)
return again("Wrong username or password.")
}
if err != nil {
return err
}
if !checkPassword(p.PasswordHash, password) {
return again("Wrong username or password.")
}
if p.Banned() {
return again("This account is banned. " + p.BanReason)
}
if err := s.startSession(q, p.ID); err != nil {
return err
}
return q.redirect(next, "")
}
// safeNext keeps redirects after signing in on the hub.
func safeNext(next string) string {
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/account"
}
return next
}
const sessionLifetime = 30 * 24 * time.Hour
func (s *Server) startSession(q *request, personID int64) error {
tok, csrf := token.Random(32), token.Random(24)
if err := s.Store.CreateSession(s.ctx(q), tok, personID, csrf, time.Now().Add(sessionLifetime)); err != nil {
return err
}
http.SetCookie(q.w, &http.Cookie{Name: sessionCookie, Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode,
Secure: strings.HasPrefix(s.PublicURL, "https://"), MaxAge: int(sessionLifetime.Seconds())})
return nil
}
func (s *Server) signout(q *request) error {
if q.token != "" {
s.Store.DeleteSession(s.ctx(q), q.token)
}
http.SetCookie(q.w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
return q.redirect("/", "Signed out.")
}
-141
View File
@@ -1,141 +0,0 @@
package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"git.t5de.dev/t5de/t5de-hub/internal/store"
)
// The moderation console: counts, who's online, people (search, link caps, bans, roles), and the audit log.
func (s *Server) console(q *request) error {
counts, err := s.Store.Counts(s.ctx(q))
if err != nil {
return err
}
log, err := s.Store.AuditLog(s.ctx(q), 50)
if err != nil {
return err
}
s.render(q, "console", map[string]any{"Counts": counts, "Connections": s.Realtime.Connections(),
"Online": s.visibleOnline(q), "Audit": log})
return nil
}
func (s *Server) consoleOnline(q *request) error {
s.fragment(q, "console", "console-online", map[string]any{"Online": s.visibleOnline(q), "Connections": s.Realtime.Connections()})
return nil
}
func (s *Server) consolePeople(q *request) error {
search := strings.TrimSpace(q.r.URL.Query().Get("q"))
people, err := s.Store.People(s.ctx(q), search, 100)
if err != nil {
return err
}
s.render(q, "console_people", map[string]any{"People": people, "Search": search})
return nil
}
func (s *Server) personParam(q *request) (*store.Person, error) {
id, err := strconv.ParseInt(q.r.PathValue("id"), 10, 64)
if err != nil {
return nil, fail(http.StatusNotFound, "No such person.")
}
p, err := s.Store.PersonByID(s.ctx(q), id)
if errors.Is(err, store.ErrNotFound) {
return nil, fail(http.StatusNotFound, "No such person.")
}
return p, err
}
func (s *Server) consolePerson(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
links, err := s.Store.Links(s.ctx(q), p.ID)
if err != nil {
return err
}
signins, err := s.Store.Signins(s.ctx(q), p.ID)
if err != nil {
return err
}
s.render(q, "console_person", map[string]any{"Subject": p, "Links": links, "Signins": signins})
return nil
}
func (s *Server) setCap(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
cap, err := strconv.Atoi(q.r.PostFormValue("cap"))
if err != nil || cap < 0 || cap > 1000 {
return fail(http.StatusBadRequest, "The link cap is a number from 0 to 1000.")
}
if err := s.Store.SetLinkCap(s.ctx(q), p.ID, cap); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.link_cap", p.Username, map[string]any{"from": p.LinkCap, "to": cap})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), fmt.Sprintf("%s can link %d accounts.", p.Username, cap))
}
func (s *Server) ban(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
if p.ID == q.person.ID {
return fail(http.StatusBadRequest, "You can't ban yourself.")
}
if p.IsModerator() && !q.person.IsAdmin() {
return fail(http.StatusForbidden, "Only admins can ban moderators.")
}
reason := strings.TrimSpace(q.r.PostFormValue("reason"))
if reason == "" || len(reason) > 500 {
return fail(http.StatusBadRequest, "Give a reason (up to 500 characters); the person sees it.")
}
if err := s.Store.SetBanned(s.ctx(q), p.ID, true, reason); err != nil {
return err
}
s.Realtime.Disconnect(p.ID)
s.Store.Audit(s.ctx(q), q.person.ID, "person.ban", p.Username, map[string]any{"reason": reason})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), "Banned "+p.Username+".")
}
func (s *Server) unban(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
if err := s.Store.SetBanned(s.ctx(q), p.ID, false, ""); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.unban", p.Username, nil)
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), "Unbanned "+p.Username+".")
}
func (s *Server) setRole(q *request) error {
p, err := s.personParam(q)
if err != nil {
return err
}
role := q.r.PostFormValue("role")
if role != "user" && role != "moderator" && role != "admin" {
return fail(http.StatusBadRequest, "No such role.")
}
if p.ID == q.person.ID {
return fail(http.StatusBadRequest, "Another admin has to change your role.")
}
if err := s.Store.SetRole(s.ctx(q), p.ID, role); err != nil {
return err
}
s.Store.Audit(s.ctx(q), q.person.ID, "person.role", p.Username, map[string]any{"from": p.Role, "to": role})
return q.redirect(fmt.Sprintf("/console/people/%d", p.ID), p.Username+" is now "+role+".")
}
-82
View File
@@ -1,82 +0,0 @@
package web
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strings"
"time"
)
// pageNames are the templates with a full page each; "layout" wraps them. Fragments (for htmx) are defined inside
// page files and rendered on their own.
var pageNames = []string{"home", "signin", "signup", "account", "device", "online", "console", "console_people",
"console_person", "error"}
var funcs = template.FuncMap{
"ago": func(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d.Hours()))
}
return t.Format("2 Jan 2006")
},
"date": func(t time.Time) string { return t.Format("2 Jan 2006 15:04") },
"upper": strings.ToUpper,
}
func (s *Server) parseTemplates() error {
s.pages = map[string]*template.Template{}
for _, name := range pageNames {
t, err := template.New("layout.html").Funcs(funcs).ParseFS(templateFiles, "templates/layout.html", "templates/"+name+".html")
if err != nil {
return err
}
s.pages[name] = t
}
return nil
}
// render shows a page in the layout. data is the page's own values; the layout gets the person and CSRF token.
func (s *Server) render(q *request, name string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Person"] = q.person
data["CSRF"] = q.csrf
data["Flash"] = q.flash
data["Page"] = name
var buf bytes.Buffer
if err := s.pages[name].ExecuteTemplate(&buf, "layout.html", data); err != nil {
s.Log.Printf("rendering %s: %v", name, err)
http.Error(q.w, "Something went wrong on our side.", http.StatusInternalServerError)
return
}
q.w.Header().Set("Content-Type", "text/html; charset=utf-8")
q.w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(q.w)
}
// fragment shows one template from a page's file, without the layout (htmx swaps it in).
func (s *Server) fragment(q *request, page, name string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
data["Person"] = q.person
data["CSRF"] = q.csrf
var buf bytes.Buffer
if err := s.pages[page].ExecuteTemplate(&buf, name, data); err != nil {
s.Log.Printf("rendering %s/%s: %v", page, name, err)
http.Error(q.w, "Something went wrong on our side.", http.StatusInternalServerError)
return
}
q.w.Header().Set("Content-Type", "text/html; charset=utf-8")
q.w.Header().Set("Cache-Control", "no-store")
buf.WriteTo(q.w)
}
-267
View File
@@ -1,267 +0,0 @@
// Package web is the hub's website (server-rendered pages, htmx for the live parts) and its API for proxies.
package web
import (
"context"
"crypto/subtle"
"embed"
"errors"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"git.t5de.dev/t5de/t5de-hub/internal/imvu"
"git.t5de.dev/t5de/t5de-hub/internal/realtime"
"git.t5de.dev/t5de/t5de-hub/internal/store"
"git.t5de.dev/t5de/t5de-hub/internal/token"
)
//go:embed templates/*.html
var templateFiles embed.FS
//go:embed static
var staticFiles embed.FS
// Audience is the hub's own API, in its access tokens.
const Audience = "t5de-hub"
// Server is the website and API.
type Server struct {
Store *store.Store
IMVU *imvu.Client
Keys *token.Keys
Realtime *realtime.Hub
PublicURL string // e.g. https://hub.t5de.dev, for links shown to proxies
Log *log.Logger
pages map[string]*template.Template
limiter *limiter
}
// Handler is the hub's HTTP handler.
func (s *Server) Handler() (http.Handler, error) {
if err := s.parseTemplates(); err != nil {
return nil, err
}
s.limiter = &limiter{hits: map[string][]time.Time{}}
mux := http.NewServeMux()
static, _ := fs.Sub(staticFiles, "static")
mux.Handle("GET /static/", http.StripPrefix("/static/", cacheStatic(http.FileServerFS(static))))
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/static/favicon.svg", http.StatusMovedPermanently)
})
mux.HandleFunc("GET /{$}", s.page(s.home))
mux.HandleFunc("GET /signup", s.page(s.signupForm))
mux.HandleFunc("POST /signup", s.page(s.signup))
mux.HandleFunc("GET /signin", s.page(s.signinForm))
mux.HandleFunc("POST /signin", s.page(s.signin))
mux.HandleFunc("POST /signout", s.page(s.signout))
mux.HandleFunc("GET /online", s.page(s.online))
mux.HandleFunc("GET /online/list", s.page(s.onlineList))
mux.HandleFunc("GET /account", s.page(s.signedIn(s.account)))
mux.HandleFunc("POST /account/link", s.page(s.signedIn(s.startLink)))
mux.HandleFunc("POST /account/link/{imvu}/check", s.page(s.signedIn(s.checkLink)))
mux.HandleFunc("POST /account/link/{imvu}/cancel", s.page(s.signedIn(s.cancelLink)))
mux.HandleFunc("POST /account/links/{imvu}/settings", s.page(s.signedIn(s.linkSettings)))
mux.HandleFunc("POST /account/links/{imvu}/recheck", s.page(s.signedIn(s.recheckLink)))
mux.HandleFunc("POST /account/links/{imvu}/unlink", s.page(s.signedIn(s.unlink)))
mux.HandleFunc("POST /account/proxies/signout", s.page(s.signedIn(s.signOutProxies)))
mux.HandleFunc("GET /device", s.page(s.signedIn(s.deviceForm)))
mux.HandleFunc("POST /device", s.page(s.signedIn(s.approveDevice)))
mux.HandleFunc("GET /console", s.page(s.moderator(s.console)))
mux.HandleFunc("GET /console/online", s.page(s.moderator(s.consoleOnline)))
mux.HandleFunc("GET /console/people", s.page(s.moderator(s.consolePeople)))
mux.HandleFunc("GET /console/people/{id}", s.page(s.moderator(s.consolePerson)))
mux.HandleFunc("POST /console/people/{id}/cap", s.page(s.moderator(s.setCap)))
mux.HandleFunc("POST /console/people/{id}/ban", s.page(s.moderator(s.ban)))
mux.HandleFunc("POST /console/people/{id}/unban", s.page(s.moderator(s.unban)))
mux.HandleFunc("POST /console/people/{id}/role", s.page(s.admin(s.setRole)))
mux.HandleFunc("GET /.well-known/jwks.json", s.jwks)
mux.HandleFunc("POST /api/v1/device/code", s.deviceCode)
mux.HandleFunc("POST /api/v1/device/token", s.deviceToken)
mux.HandleFunc("POST /api/v1/token/refresh", s.refresh)
mux.HandleFunc("GET /api/v1/me", s.me)
mux.HandleFunc("GET /api/v1/realtime", s.realtime)
return securityHeaders(mux), nil
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' https: data:; style-src 'self'; script-src 'self'; "+
"connect-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'none'; object-src 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
func cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=3600")
next.ServeHTTP(w, r)
})
}
// request is one page request: who's asking, and what the page shows.
type request struct {
w http.ResponseWriter
r *http.Request
person *store.Person // nil when signed out
csrf string
token string // session token
flash string
}
type pageFunc func(q *request) error
// errStatus is an error with the HTTP status to answer with, shown to the person.
type errStatus struct {
status int
message string
}
func (e errStatus) Error() string { return e.message }
func fail(status int, message string) error { return errStatus{status, message} }
const sessionCookie = "t5de_session"
// page wraps a page handler: it finds the session, checks CSRF on POSTs, and shows errors.
func (s *Server) page(fn pageFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := &request{w: w, r: r, flash: r.URL.Query().Get("done")}
if c, err := r.Cookie(sessionCookie); err == nil {
p, csrf, err := s.Store.Session(r.Context(), c.Value)
if err == nil && !p.Banned() {
q.person, q.csrf, q.token = p, csrf, c.Value
}
}
if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
sent := r.Header.Get("X-CSRF-Token")
if sent == "" {
sent = r.PostFormValue("csrf")
}
// Signed in, a form must carry the session's token. Signed out (sign-in and sign-up), browsers' same-site
// checks apply: the form must come from the hub itself.
if q.person != nil && subtle.ConstantTimeCompare([]byte(sent), []byte(q.csrf)) != 1 ||
q.person == nil && !sameOrigin(r) {
s.showError(q, fail(http.StatusForbidden, "That form expired. Reload the page and try again."))
return
}
}
if err := fn(q); err != nil {
s.showError(q, err)
}
}
}
func sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" || site == "none"
}
origin := r.Header.Get("Origin")
return origin == "" || strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://") == r.Host
}
func (s *Server) signedIn(fn pageFunc) pageFunc {
return func(q *request) error {
if q.person == nil {
http.Redirect(q.w, q.r, "/signin?next="+q.r.URL.RequestURI(), http.StatusSeeOther)
return nil
}
return fn(q)
}
}
func (s *Server) moderator(fn pageFunc) pageFunc {
return s.signedIn(func(q *request) error {
if !q.person.IsModerator() {
return fail(http.StatusForbidden, "Only moderators can see this.")
}
return fn(q)
})
}
func (s *Server) admin(fn pageFunc) pageFunc {
return s.signedIn(func(q *request) error {
if !q.person.IsAdmin() {
return fail(http.StatusForbidden, "Only admins can do this.")
}
return fn(q)
})
}
func (s *Server) showError(q *request, err error) {
var e errStatus
if !errors.As(err, &e) {
s.Log.Printf("%s %s: %v", q.r.Method, q.r.URL.Path, err)
e = errStatus{http.StatusInternalServerError, "Something went wrong on our side. Try again in a moment."}
}
q.w.WriteHeader(e.status)
s.render(q, "error", map[string]any{"Message": e.message})
}
// redirect sends the browser on after a form, with a short note to show.
func (q *request) redirect(path, done string) error {
if done != "" {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
path += sep + "done=" + template.URLQueryEscaper(done)
}
http.Redirect(q.w, q.r, path, http.StatusSeeOther)
return nil
}
func clientIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// limiter allows a number of attempts per key in a window (sign-ins, sign-ups, IMVU lookups).
type limiter struct {
mu sync.Mutex
hits map[string][]time.Time
}
func (l *limiter) allow(key string, n int, window time.Duration) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
recent := l.hits[key][:0]
for _, t := range l.hits[key] {
if now.Sub(t) < window {
recent = append(recent, t)
}
}
if len(recent) >= n {
l.hits[key] = recent
return false
}
l.hits[key] = append(recent, now)
if len(l.hits) > 100000 { // forget everything rather than grow without end
l.hits = map[string][]time.Time{}
}
return true
}
func (s *Server) ctx(q *request) context.Context { return q.r.Context() }
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ffe795"/><stop offset="1" stop-color="#ffd200"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#g)"/><text x="32" y="43" font-family="Segoe UI, Arial, sans-serif" font-size="27" font-weight="800" text-anchor="middle" fill="#000">T5</text></svg>

Before

Width:  |  Height:  |  Size: 416 B

-1
View File
@@ -1 +0,0 @@
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toStrinLine truncated
-173
View File
@@ -1,173 +0,0 @@
/* T5DE Hub: IMVU Classic's dialog palette (dark grays, gold buttons with black text), always dark. */
:root {
--bg: #262626;
--panel: #383838;
--panel-2: #303030;
--line: #4a4a4a;
--text: #ededed;
--muted: #a8a8a8;
--gold: #ffd200;
--gold-light: #ffe795;
--green: #6fd36f;
--red: #ff7b72;
--blue: #7cc4ff;
--radius: 8px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body {
margin: 0;
color: var(--text);
font: 15px/1.5 "Segoe UI", system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, sans-serif;
}
a { color: var(--gold); text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 960px; margin: 0 auto; padding: 0 20px; }
/* Header */
.top {
background: linear-gradient(#3a3a3a, #2c2c2c);
border-bottom: 1px solid #000;
box-shadow: 0 1px 0 #4a4a4a inset, 0 2px 8px rgba(0, 0, 0, .4);
}
.top .wrap { display: flex; align-items: center; gap: 24px; height: 56px; }
.brand { color: var(--text); font-weight: 600; font-size: 17px; display: flex; align-items: center; gap: 8px; }
.brand:hover { text-decoration: none; }
.logo {
background: linear-gradient(var(--gold-light), var(--gold));
color: #000; font-weight: 800; letter-spacing: .5px;
padding: 2px 8px; border-radius: 5px; font-size: 15px;
}
nav { display: flex; gap: 4px; align-items: center; flex: 1; }
nav a { color: var(--text); padding: 6px 12px; border-radius: 6px; }
nav a:hover, nav a.here { background: #4a4a4a; text-decoration: none; }
nav .right { margin-left: auto; display: flex; gap: 8px; align-items: center; }
nav form { margin: 0; }
main { padding-top: 28px; padding-bottom: 40px; min-height: 70vh; }
footer { padding: 24px 20px 40px; font-size: 13px; }
h1 { font-size: 26px; margin: 0 0 6px; font-weight: 600; }
h2 { font-size: 18px; margin: 0 0 12px; font-weight: 600; }
p.lead { color: var(--muted); margin: 0 0 24px; font-size: 16px; }
.muted { color: var(--muted); }
.small { font-size: 13px; }
/* Panels */
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
}
.panel.narrow { max-width: 420px; margin-left: auto; margin-right: auto; }
.panel h2 .count { color: var(--muted); font-weight: 400; font-size: 14px; margin-left: 6px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; }
.stat { font-size: 30px; font-weight: 600; }
/* Buttons and forms */
.button, button {
display: inline-block;
font: inherit; font-weight: 600; font-size: 14px;
padding: 7px 16px;
border-radius: 6px;
border: 1px solid #b38f00;
background: linear-gradient(var(--gold-light), var(--gold));
color: #000;
cursor: pointer;
}
.button:hover, button:hover { filter: brightness(1.06); text-decoration: none; }
button.secondary, .button.secondary {
background: linear-gradient(#616161, #3e3e3e);
border-color: #2a2a2a;
color: var(--text);
}
button.danger { background: linear-gradient(#b8443c, #8c2a24); border-color: #5c1a16; color: #fff; }
button.link { background: none; border: none; color: var(--gold); padding: 0; font-weight: 400; }
label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 4px; }
input[type=text], input[type=password], input[type=number], input[type=search], textarea, select {
width: 100%;
font: inherit;
padding: 8px 10px;
color: var(--text);
background: #262626;
border: 1px solid #555;
border-radius: 6px;
}
input:focus, textarea:focus, select:focus { outline: 2px solid var(--gold); outline-offset: -1px; border-color: transparent; }
.inline { display: flex; gap: 8px; align-items: center; }
.inline input { flex: 1; }
.actions { display: flex; gap: 8px; margin-top: 18px; align-items: center; flex-wrap: wrap; }
.check { display: flex; gap: 8px; align-items: center; color: var(--text); font-size: 14px; margin: 8px 0; }
/* Messages */
.flash, .error {
padding: 10px 14px; border-radius: 6px; margin-bottom: 20px;
}
.flash { background: #2f3d2f; border: 1px solid #4b6b4b; }
.error { background: #4a2a28; border: 1px solid #7a3b36; }
/* Linked accounts */
.account-row {
display: flex; gap: 16px; align-items: flex-start;
padding: 16px 0; border-top: 1px solid var(--line);
}
.account-row:first-of-type { border-top: none; padding-top: 4px; }
.avatar {
width: 44px; height: 44px; border-radius: 50%; flex: none;
background: linear-gradient(135deg, #5a5a5a, #3a3a3a);
display: flex; align-items: center; justify-content: center;
font-weight: 700; color: var(--gold);
border: 1px solid #222;
}
.account-main { flex: 1; min-width: 0; }
.account-name { font-weight: 600; font-size: 16px; }
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }
.tag {
font-size: 12px; padding: 1px 8px; border-radius: 999px;
background: #2a2a2a; border: 1px solid #555; color: var(--muted);
}
.tag.ok { color: var(--green); border-color: #3f6b3f; }
.tag.adult { color: var(--blue); border-color: #3b5f7a; }
.tag.online { color: #000; background: var(--green); border-color: var(--green); }
.tag.gold { color: #000; background: var(--gold); border-color: var(--gold); }
.tag.warn { color: var(--red); border-color: #7a3b36; }
.settings { display: flex; gap: 18px; flex-wrap: wrap; align-items: center; margin-top: 4px; }
.settings form { display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin: 0; }
.row-actions { display: flex; gap: 8px; flex: none; }
.row-actions form { margin: 0; }
.code {
font: 600 22px/1.2 ui-monospace, "Cascadia Mono", Consolas, monospace;
letter-spacing: 1px;
background: #222; border: 1px dashed var(--gold); color: var(--gold);
padding: 10px 14px; border-radius: 6px; display: inline-block; margin: 6px 0;
user-select: all;
}
ol.steps { padding-left: 20px; margin: 8px 0 0; }
ol.steps li { margin: 6px 0; }
.outfit { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
.item { font-size: 12px; padding: 2px 8px; border-radius: 4px; background: #2a2a2a; border: 1px solid #555; }
/* Tables */
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; color: var(--muted); font-weight: 400; padding: 6px 8px; border-bottom: 1px solid var(--line); }
td { padding: 8px; border-bottom: 1px solid #424242; vertical-align: top; }
tr:last-child td { border-bottom: none; }
.empty { color: var(--muted); padding: 12px 0; }
.hero { text-align: center; padding: 30px 0 34px; }
.hero h1 { font-size: 34px; }
.hero .actions { justify-content: center; }
-82
View File
@@ -1,82 +0,0 @@
{{define "title"}}Account{{end}}
{{define "content"}}
<h1>Your account</h1>
<p class="lead">Signed in as {{.Person.Username}}{{if .Person.IsModerator}} <span class="tag gold">{{.Person.Role}}</span>{{end}}</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<div class="panel">
<h2>IMVU accounts <span class="count">{{len .Links}} of {{.Person.LinkCap}}</span></h2>
{{range .Links}}
<div class="account-row">
<div class="avatar">{{slice .Username 0 1 | upper}}</div>
<div class="account-main">
<div class="account-name">{{.Username}}</div>
<div class="tags">
{{if .Online}}<span class="tag online">online in T5DE</span>{{end}}
{{if .AgeVerified}}<span class="tag adult">age verified</span>{{else if .AccessPass}}<span class="tag adult">Access Pass</span>{{else}}<span class="tag">not age verified</span>{{end}}
<span class="tag">checked {{ago .CheckedAt}}</span>
</div>
{{if .Outfit}}
<div class="small muted">Wearing:</div>
<div class="outfit">{{range .Outfit}}<span class="item">{{.Name}}</span>{{end}}</div>
{{end}}
<div class="settings">
<form method="post" action="/account/links/{{.IMVUID}}/settings">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<label class="check"><input type="checkbox" name="badge" {{if .ShowBadge}}checked{{end}}> Show T5DE badge</label>
<label class="check"><input type="checkbox" name="presence" {{if .ShowPresence}}checked{{end}}> Show when I'm online in T5DE</label>
<button class="secondary">Save</button>
</form>
</div>
</div>
<div class="row-actions">
<form method="post" action="/account/links/{{.IMVUID}}/recheck"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="secondary" title="Check age verification with IMVU again">Check again</button></form>
<form method="post" action="/account/links/{{.IMVUID}}/unlink"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="secondary">Unlink</button></form>
</div>
</div>
{{else}}
<p class="empty">No IMVU accounts linked yet.</p>
{{end}}
</div>
{{range .Challenges}}
<div class="panel" id="link-{{.IMVUID}}">
<h2>Link {{.Username}}</h2>
<p>To prove {{.Username}} is yours:</p>
<ol class="steps">
<li>Put this code anywhere in {{.Username}}'s IMVU tagline (on your avatar card in the client, or your profile):<br>
<span class="code">{{.Code}}</span></li>
<li>Press Check. You can take the code out again once it's linked.</li>
</ol>
<div class="actions">
<form method="post" action="/account/link/{{.IMVUID}}/check"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button>Check</button></form>
<form method="post" action="/account/link/{{.IMVUID}}/cancel"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="secondary">Cancel</button></form>
<span class="muted small">The code works for a day.</span>
</div>
</div>
{{end}}
{{if .CanLink}}
<div class="panel">
<h2>Link an IMVU account</h2>
<form method="post" action="/account/link" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="username" placeholder="IMVU username" required maxlength="64">
<button>Next</button>
</form>
<p class="muted small">Other people can't see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass)
can see adult community products; each account is checked on its own.</p>
</div>
{{else}}
<div class="panel"><p class="muted">You've linked as many IMVU accounts as you can ({{.Person.LinkCap}}). Ask a moderator if you need more.</p></div>
{{end}}
<div class="panel">
<h2>T5DE on your computers</h2>
<p class="muted">{{if .Signins}}Signed in on {{.Signins}} computer{{if ne .Signins 1}}s{{end}}.{{else}}Not signed in on any computer yet.{{end}}
To sign T5DE in, it shows a code to enter at <a href="/device">/device</a>.</p>
{{if .Signins}}
<form method="post" action="/account/proxies/signout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Sign out everywhere</button></form>
{{end}}
</div>
{{end}}
-45
View File
@@ -1,45 +0,0 @@
{{define "title"}}Console{{end}}
{{define "content"}}
<h1>Console</h1>
<p class="lead">Moderation. <a href="/console/people">People</a></p>
<div class="grid">
<div class="panel"><h2>People</h2><div class="stat">{{.Counts.People}}</div><p class="muted small">accounts</p></div>
<div class="panel"><h2>IMVU accounts</h2><div class="stat">{{.Counts.Links}}</div><p class="muted small">linked</p></div>
<div class="panel"><h2>Computers</h2><div class="stat">{{.Counts.Signins}}</div><p class="muted small">signed in</p></div>
</div>
<div class="panel" hx-get="/console/online" hx-trigger="every 5s" hx-swap="innerHTML">
{{template "console-online" .}}
</div>
<div class="panel">
<h2>Audit log</h2>
<table>
<tr><th>When</th><th>Who</th><th>What</th><th>Target</th><th>Detail</th></tr>
{{range .Audit}}
<tr><td class="muted">{{date .At}}</td><td>{{if .Actor}}{{.Actor}}{{else}}<span class="muted">hub</span>{{end}}</td><td>{{.Action}}</td><td>{{.Target}}</td>
<td class="muted small">{{range $k, $v := .Detail}}{{$k}}: {{$v}} {{end}}</td></tr>
{{else}}
<tr><td colspan="5" class="empty">Nothing yet.</td></tr>
{{end}}
</table>
</div>
{{end}}
{{define "console-online"}}
<h2>Online now <span class="count">{{len .Online}} IMVU accounts, {{.Connections}} connections</span></h2>
{{range .Online}}
<div class="account-row">
<div class="avatar">{{slice .Link.Username 0 1 | upper}}</div>
<div class="account-main">
<div class="account-name">{{.Link.Username}} <a class="small" href="/console/people/{{.Online.PersonID}}">person</a></div>
<div class="tags">
<span class="tag online">since {{ago .Online.Since}}</span>
{{if .Hidden}}<span class="tag">presence hidden</span>{{end}}
{{if .Link.Adult}}<span class="tag adult">adult</span>{{end}}
</div>
{{if .Online.Outfit}}<div class="outfit">{{range .Online.Outfit}}<span class="item">{{.Name}}</span>{{end}}</div>{{end}}
</div>
</div>
{{else}}
<p class="empty">Nobody.</p>
{{end}}
{{end}}
@@ -1,25 +0,0 @@
{{define "title"}}People{{end}}
{{define "content"}}
<h1>People</h1>
<p class="lead"><a href="/console">Console</a></p>
<div class="panel">
<form method="get" action="/console/people" class="inline">
<input type="search" name="q" value="{{.Search}}" placeholder="T5DE or IMVU username">
<button class="secondary">Search</button>
</form>
</div>
<div class="panel">
<table>
<tr><th>Username</th><th>Role</th><th>Link cap</th><th>Joined</th><th></th></tr>
{{range .People}}
<tr>
<td><a href="/console/people/{{.ID}}">{{.Username}}</a></td>
<td>{{.Role}}</td><td>{{.LinkCap}}</td><td class="muted">{{date .CreatedAt}}</td>
<td>{{if .Banned}}<span class="tag warn">banned</span>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="empty">Nobody found.</td></tr>
{{end}}
</table>
</div>
{{end}}
@@ -1,61 +0,0 @@
{{define "title"}}{{.Subject.Username}}{{end}}
{{define "content"}}
<h1>{{.Subject.Username}}</h1>
<p class="lead"><a href="/console/people">People</a> · {{.Subject.Role}} · joined {{date .Subject.CreatedAt}}
{{if .Subject.Banned}}<span class="tag warn">banned: {{.Subject.BanReason}}</span>{{end}}</p>
<div class="panel">
<h2>IMVU accounts <span class="count">{{len .Links}} of {{.Subject.LinkCap}}</span></h2>
<p class="muted small">Only moderators see which IMVU accounts belong to one person.</p>
<table>
<tr><th>IMVU account</th><th>ID</th><th>Linked</th><th>Age</th><th>Shows</th></tr>
{{range .Links}}
<tr><td>{{.Username}}</td><td class="muted">{{.IMVUID}}</td><td class="muted">{{date .LinkedAt}}</td>
<td>{{if .AgeVerified}}verified{{else if .AccessPass}}Access Pass{{else}}<span class="muted">no</span>{{end}}</td>
<td class="small">{{if .ShowBadge}}badge {{end}}{{if .ShowPresence}}presence{{end}}</td></tr>
{{else}}
<tr><td colspan="5" class="empty">None.</td></tr>
{{end}}
</table>
<p class="muted small">Signed in on {{.Signins}} computer{{if ne .Signins 1}}s{{end}}.</p>
</div>
<div class="grid">
<div class="panel">
<h2>Link cap</h2>
<form method="post" action="/console/people/{{.Subject.ID}}/cap" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="number" name="cap" value="{{.Subject.LinkCap}}" min="0" max="1000">
<button>Set</button>
</form>
<p class="muted small">How many IMVU accounts they can link (normally 10).</p>
</div>
<div class="panel">
<h2>Ban</h2>
{{if .Subject.Banned}}
<form method="post" action="/console/people/{{.Subject.ID}}/unban"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Unban</button></form>
{{else}}
<form method="post" action="/console/people/{{.Subject.ID}}/ban">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="reason" placeholder="Reason (they see it)" required maxlength="500">
<div class="actions"><button class="danger">Ban</button></div>
</form>
<p class="muted small">Covers all their IMVU accounts, and signs them out everywhere.</p>
{{end}}
</div>
{{if .Person.IsAdmin}}
<div class="panel">
<h2>Role</h2>
<form method="post" action="/console/people/{{.Subject.ID}}/role" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<select name="role">
<option value="user"{{if eq .Subject.Role "user"}} selected{{end}}>User</option>
<option value="moderator"{{if eq .Subject.Role "moderator"}} selected{{end}}>Moderator</option>
<option value="admin"{{if eq .Subject.Role "admin"}} selected{{end}}>Admin</option>
</select>
<button>Set</button>
</form>
</div>
{{end}}
</div>
{{end}}
-20
View File
@@ -1,20 +0,0 @@
{{define "title"}}Sign T5DE in{{end}}
{{define "content"}}
<div class="panel narrow">
{{if .Approved}}
<h1>T5DE is signed in</h1>
<p class="muted">You can close this page and go back to T5DE.</p>
{{else}}
<h1>Sign T5DE in</h1>
<p class="muted">Enter the code T5DE shows, to sign it in as {{.Person.Username}}.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="post" action="/device">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="code">Code</label>
<input type="text" id="code" name="code" value="{{.Code}}" placeholder="XXXX-XXXX" required autofocus autocomplete="off">
<div class="actions"><button>Sign in</button></div>
</form>
<p class="muted small">Only enter a code you got from T5DE yourself.</p>
{{end}}
</div>
{{end}}
-8
View File
@@ -1,8 +0,0 @@
{{define "title"}}Sorry{{end}}
{{define "content"}}
<div class="panel narrow">
<h1>Sorry</h1>
<p>{{.Message}}</p>
<div class="actions"><a class="button secondary" href="/">Home</a></div>
</div>
{{end}}
-33
View File
@@ -1,33 +0,0 @@
{{define "title"}}Home{{end}}
{{define "content"}}
<div class="hero">
<h1>T5DE Hub</h1>
<p class="lead">Your T5DE account: link your IMVU accounts, see who's online in T5DE, and soon, community products.</p>
{{if not .Person}}
<div class="actions">
<a class="button" href="/signup">Create account</a>
<a class="button secondary" href="/signin">Sign in</a>
</div>
{{end}}
</div>
<div class="grid">
<div class="panel">
<h2>Online in T5DE</h2>
<div class="stat">{{.OnlineCount}}</div>
<p class="muted small">people showing that they're online. <a href="/online">See who</a></p>
</div>
{{if .Person}}
<div class="panel">
<h2>Your IMVU accounts</h2>
<div class="stat">{{len .Links}}</div>
<p class="muted small">linked. <a href="/account">Manage</a></p>
</div>
{{else}}
<div class="panel">
<h2>One account, all your avatars</h2>
<p class="muted small">Link any of your IMVU accounts by putting a code in its tagline. Your accounts stay private:
nobody else can see they're yours.</p>
</div>
{{end}}
</div>
{{end}}
-40
View File
@@ -1,40 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{template "title" .}} · T5DE Hub</title>
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
</head>
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}'>
<header class="top">
<div class="wrap">
<a class="brand" href="/"><span class="logo">T5DE</span> Hub</a>
<nav>
<a href="/online"{{if eq .Page "online"}} class="here"{{end}}>Online</a>
{{if .Person}}
<a href="/account"{{if eq .Page "account"}} class="here"{{end}}>Account</a>
{{if .Person.IsModerator}}<a href="/console"{{if eq .Page "console"}} class="here"{{end}}>Console</a>{{end}}
<span class="right">
<span class="muted small">{{.Person.Username}}</span>
<form method="post" action="/signout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Sign out</button></form>
</span>
{{else}}
<span class="right">
<a href="/signin">Sign in</a>
<a class="button" href="/signup">Create account</a>
</span>
{{end}}
</nav>
</div>
</header>
<main class="wrap">
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{template "content" .}}
</main>
<footer class="wrap muted">T5DE is unofficial: not made by or affiliated with IMVU.</footer>
</body>
</html>
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more