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]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-10-29 05:03:54 -07:00
1 parent 64c552330b
commit 7a06ba49c3
59 files changed
+2640 -3523

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"]
}
+1 -2
View File
@@ -38,8 +38,7 @@ export async function unban(_: ActionState, form: FormData): Promise<ActionState
export async function setRole(_: ActionState, form: FormData): Promise<ActionState> { export async function setRole(_: ActionState, form: FormData): Promise<ActionState> {
return onPerson(form, async (id) => { return onPerson(form, async (id) => {
const role = roles.find((r) => r === field(form, "role")) ?? "user"; const role = roles.find((r) => r === field(form, "role")) ?? "user";
// The API's schema builds its union from an array, which its type reads as never; the value is checked above. const { person } = unwrap(await api.console.people({ id }).role.post({ role }));
const { person } = unwrap(await api.console.people({ id }).role.post({ role: role as never }));
return `Role set to ${person.role}.`; return `Role set to ${person.role}.`;
}); });
} }
+17
View File
@@ -4,6 +4,9 @@
"workspaces": { "workspaces": {
"": { "": {
"name": "t5de-hub", "name": "t5de-hub",
"devDependencies": {
"turbo": "2.11.10",
},
}, },
"apps/api": { "apps/api": {
"name": "@t5de/api", "name": "@t5de/api",
@@ -368,6 +371,18 @@
"@ts-morph/common": ["@ts-morph/[email protected]", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@ts-morph/common": ["@ts-morph/[email protected]", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
"@turbo/darwin-64": ["@turbo/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-By4idRap5+nLnMuRzLB4qkOPRlvNdyY1ne4VJYDyUoUB11H94k7uif9mbe3JT8JSNXs1D68uMcBj6hJ/jMQRvA=="],
"@turbo/darwin-arm64": ["@turbo/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pyEYlVcuexOPa92p0Wlwl9YT1Is5D60lxN3ItWm3g30YLvFaUq86TF4BBWg5ZAc0uMTnrOzCvuCsOBTdzUWnRQ=="],
"@turbo/linux-64": ["@turbo/[email protected]", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-1tYeR/IDZ5HEdsb0jNq2L2xam5nMQ+g80tlV2BZmv4OFw+pMcUM0mMmUcfc5wXPPNHVNPuDMjh9Y8G8HgXPLZA=="],
"@turbo/linux-arm64": ["@turbo/[email protected]", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-HuekQxvVGhUd46g1Im/DKdOtglHr4/gguZAvzEpPUgtxNkztSa8lWhQEGQ+EmPQ0n7w19qjThFU+OfEEd0uvlw=="],
"@turbo/windows-64": ["@turbo/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-UJOP3BkbreY3/JlMgJR6ZiimxYIBn+c+B4tnBIxD0E9c3Estmk+XoVNVBNBottZLNRXe3/ScgOU4+/k8tIWysg=="],
"@turbo/windows-arm64": ["@turbo/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-xOkEjF3ws/b/YNlc+bvJHMYBn+vL+QbEISZFVi0qS+ImmhA1X3/gmNmBrjjIG3dg6aeN+n6gQitzSkXQ286qJQ=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="], "@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], "@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
@@ -820,6 +835,8 @@
"tsx": ["[email protected]", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="], "tsx": ["[email protected]", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="],
"turbo": ["[email protected]", "", { "optionalDependencies": { "@turbo/darwin-64": "2.11.10", "@turbo/darwin-arm64": "2.11.10", "@turbo/linux-64": "2.11.10", "@turbo/linux-arm64": "2.11.10", "@turbo/windows-64": "2.11.10", "@turbo/windows-arm64": "2.11.10" }, "bin": { "turbo": "bin/turbo" } }, "sha512-3GJYwig8ygEI89oaOZdduJX8/aw6O9eRPYHoIt6T3FZl9hkKjiyIspufJoVgqXihPoJu8M5CSV0DlZvzfohBCw=="],
"tw-animate-css": ["[email protected]", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "tw-animate-css": ["[email protected]", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-fest": ["[email protected]", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["[email protected]", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
-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>
-27
View File
@@ -1,27 +0,0 @@
{{define "title"}}Online{{end}}
{{define "content"}}
<h1>Online in T5DE</h1>
<p class="lead">IMVU accounts in a T5DE client right now, that chose to show it.</p>
<div class="panel" hx-get="/online/list" hx-trigger="every 5s" hx-swap="innerHTML">
{{template "online-list" .}}
</div>
{{end}}
{{define "online-list"}}
{{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}}</div>
<div class="tags">
{{if .Link.ShowBadge}}<span class="tag gold">T5DE</span>{{end}}
<span class="tag online">online {{ago .Online.Since}}</span>
{{if .Hidden}}<span class="tag warn">hidden: moderators only</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 is showing that they're online right now.</p>
{{end}}
{{end}}
-19
View File
@@ -1,19 +0,0 @@
{{define "title"}}Sign in{{end}}
{{define "content"}}
<div class="panel narrow">
<h1>Sign in</h1>
<p class="muted">to your T5DE account.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="post" action="/signin">
<input type="hidden" name="next" value="{{.Next}}">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
<div class="actions">
<button>Sign in</button>
<span class="muted small">No account? <a href="/signup">Create one</a></span>
</div>
</form>
</div>
{{end}}
-22
View File
@@ -1,22 +0,0 @@
{{define "title"}}Create account{{end}}
{{define "content"}}
<div class="panel narrow">
<h1>Create account</h1>
<p class="muted">One T5DE account for all your IMVU accounts.</p>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
<form method="post" action="/signup">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" required autofocus
pattern="[A-Za-z0-9_]{3,32}" title="3 to 32 letters, digits or underscores">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="new-password" required minlength="10">
<label for="password2">Password again</label>
<input type="password" id="password2" name="password2" autocomplete="new-password" required minlength="10">
<p class="muted small">Not your IMVU password: use a new one.</p>
<div class="actions">
<button>Create account</button>
<span class="muted small">Have one? <a href="/signin">Sign in</a></span>
</div>
</form>
</div>
{{end}}
-416
View File
@@ -1,416 +0,0 @@
package web
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/jackc/pgx/v5"
"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"
)
// The tests run the whole hub against a real Postgres (embedded, downloaded once) and a fake IMVU API.
var dbURL string
func TestMain(m *testing.M) {
// CI gives a Postgres of its own (embedded Postgres won't run as root, as CI jobs do).
if url := os.Getenv("T5DE_TEST_DB"); url != "" {
dbURL = url
os.Exit(m.Run())
}
dir, _ := os.MkdirTemp("", "t5de-hub-test")
cache := os.Getenv("T5DE_PG_CACHE")
if cache == "" {
home, _ := os.UserCacheDir()
cache = filepath.Join(home, "t5de-hub-test-postgres")
}
const port = 54339
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(port).
DataPath(filepath.Join(dir, "data")).RuntimePath(filepath.Join(dir, "rt")).CachePath(cache).
Logger(io.Discard).StartTimeout(2 * time.Minute))
if err := pg.Start(); err != nil {
fmt.Println("starting Postgres:", err)
os.Exit(1)
}
dbURL = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port)
code := m.Run()
pg.Stop()
os.RemoveAll(dir)
os.Exit(code)
}
// fakeIMVU is IMVU's public user API, with accounts the test controls.
type fakeIMVU struct {
mu sync.Mutex
users map[int64]*imvu.User
}
func (f *fakeIMVU) set(u imvu.User) {
f.mu.Lock()
defer f.mu.Unlock()
f.users[u.ID] = &u
}
func (f *fakeIMVU) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
var found *imvu.User
for _, u := range f.users {
if r.URL.Path == fmt.Sprintf("/user/user-%d", u.ID) || r.URL.Path == "/user" && strings.EqualFold(r.URL.Query().Get("username"), u.Username) {
found = u
}
}
den := map[string]any{}
if found != nil {
den[fmt.Sprintf("https://api.imvu.com/user/user-%d", found.ID)] = map[string]any{"data": map[string]any{
"legacy_cid": found.ID, "username": found.Username, "tagline": found.Tagline,
"is_ageverified": found.AgeVerified, "is_ap": found.AccessPass}}
}
json.NewEncoder(w).Encode(map[string]any{"status": "success", "id": r.URL.String(), "denormalized": den})
}
type hub struct {
t *testing.T
server *Server
http *httptest.Server
imvu *fakeIMVU
}
// newHub runs a hub on a fresh database.
func newHub(t *testing.T) *hub {
ctx := context.Background()
admin, err := pgx.Connect(ctx, dbURL)
if err != nil {
t.Fatal(err)
}
db := "t" + strings.ToLower(token.Random(6))
db = regexp.MustCompile(`[^a-z0-9]`).ReplaceAllString(db, "x")
if _, err := admin.Exec(ctx, "create database "+db); err != nil {
t.Fatal(err)
}
admin.Close(ctx)
u, _ := url.Parse(dbURL)
u.Path = "/" + db
st, err := store.Open(ctx, u.String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(st.Close)
fake := &fakeIMVU{users: map[int64]*imvu.User{}}
imvuServer := httptest.NewServer(fake)
t.Cleanup(imvuServer.Close)
keys, err := token.LoadOrCreate(filepath.Join(t.TempDir(), "key"), "http://hub.test")
if err != nil {
t.Fatal(err)
}
s := &Server{Store: st, IMVU: &imvu.Client{Base: imvuServer.URL, HTTP: imvuServer.Client()}, Keys: keys,
Log: log.New(io.Discard, "", 0)}
s.Realtime = realtime.New(Owners{Store: st}, s.Log)
handler, err := s.Handler()
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
s.PublicURL = srv.URL
return &hub{t: t, server: s, http: srv, imvu: fake}
}
// browser is a person on the website.
type browser struct {
h *hub
client *http.Client
csrf string
}
func (h *hub) browser() *browser {
jar, _ := cookiejar.New(nil)
return &browser{h: h, client: &http.Client{Jar: jar}}
}
var csrfPattern = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
func (b *browser) get(path string) (int, string) {
b.h.t.Helper()
resp, err := b.client.Get(b.h.http.URL + path)
if err != nil {
b.h.t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if m := csrfPattern.FindSubmatch(body); m != nil {
b.csrf = string(m[1])
}
return resp.StatusCode, string(body)
}
func (b *browser) post(path string, form url.Values) (int, string) {
b.h.t.Helper()
if form == nil {
form = url.Values{}
}
if b.csrf != "" && form.Get("csrf") == "" {
form.Set("csrf", b.csrf)
}
req, _ := http.NewRequest("POST", b.h.http.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := b.client.Do(req)
if err != nil {
b.h.t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if m := csrfPattern.FindSubmatch(body); m != nil {
b.csrf = string(m[1])
}
return resp.StatusCode, string(body)
}
func (b *browser) signup(username string) {
b.h.t.Helper()
status, body := b.post("/signup", url.Values{"username": {username}, "password": {"a long password"}, "password2": {"a long password"}})
if status != 200 || !strings.Contains(body, "Signed in as "+username) {
b.h.t.Fatalf("signup %s: %d %s", username, status, body)
}
}
var codePattern = regexp.MustCompile(`t5de-[A-Z0-9]{4}-[A-Z0-9]{4}`)
// link links an IMVU account through the website, putting the code in its fake tagline.
func (b *browser) link(u imvu.User) string {
b.h.t.Helper()
b.h.imvu.set(u)
_, body := b.post("/account/link", url.Values{"username": {u.Username}})
code := codePattern.FindString(body)
if code == "" {
b.h.t.Fatalf("no code: %s", body)
}
u.Tagline = "hello " + strings.ToLower(code)
b.h.imvu.set(u)
_, body = b.post(fmt.Sprintf("/account/link/%d/check", u.ID), nil)
return body
}
func TestSignupSigninAndCSRF(t *testing.T) {
h := newHub(t)
a := h.browser()
a.signup("alice")
// The first account is the admin.
if _, body := a.get("/account"); !strings.Contains(body, ">admin<") {
t.Fatal("first account isn't admin")
}
b := h.browser()
b.signup("bob")
if status, _ := b.get("/console"); status != http.StatusForbidden {
t.Fatalf("bob sees the console: %d", status)
}
// Taken usernames (any case), bad passwords.
c := h.browser()
if status, body := c.post("/signup", url.Values{"username": {"ALICE"}, "password": {"a long password"}, "password2": {"a long password"}}); status != 400 || !strings.Contains(body, "taken") {
t.Fatalf("%d %s", status, body)
}
if status, _ := c.post("/signup", url.Values{"username": {"carol"}, "password": {"short"}, "password2": {"short"}}); status != 400 {
t.Fatalf("short password: %d", status)
}
// Signing in, with the right and wrong password.
if status, body := c.post("/signin", url.Values{"username": {"bob"}, "password": {"wrong"}}); status != 400 || !strings.Contains(body, "Wrong") {
t.Fatalf("%d %s", status, body)
}
if _, body := c.post("/signin", url.Values{"username": {"Bob"}, "password": {"a long password"}}); !strings.Contains(body, "Signed in as bob") {
t.Fatal("sign-in failed")
}
// A form without the session's CSRF token is refused.
if status, _ := c.post("/account/link", url.Values{"username": {"x"}, "csrf": {"forged"}}); status != http.StatusForbidden {
t.Fatalf("forged CSRF: %d", status)
}
}
func TestLinkingByTagline(t *testing.T) {
h := newHub(t)
a := h.browser()
a.signup("alice")
// Without the code in the tagline, nothing is linked.
h.imvu.set(imvu.User{ID: 42, Username: "AliceAv"})
a.post("/account/link", url.Values{"username": {"aliceav"}})
if _, body := a.post("/account/link/42/check", nil); !strings.Contains(body, "doesn&#39;t have the code") {
t.Fatalf("linked without the code: %s", body)
}
body := a.link(imvu.User{ID: 42, Username: "AliceAv", AgeVerified: true})
if !strings.Contains(body, "Linked AliceAv") || !strings.Contains(body, "age verified") {
t.Fatalf("%s", body)
}
if _, body := a.post("/account/link", url.Values{"username": {"nobody"}}); !strings.Contains(body, "IMVU has no account") {
t.Fatalf("%s", body)
}
// Bob proves the same account is his (it was sold, say): it moves to him, with a new pairwise ID.
before, _ := h.server.Store.LinkByIMVUID(context.Background(), 42)
b := h.browser()
b.signup("bob")
b.link(imvu.User{ID: 42, Username: "AliceAv"})
after, _ := h.server.Store.LinkByIMVUID(context.Background(), 42)
if after.PersonID == before.PersonID || after.PairwiseID == before.PairwiseID {
t.Fatalf("not moved: %+v -> %+v", before, after)
}
if _, body := a.get("/account"); strings.Contains(body, "AliceAv") {
t.Fatal("alice still has the account")
}
// The link cap: bob may link only one more after a moderator lowers it.
bob, _ := h.server.Store.PersonByUsername(context.Background(), "bob")
a.get("/console/people/" + fmt.Sprint(bob.ID))
if status, _ := a.post(fmt.Sprintf("/console/people/%d/cap", bob.ID), url.Values{"cap": {"1"}}); status != 200 {
t.Fatalf("setting the cap: %d", status)
}
if body := b.link(imvu.User{ID: 43, Username: "BobAlt"}); !strings.Contains(body, "link up to 1") {
t.Fatalf("over the cap: %s", body)
}
}
func TestProxySignInAndPresence(t *testing.T) {
h := newHub(t)
a := h.browser()
a.signup("alice")
a.link(imvu.User{ID: 42, Username: "AliceAv"})
// The proxy asks for a code, alice enters it, the proxy gets tokens.
resp, err := http.Post(h.http.URL+"/api/v1/device/code", "", nil)
if err != nil {
t.Fatal(err)
}
var device struct{ DeviceCode, UserCode string }
var raw map[string]any
json.NewDecoder(resp.Body).Decode(&raw)
resp.Body.Close()
device.DeviceCode, device.UserCode = raw["device_code"].(string), raw["user_code"].(string)
poll := func() map[string]any {
resp, err := http.PostForm(h.http.URL+"/api/v1/device/token", url.Values{"device_code": {device.DeviceCode}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return out
}
if out := poll(); out["error"] != "authorization_pending" {
t.Fatalf("%v", out)
}
a.get("/device")
if _, body := a.post("/device", url.Values{"code": {strings.ToLower(strings.ReplaceAll(device.UserCode, "-", ""))}}); !strings.Contains(body, "T5DE is signed in") {
t.Fatalf("%s", body)
}
tokens := poll()
access, _ := tokens["access_token"].(string)
if access == "" || tokens["refresh_token"] == "" {
t.Fatalf("%v", tokens)
}
if out := poll(); out["error"] != "expired_token" {
t.Fatalf("a code worked twice: %v", out)
}
// The refresh token rotates: the old one stops working.
refresh := func(tok string) map[string]any {
resp, err := http.PostForm(h.http.URL+"/api/v1/token/refresh", url.Values{"refresh_token": {tok}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return out
}
next := refresh(tokens["refresh_token"].(string))
if next["access_token"] == nil || refresh(tokens["refresh_token"].(string))["error"] != "invalid_grant" {
t.Fatalf("rotation: %v", next)
}
// The live connection: an account that isn't alice's is refused; hers shows as online (to moderators, and to
// everyone once she opts in) with her community outfit.
ctx := context.Background()
ws, _, err := websocket.Dial(ctx, strings.Replace(h.http.URL, "http", "ws", 1)+"/api/v1/realtime",
&websocket.DialOptions{HTTPHeader: http.Header{"Authorization": {"Bearer " + access}}})
if err != nil {
t.Fatal(err)
}
defer ws.CloseNow()
read := func() map[string]any {
_, data, err := ws.Read(ctx)
if err != nil {
t.Fatal(err)
}
var m map[string]any
json.Unmarshal(data, &m)
return m
}
if m := read(); m["type"] != "welcome" {
t.Fatalf("%v", m)
}
ws.Write(ctx, websocket.MessageText, []byte(`{"type":"session","imvu_id":99}`))
if m := read(); m["message"] != "not_linked" {
t.Fatalf("%v", m)
}
ws.Write(ctx, websocket.MessageText, []byte(`{"type":"session","imvu_id":42}`))
ws.Write(ctx, websocket.MessageText, []byte(`{"type":"outfit","items":[{"id":"blue-coat","name":"Blue Coat"}]}`))
waitFor(t, func() bool { o := h.server.Realtime.Online(); return len(o) == 1 && len(o[0].Outfit) == 1 })
visitor := h.browser()
if _, body := visitor.get("/online"); strings.Contains(body, "AliceAv") {
t.Fatal("online shown without opting in")
}
if _, body := a.get("/console"); !strings.Contains(body, "AliceAv") || !strings.Contains(body, "Blue Coat") {
t.Fatal("the console doesn't show her")
}
a.post("/account/links/42/settings", url.Values{"presence": {"on"}, "badge": {"on"}})
if _, body := visitor.get("/online"); !strings.Contains(body, "AliceAv") || !strings.Contains(body, "Blue Coat") {
t.Fatal("online not shown after opting in")
}
// A ban ends the connection and the sign-in.
bob := h.browser()
bob.signup("bob")
alice, _ := h.server.Store.PersonByUsername(ctx, "alice")
h.server.Store.SetRole(ctx, alice.ID, "user")
bobP, _ := h.server.Store.PersonByUsername(ctx, "bob")
h.server.Store.SetRole(ctx, bobP.ID, "admin")
bob.get("/account")
if status, _ := bob.post(fmt.Sprintf("/console/people/%d/ban", alice.ID), url.Values{"reason": {"testing"}}); status != 200 {
t.Fatalf("ban: %d", status)
}
waitFor(t, func() bool { return len(h.server.Realtime.Online()) == 0 })
if out := refresh(next["refresh_token"].(string)); out["access_token"] != nil {
t.Fatal("a banned person's proxy can still sign in")
}
}
func waitFor(t *testing.T, ok func() bool) {
t.Helper()
for i := 0; i < 200; i++ {
if ok() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("timed out")
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "t5de-hub",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"check-types": "turbo run check-types",
"lint": "turbo run lint"
},
"packageManager": "[email protected]",
"devDependencies": {
"turbo": "2.11.10"
}
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({ dialect: "postgresql", schema: "./src/schema.ts", out: "./drizzle" });
+126
View File
@@ -0,0 +1,126 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp with time zone,
"refresh_token_expires_at" timestamp with time zone,
"scope" text,
"password" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit" (
"id" serial PRIMARY KEY NOT NULL,
"at" timestamp with time zone DEFAULT now() NOT NULL,
"actor_id" text,
"action" text NOT NULL,
"target" text DEFAULT '' NOT NULL,
"detail" jsonb DEFAULT '{}'::jsonb NOT NULL
);
--> statement-breakpoint
CREATE TABLE "device_code" (
"id" text PRIMARY KEY NOT NULL,
"device_code" text NOT NULL,
"user_code" text NOT NULL,
"user_id" text,
"expires_at" timestamp with time zone NOT NULL,
"status" text NOT NULL,
"last_polled_at" timestamp with time zone,
"polling_interval" integer,
"client_id" text,
"scope" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "imvu_link" (
"imvu_id" bigint PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"pairwise_id" text NOT NULL,
"username" text NOT NULL,
"linked_at" timestamp with time zone DEFAULT now() NOT NULL,
"age_verified" boolean DEFAULT false NOT NULL,
"access_pass" boolean DEFAULT false NOT NULL,
"checked_at" timestamp with time zone DEFAULT now() NOT NULL,
"show_badge" boolean DEFAULT false NOT NULL,
"show_presence" boolean DEFAULT false NOT NULL,
CONSTRAINT "imvu_link_pairwise_id_unique" UNIQUE("pairwise_id")
);
--> statement-breakpoint
CREATE TABLE "jwks" (
"id" text PRIMARY KEY NOT NULL,
"public_key" text NOT NULL,
"private_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone,
"alg" text,
"crv" text
);
--> statement-breakpoint
CREATE TABLE "link_challenge" (
"user_id" text NOT NULL,
"imvu_id" bigint NOT NULL,
"username" text NOT NULL,
"code" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "link_challenge_user_id_imvu_id_pk" PRIMARY KEY("user_id","imvu_id")
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"token" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"impersonated_by" text,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"username" text,
"display_username" text,
"role" text DEFAULT 'user' NOT NULL,
"banned" boolean DEFAULT false,
"ban_reason" text,
"ban_expires" timestamp with time zone,
"link_cap" integer DEFAULT 10 NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email"),
CONSTRAINT "user_username_unique" UNIQUE("username")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit" ADD CONSTRAINT "audit_actor_id_user_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "device_code" ADD CONSTRAINT "device_code_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "imvu_link" ADD CONSTRAINT "imvu_link_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "link_challenge" ADD CONSTRAINT "link_challenge_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_user" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "audit_at" ON "audit" USING btree ("at");--> statement-breakpoint
CREATE INDEX "imvu_link_user" ON "imvu_link" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_user" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "user_username_lower" ON "user" USING btree (lower("username"));--> statement-breakpoint
CREATE INDEX "verification_identifier" ON "verification" USING btree ("identifier");
+872
View File
@@ -0,0 +1,872 @@
{
"id": "47a7aacf-4e5f-4181-9ac1-0100ba416d0c",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"account_user": {
"name": "account_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audit": {
"name": "audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"at": {
"name": "at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"target": {
"name": "target",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"detail": {
"name": "detail",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
}
},
"indexes": {
"audit_at": {
"name": "audit_at",
"columns": [
{
"expression": "at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"audit_actor_id_user_id_fk": {
"name": "audit_actor_id_user_id_fk",
"tableFrom": "audit",
"tableTo": "user",
"columnsFrom": [
"actor_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.device_code": {
"name": "device_code",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"device_code": {
"name": "device_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_code": {
"name": "user_code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_polled_at": {
"name": "last_polled_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"polling_interval": {
"name": "polling_interval",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"device_code_user_id_user_id_fk": {
"name": "device_code_user_id_user_id_fk",
"tableFrom": "device_code",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.imvu_link": {
"name": "imvu_link",
"schema": "",
"columns": {
"imvu_id": {
"name": "imvu_id",
"type": "bigint",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"pairwise_id": {
"name": "pairwise_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"linked_at": {
"name": "linked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"age_verified": {
"name": "age_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"access_pass": {
"name": "access_pass",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"checked_at": {
"name": "checked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"show_badge": {
"name": "show_badge",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"show_presence": {
"name": "show_presence",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {
"imvu_link_user": {
"name": "imvu_link_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"imvu_link_user_id_user_id_fk": {
"name": "imvu_link_user_id_user_id_fk",
"tableFrom": "imvu_link",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"imvu_link_pairwise_id_unique": {
"name": "imvu_link_pairwise_id_unique",
"nullsNotDistinct": false,
"columns": [
"pairwise_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.jwks": {
"name": "jwks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"alg": {
"name": "alg",
"type": "text",
"primaryKey": false,
"notNull": false
},
"crv": {
"name": "crv",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.link_challenge": {
"name": "link_challenge",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"imvu_id": {
"name": "imvu_id",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"link_challenge_user_id_user_id_fk": {
"name": "link_challenge_user_id_user_id_fk",
"tableFrom": "link_challenge",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"link_challenge_user_id_imvu_id_pk": {
"name": "link_challenge_user_id_imvu_id_pk",
"columns": [
"user_id",
"imvu_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"session_user": {
"name": "session_user",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_username": {
"name": "display_username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"link_cap": {
"name": "link_cap",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
}
},
"indexes": {
"user_username_lower": {
"name": "user_username_lower",
"columns": [
{
"expression": "lower(\"username\")",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier": {
"name": "verification_identifier",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1793273462704,
"tag": "0000_init",
"breakpoints": true
}
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@t5de/db",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema.ts"
},
"scripts": {
"check-types": "tsc --noEmit",
"generate": "drizzle-kit generate"
},
"dependencies": {
"@electric-sql/pglite": "0.5.13",
"drizzle-orm": "0.45.4"
},
"devDependencies": {
"@types/bun": "^1.4.4",
"drizzle-kit": "0.31.11",
"typescript": "^7.0.2"
}
}
+37
View File
@@ -0,0 +1,37 @@
// The hub's database: Postgres in production (DATABASE_URL), or PGlite (Postgres compiled to WebAssembly, in
// process) for development and tests, so nothing needs installing.
import { drizzle as drizzleBun } from "drizzle-orm/bun-sql";
import { migrate as migrateBun } from "drizzle-orm/bun-sql/migrator";
import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
import { migrate as migratePglite } from "drizzle-orm/pglite/migrator";
import { PGlite } from "@electric-sql/pglite";
import { SQL } from "bun";
import { mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import * as schema from "./schema";
export { schema };
export * from "drizzle-orm";
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
export type Database = ReturnType<typeof drizzlePglite<typeof schema>>;
/**
* Opens the database and brings its schema up to date.
* @param url a postgres:// URL, "memory" (a fresh in-process database), or a directory for PGlite's files.
*/
export async function openDatabase(url: string): Promise<{ db: Database; close: () => Promise<void> }> {
if (url.startsWith("postgres://") || url.startsWith("postgresql://")) {
const client = new SQL(url);
const db = drizzleBun({ client, schema });
await migrateBun(db, { migrationsFolder });
// Both drivers speak the same query builder; the PGlite type stands for both.
return { db: db as unknown as Database, close: async () => client.close() };
}
if (url !== "memory") mkdirSync(url, { recursive: true });
const client = url === "memory" ? new PGlite() : new PGlite(url);
const db = drizzlePglite({ client, schema });
await migratePglite(db, { migrationsFolder });
return { db, close: async () => client.close() };
}
+175
View File
@@ -0,0 +1,175 @@
// The hub's tables: Better Auth's (people, sessions, passwords, signing keys, device sign-ins) and the hub's own
// (linked IMVU accounts, link codes, the audit log). Better Auth's are as its CLI generates them for the plugins
// used (username, admin, device-authorization, bearer, jwt), with the hub's linkCap added to user.
import { sql } from "drizzle-orm";
import {
bigint,
boolean,
index,
integer,
jsonb,
pgTable,
primaryKey,
serial,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
const createdAt = () => timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
const updatedAt = () =>
timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date());
// A person: one T5DE account.
export const user = pgTable(
"user",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").notNull().default(false),
image: text("image"),
createdAt: createdAt(),
updatedAt: updatedAt(),
username: text("username").unique(),
displayUsername: text("display_username"),
role: text("role").notNull().default("user"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires", { withTimezone: true }),
// How many IMVU accounts the person may link; moderators raise it on request.
linkCap: integer("link_cap").notNull().default(10),
},
(t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)],
);
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
token: text("token").notNull().unique(),
createdAt: createdAt(),
updatedAt: updatedAt(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
},
(t) => [index("session_user").on(t.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
scope: text("scope"),
password: text("password"),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("account_user").on(t.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [index("verification_identifier").on(t.identifier)],
);
export const jwks = pgTable("jwks", {
id: text("id").primaryKey(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
createdAt: createdAt(),
expiresAt: timestamp("expires_at", { withTimezone: true }),
alg: text("alg"),
crv: text("crv"),
});
// A T5DE proxy signing in: it shows userCode, the person approves it on the website.
export const deviceCode = pgTable("device_code", {
id: text("id").primaryKey(),
deviceCode: text("device_code").notNull(),
userCode: text("user_code").notNull(),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
status: text("status").notNull(),
lastPolledAt: timestamp("last_polled_at", { withTimezone: true }),
pollingInterval: integer("polling_interval"),
clientId: text("client_id"),
scope: text("scope"),
createdAt: createdAt(),
updatedAt: updatedAt(),
});
// A linked IMVU account. Linking it again from another person moves it (the most recent proof wins).
export const imvuLink = pgTable(
"imvu_link",
{
imvuId: bigint("imvu_id", { mode: "number" }).primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// What catalogs and rooms see instead of the person, so a person's accounts can't be connected.
pairwiseId: text("pairwise_id").notNull().unique(),
username: text("username").notNull(),
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
ageVerified: boolean("age_verified").notNull().default(false),
accessPass: boolean("access_pass").notNull().default(false),
checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(),
showBadge: boolean("show_badge").notNull().default(false),
showPresence: boolean("show_presence").notNull().default(false),
},
(t) => [index("imvu_link_user").on(t.userId)],
);
// A code the person is to put in an IMVU account's tagline, proving it's theirs.
export const linkChallenge = pgTable(
"link_challenge",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
username: text("username").notNull(),
code: text("code").notNull(),
createdAt: createdAt(),
},
(t) => [primaryKey({ columns: [t.userId, t.imvuId] })],
);
export const audit = pgTable(
"audit",
{
id: serial("id").primaryKey(),
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
actorId: text("actor_id").references(() => user.id, { onDelete: "set null" }),
action: text("action").notNull(),
target: text("target").notNull().default(""),
detail: jsonb("detail").$type<Record<string, unknown>>().notNull().default({}),
},
(t) => [index("audit_at").on(t.at)],
);
+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", "drizzle.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"] },
"dev": { "cache": false, "persistent": true },
"test": { "dependsOn": ["^build"] },
"check-types": { "dependsOn": ["^check-types"] },
"lint": {}
}
}