Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a06ba49c3 | ||
|
|
64c552330b |
No files matched your search
+15
-20
@@ -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
@@ -1,2 +1,8 @@
|
|||||||
/dist/
|
node_modules/
|
||||||
/t5de-hub-data/
|
.next/
|
||||||
|
.turbo/
|
||||||
|
dist/
|
||||||
|
apps/api/data/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -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.
|
||||||
@@ -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.
|
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"];
|
||||||
@@ -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>;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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})`);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true,
|
||||||
|
"noEmit": true, "skipLibCheck": true, "types": ["bun"], "noUncheckedIndexedAccess": true
|
||||||
|
},
|
||||||
|
"include": ["src", "test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/.next/
|
||||||
|
/node_modules/
|
||||||
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# The hub's website as a standalone Next.js server. Build from the repository root (the app uses the workspace's
|
||||||
|
# packages for the API's types):
|
||||||
|
#
|
||||||
|
# docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web .
|
||||||
|
# docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web
|
||||||
|
#
|
||||||
|
# API_URL is needed at build time too: Next fixes the /api rewrite when it builds.
|
||||||
|
|
||||||
|
FROM oven/bun:1.4.4 AS bun
|
||||||
|
|
||||||
|
FROM node:26-slim AS build
|
||||||
|
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
||||||
|
WORKDIR /repo
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# Dependencies first, so they're cached while only source changes.
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY packages/db packages/db
|
||||||
|
COPY apps/api apps/api
|
||||||
|
COPY apps/web apps/web
|
||||||
|
ARG API_URL=http://api:4101
|
||||||
|
ENV API_URL=$API_URL
|
||||||
|
RUN cd apps/web && bun run build
|
||||||
|
|
||||||
|
FROM node:26-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4100 HOSTNAME=0.0.0.0
|
||||||
|
ARG API_URL=http://api:4101
|
||||||
|
ENV API_URL=$API_URL
|
||||||
|
COPY --from=build --chown=node:node /repo/apps/web/.next/standalone ./
|
||||||
|
COPY --from=build --chown=node:node /repo/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
USER node
|
||||||
|
EXPOSE 4100
|
||||||
|
CMD ["node", "apps/web/server.js"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.next
|
||||||
|
**/.turbo
|
||||||
|
**/data
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.git
|
||||||
|
dist
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# T5DE Hub website
|
||||||
|
|
||||||
|
The hub's website: accounts, linked IMVU accounts, who's online, signing T5DE in, and the moderation console.
|
||||||
|
Next.js (App Router), Tailwind CSS and shadcn/ui. Pages are rendered on the server, which calls the hub's API
|
||||||
|
(`apps/api`) with the visitor's cookie; forms use server actions. The browser only talks to this app: `/api/*` is
|
||||||
|
rewritten to the API, so the API's session cookies belong to the website's origin.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
From the repository root, `bun install`, then start the API (`cd apps/api && bun src/index.ts`) and the website:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd apps/web
|
||||||
|
bun run dev # http://localhost:4100
|
||||||
|
bun run check-types
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The first account created is the hub's admin.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | What |
|
||||||
|
| --------- | ----------------------- | -------------------------------------------------------------------------------------- |
|
||||||
|
| `API_URL` | `http://localhost:4101` | The API's address as this app reaches it. Needed at build time and at run time (below). |
|
||||||
|
| `PORT` | `4100` | The standalone server's port (`next start` and `bun run dev` use 4100). |
|
||||||
|
|
||||||
|
The API's `WEB_URL` must be this website's public address (it's where Better Auth trusts requests from, and where
|
||||||
|
T5DE's sign-in code page is).
|
||||||
|
|
||||||
|
`API_URL` is read in two places: by server code on every request, and by `next.config.ts`'s `/api` rewrite, which
|
||||||
|
Next fixes when it builds. So build with the same `API_URL` the app will run with.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
### Container
|
||||||
|
|
||||||
|
The `Dockerfile` builds a standalone server (`output: "standalone"`). Build it from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -f apps/web/Dockerfile --build-arg API_URL=http://api:4101 -t t5de-hub-web .
|
||||||
|
docker run -p 4100:4100 -e API_URL=http://api:4101 t5de-hub-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Next's own gzip is off (it held back the API's live event stream), so put a reverse proxy that compresses in front,
|
||||||
|
and don't let it buffer `text/event-stream` responses (`/api/v1/online/events`).
|
||||||
|
|
||||||
|
### Vercel
|
||||||
|
|
||||||
|
Import the repository with the project's root directory set to `apps/web` (Vercel installs the Bun workspace from the
|
||||||
|
root). Set `API_URL` to the API's public address for both builds and runtime. Nothing else is needed: there's no
|
||||||
|
custom server.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||||
|
|
||||||
|
const done = () => revalidatePath("/account");
|
||||||
|
|
||||||
|
/** Starts linking an IMVU account: the API makes a code to put in its tagline. */
|
||||||
|
export async function linkAccount(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.account.links.post({ username: field(form, "username") }));
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pending link: check the tagline for the code, or cancel. */
|
||||||
|
export async function challengeAction(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const imvu = field(form, "imvu");
|
||||||
|
return attempt(async () => {
|
||||||
|
if (field(form, "intent") === "cancel") {
|
||||||
|
unwrap(await api.account.challenges({ imvu }).delete());
|
||||||
|
} else {
|
||||||
|
unwrap(await api.account.links({ imvu }).check.post());
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A linked account: check its age status with IMVU again, or unlink it. */
|
||||||
|
export async function linkAction(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
const imvu = field(form, "imvu");
|
||||||
|
return attempt(async () => {
|
||||||
|
if (field(form, "intent") === "unlink") {
|
||||||
|
unwrap(await api.account.links({ imvu }).delete());
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unwrap(await api.account.links({ imvu }).recheck.post());
|
||||||
|
done();
|
||||||
|
return "Checked with IMVU just now.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(
|
||||||
|
imvu: number,
|
||||||
|
settings: { showBadge?: boolean; showPresence?: boolean },
|
||||||
|
): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.account.links({ imvu }).patch(settings));
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOutComputers(_: ActionState, _form: FormData): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.account["signout-computers"].post());
|
||||||
|
done();
|
||||||
|
return "Signed out everywhere.";
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CopyButton } from "@/components/copy-button";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { Challenge } from "@/lib/api";
|
||||||
|
import { challengeAction } from "./actions";
|
||||||
|
|
||||||
|
/** A link in progress: the code to put in the tagline, then Check. */
|
||||||
|
export function ChallengeCard({ challenge: c }: { challenge: Challenge }) {
|
||||||
|
return (
|
||||||
|
<Card id={`link-${c.imvuId}`}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link {c.username}</CardTitle>
|
||||||
|
<CardDescription>To prove {c.username} is yours:</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={challengeAction} fields={{ imvu: c.imvuId }} className="space-y-6">
|
||||||
|
<ol className="space-y-5 text-sm">
|
||||||
|
<Step n={1}>
|
||||||
|
<p>
|
||||||
|
Put this code anywhere in {c.username}'s IMVU tagline (on your avatar card in the client, or your
|
||||||
|
profile):
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<code className="rounded-lg border bg-muted/50 px-4 py-2.5 font-mono text-xl font-semibold tracking-wider select-all sm:text-2xl">
|
||||||
|
{c.code}
|
||||||
|
</code>
|
||||||
|
<CopyButton text={c.code} />
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
<Step n={2}>
|
||||||
|
<p>Press Check. You can take the code out again once it's linked.</p>
|
||||||
|
</Step>
|
||||||
|
</ol>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SubmitButton name="intent" value="check" pendingLabel="Checking…">
|
||||||
|
Check
|
||||||
|
</SubmitButton>
|
||||||
|
<SubmitButton variant="outline" name="intent" value="cancel" pendingLabel="Cancelling…">
|
||||||
|
Cancel
|
||||||
|
</SubmitButton>
|
||||||
|
<span className="text-sm text-muted-foreground">The code works for a day.</span>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({ n, children }: { n: number; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="grid size-6 shrink-0 place-items-center rounded-full border text-xs font-medium text-muted-foreground">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 pt-0.5">{children}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { AgeTag, Initial, Outfit, Tag } from "@/components/status";
|
||||||
|
import { TimeAgo } from "@/components/time";
|
||||||
|
import type { Link } from "@/lib/api";
|
||||||
|
import { linkAction } from "./actions";
|
||||||
|
import { LinkSettings } from "./link-settings";
|
||||||
|
|
||||||
|
/** One linked IMVU account. */
|
||||||
|
export function LinkCard({ link }: { link: Link }) {
|
||||||
|
return (
|
||||||
|
<li className="flex gap-4 py-6 first:pt-0 last:pb-0">
|
||||||
|
<Initial name={link.username} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-5 sm:flex-row sm:items-start">
|
||||||
|
<div className="min-w-0 flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">{link.username}</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{link.online && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
Online in T5DE
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<AgeTag link={link} />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
checked <TimeAgo iso={link.checkedAt} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Outfit items={link.outfit} />
|
||||||
|
<LinkSettings imvuId={link.imvuId} showBadge={link.showBadge} showPresence={link.showPresence} />
|
||||||
|
</div>
|
||||||
|
<ActionForm action={linkAction} fields={{ imvu: link.imvuId }} className="sm:max-w-56 sm:text-right">
|
||||||
|
<div className="flex gap-2 sm:justify-end">
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
name="intent"
|
||||||
|
value="recheck"
|
||||||
|
pendingLabel="Checking…"
|
||||||
|
title="Check age verification with IMVU again"
|
||||||
|
>
|
||||||
|
Check again
|
||||||
|
</SubmitButton>
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
name="intent"
|
||||||
|
value="unlink"
|
||||||
|
pendingLabel="Unlinking…"
|
||||||
|
confirm={`Unlink ${link.username}? To link it again, you'll need a new code in its tagline.`}
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
// A linked account's two settings, saved as soon as they're switched.
|
||||||
|
import { useOptimistic, useState, useTransition } from "react";
|
||||||
|
import { FormMessage } from "@/components/forms";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import type { ActionState } from "@/lib/actions";
|
||||||
|
import { saveSettings } from "./actions";
|
||||||
|
|
||||||
|
type Settings = { showBadge: boolean; showPresence: boolean };
|
||||||
|
|
||||||
|
export function LinkSettings({ imvuId, ...saved }: Settings & { imvuId: number }) {
|
||||||
|
const [settings, setOptimistic] = useOptimistic(saved);
|
||||||
|
const [state, setState] = useState<ActionState>(null);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const change = (key: keyof Settings) => (value: boolean) =>
|
||||||
|
startTransition(async () => {
|
||||||
|
setOptimistic({ ...settings, [key]: value });
|
||||||
|
setState(await saveSettings(imvuId, { [key]: value }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch checked={settings.showBadge} onCheckedChange={change("showBadge")} disabled={pending} />
|
||||||
|
Show T5DE badge
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<Switch checked={settings.showPresence} onCheckedChange={change("showPresence")} disabled={pending} />
|
||||||
|
Show when I'm online in T5DE
|
||||||
|
</label>
|
||||||
|
<FormMessage state={state} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { PageHeader, Tag } from "@/components/status";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api, getMe, isModerator, unwrap } from "@/lib/api";
|
||||||
|
import { linkAccount, signOutComputers } from "./actions";
|
||||||
|
import { ChallengeCard } from "./challenge-card";
|
||||||
|
import { LinkCard } from "./link-card";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Account" };
|
||||||
|
|
||||||
|
export default async function Account({ searchParams }: { searchParams: Promise<{ welcome?: string }> }) {
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) redirect("/signin?next=/account");
|
||||||
|
const account = unwrap(await api.account.get());
|
||||||
|
const { person, links, challenges, computers } = account;
|
||||||
|
const welcome = (await searchParams).welcome !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader title="Your account">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
Signed in as {person.username}
|
||||||
|
{isModerator(person) && <Tag tone="strong">{person.role}</Tag>}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{welcome && (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Welcome to T5DE, {person.username}.</AlertTitle>
|
||||||
|
<AlertDescription>Link an IMVU account below to get started.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>IMVU accounts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{links.length} of {person.linkCap}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{links.length > 0 ? (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{links.map((link) => (
|
||||||
|
<LinkCard key={link.imvuId} link={link} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No IMVU accounts linked yet.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{challenges.map((c) => (
|
||||||
|
<ChallengeCard key={c.imvuId} challenge={c} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link an IMVU account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{account.canLink ? (
|
||||||
|
<ActionForm action={linkAccount}>
|
||||||
|
<div className="flex max-w-md gap-2">
|
||||||
|
<Input name="username" placeholder="IMVU username" required maxLength={64} aria-label="IMVU username" />
|
||||||
|
<SubmitButton pendingLabel="Looking…">Next</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">
|
||||||
|
You've linked as many IMVU accounts as you can ({person.linkCap}). Ask a moderator if you need more.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Other people can't see which IMVU accounts are yours. Age-verified IMVU accounts (or with Access Pass)
|
||||||
|
can see adult community products; each account is checked on its own.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>T5DE on your computers</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{computers > 0
|
||||||
|
? `Signed in on ${computers} computer${computers === 1 ? "" : "s"}.`
|
||||||
|
: "Not signed in on any computer yet."}{" "}
|
||||||
|
To sign T5DE in, it shows a code to enter at{" "}
|
||||||
|
<Link href="/device" className="text-foreground underline underline-offset-4">
|
||||||
|
/device
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
{computers > 0 && (
|
||||||
|
<ActionForm action={signOutComputers}>
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
pendingLabel="Signing out…"
|
||||||
|
confirm="Sign T5DE out on all your computers?"
|
||||||
|
>
|
||||||
|
Sign out everywhere
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||||
|
|
||||||
|
const roles = ["user", "moderator", "admin"] as const;
|
||||||
|
|
||||||
|
/** Runs a change to a person from the console, then shows their page again. */
|
||||||
|
async function onPerson(form: FormData, change: (id: string) => Promise<string | void>): Promise<ActionState> {
|
||||||
|
const id = field(form, "id");
|
||||||
|
return attempt(async () => {
|
||||||
|
const notice = await change(id);
|
||||||
|
revalidatePath(`/console/people/${id}`);
|
||||||
|
return notice;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLinkCap(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
const linkCap = Number(field(form, "linkCap"));
|
||||||
|
const { person } = unwrap(await api.console.people({ id })["link-cap"].post({ linkCap }));
|
||||||
|
return `Link cap set to ${person.linkCap}.`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ban(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
unwrap(await api.console.people({ id }).ban.post({ reason: field(form, "reason") }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unban(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
unwrap(await api.console.people({ id }).unban.post());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRole(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return onPerson(form, async (id) => {
|
||||||
|
const role = roles.find((r) => r === field(form, "role")) ?? "user";
|
||||||
|
const { person } = unwrap(await api.console.people({ id }).role.post({ role }));
|
||||||
|
return `Role set to ${person.role}.`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { NavLink } from "@/components/header-client";
|
||||||
|
import { getMe, isModerator, type Person } from "@/lib/api";
|
||||||
|
|
||||||
|
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */
|
||||||
|
export async function requireModerator(path: string): Promise<Person> {
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||||
|
if (!isModerator(me)) notFound();
|
||||||
|
return me;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsoleNav() {
|
||||||
|
return (
|
||||||
|
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
|
||||||
|
<NavLink href="/console" exact>
|
||||||
|
Overview
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/console/people">People</NavLink>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { OnlineList } from "@/components/online-list";
|
||||||
|
import { PageHeader } from "@/components/status";
|
||||||
|
import { DateTime } from "@/components/time";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { api, unwrap, type AuditEntry } from "@/lib/api";
|
||||||
|
import { ConsoleNav, requireModerator } from "./console-nav";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Console" };
|
||||||
|
|
||||||
|
export default async function Console() {
|
||||||
|
await requireModerator("/console");
|
||||||
|
const data = unwrap(await api.console.get());
|
||||||
|
const stats = [
|
||||||
|
{ label: "People", value: data.counts.people, note: "accounts" },
|
||||||
|
{ label: "IMVU accounts", value: data.counts.links, note: "linked" },
|
||||||
|
{ label: "Computers", value: data.counts.computers, note: "signed in" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Console">Moderation.</PageHeader>
|
||||||
|
<ConsoleNav />
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<Card key={s.label}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{s.label}</CardDescription>
|
||||||
|
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">{s.note}</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Online now</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<OnlineList initial={data.online} moderator />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Audit log</CardTitle>
|
||||||
|
<CardDescription>The latest 50 changes.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AuditLog entries={data.audit} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLog({ entries }: { entries: AuditEntry[] }) {
|
||||||
|
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>;
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Who</TableHead>
|
||||||
|
<TableHead>What</TableHead>
|
||||||
|
<TableHead>Target</TableHead>
|
||||||
|
<TableHead>Detail</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={e.at} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||||
|
<TableCell>{e.target}</TableCell>
|
||||||
|
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
||||||
|
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
|
||||||
|
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||||
|
.join(", ")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||||
|
import { AgeTag, PageHeader, Tag } from "@/components/status";
|
||||||
|
import { DateTime } from "@/components/time";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { api, ApiError, unwrap } from "@/lib/api";
|
||||||
|
import { ban, setLinkCap, setRole, unban } from "../../actions";
|
||||||
|
import { ConsoleNav, requireModerator } from "../../console-nav";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Person" };
|
||||||
|
|
||||||
|
export default async function PersonPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const me = await requireModerator(`/console/people/${id}`);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = unwrap(await api.console.people({ id }).get());
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 404) notFound();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
const { person, links, computers } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={person.username}>
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="capitalize">{person.role}</span>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>
|
||||||
|
joined <DateTime iso={person.createdAt} dateOnly />
|
||||||
|
</span>
|
||||||
|
{person.banned && <Tag tone="danger">banned: {person.banReason}</Tag>}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
<ConsoleNav />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>IMVU accounts</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{links.length} of {person.linkCap}. Only moderators see which IMVU accounts belong to one person.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{links.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">None.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>IMVU account</TableHead>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Linked</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead>Shows</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{links.map((l) => (
|
||||||
|
<TableRow key={l.imvuId}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{l.username}
|
||||||
|
{l.online && (
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
online
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums">{l.imvuId}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={l.linkedAt} dateOnly />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AgeTag link={l} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{[l.showBadge && "badge", l.showPresence && "presence"].filter(Boolean).join(", ") || "nothing"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Signed in on {computers} computer{computers === 1 ? "" : "s"}.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Link cap</CardTitle>
|
||||||
|
<CardDescription>How many IMVU accounts they can link (normally 10).</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={setLinkCap} fields={{ id: person.id }}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="linkCap"
|
||||||
|
defaultValue={person.linkCap}
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
required
|
||||||
|
aria-label="Link cap"
|
||||||
|
className="w-28"
|
||||||
|
/>
|
||||||
|
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Ban</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{person.banned
|
||||||
|
? `Banned: ${person.banReason}`
|
||||||
|
: "Covers all their IMVU accounts, and signs them out everywhere."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{person.banned ? (
|
||||||
|
<ActionForm action={unban} fields={{ id: person.id }}>
|
||||||
|
<SubmitButton variant="outline" pendingLabel="Unbanning…">
|
||||||
|
Unban
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<ActionForm action={ban} fields={{ id: person.id }}>
|
||||||
|
<Input
|
||||||
|
name="reason"
|
||||||
|
placeholder="Reason (they see it)"
|
||||||
|
required
|
||||||
|
maxLength={500}
|
||||||
|
aria-label="Reason"
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
className="bg-red-600 text-white hover:bg-red-600/90 dark:bg-red-600 dark:hover:bg-red-600/90"
|
||||||
|
pendingLabel="Banning…"
|
||||||
|
confirm={`Ban ${person.username}?`}
|
||||||
|
>
|
||||||
|
Ban
|
||||||
|
</SubmitButton>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{me.role === "admin" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Role</CardTitle>
|
||||||
|
<CardDescription>Moderators use the console; admins also set roles.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActionForm action={setRole} fields={{ id: person.id }}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NativeSelect name="role" defaultValue={person.role} aria-label="Role">
|
||||||
|
<NativeSelectOption value="user">User</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="moderator">Moderator</NativeSelectOption>
|
||||||
|
<NativeSelectOption value="admin">Admin</NativeSelectOption>
|
||||||
|
</NativeSelect>
|
||||||
|
<SubmitButton pendingLabel="Setting…">Set</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { PageHeader, Tag } from "@/components/status";
|
||||||
|
import { DateTime } from "@/components/time";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { ConsoleNav, requireModerator } from "../console-nav";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "People" };
|
||||||
|
|
||||||
|
export default async function People({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
|
||||||
|
const raw = (await searchParams).q;
|
||||||
|
const q = typeof raw === "string" ? raw.slice(0, 64) : "";
|
||||||
|
await requireModerator(q ? `/console/people?q=${encodeURIComponent(q)}` : "/console/people");
|
||||||
|
const { people } = unwrap(await api.console.people.get({ query: q ? { q } : {} }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="People" />
|
||||||
|
<ConsoleNav />
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<form action="/console/people" className="flex max-w-md gap-2">
|
||||||
|
<Input type="search" name="q" defaultValue={q} placeholder="T5DE or IMVU username" aria-label="Search" />
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{people.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Nobody found.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Username</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Link cap</TableHead>
|
||||||
|
<TableHead>Joined</TableHead>
|
||||||
|
<TableHead />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{people.map((p) => (
|
||||||
|
<TableRow key={p.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Link href={`/console/people/${p.id}`} className="font-medium underline-offset-4 hover:underline">
|
||||||
|
{p.username}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="capitalize">{p.role}</TableCell>
|
||||||
|
<TableCell className="tabular-nums">{p.linkCap}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={p.createdAt} dateOnly />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{p.banned && <Tag tone="danger">banned</Tag>}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"use server";
|
||||||
|
import { api, unwrap } from "@/lib/api";
|
||||||
|
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||||
|
|
||||||
|
/** Approves the sign-in code T5DE shows, signing that computer in as this person. */
|
||||||
|
export async function approveDevice(_: ActionState, form: FormData): Promise<ActionState> {
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.device.approve.post({ code: field(form, "code") }));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { FormMessage, SubmitButton } from "@/components/forms";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { approveDevice } from "./actions";
|
||||||
|
|
||||||
|
export function DeviceForm({ code, username }: { code: string; username: string }) {
|
||||||
|
const [state, action] = useActionState(approveDevice, null);
|
||||||
|
|
||||||
|
if (state && !state.error)
|
||||||
|
return <NarrowCard title="T5DE is signed in" description="You can close this page and go back to T5DE." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Sign T5DE in" description={`Enter the code T5DE shows, to sign it in as ${username}.`}>
|
||||||
|
<form action={action}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="code">Code</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
name="code"
|
||||||
|
defaultValue={code}
|
||||||
|
placeholder="XXXX-XXXX"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="h-11 text-center font-mono text-lg tracking-widest uppercase"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<FormMessage state={state} />
|
||||||
|
<Field>
|
||||||
|
<SubmitButton pendingLabel="Signing in…">Sign in</SubmitButton>
|
||||||
|
<FieldDescription>Only enter a code you got from T5DE yourself.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
|
import { DeviceForm } from "./device-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Sign T5DE in" };
|
||||||
|
|
||||||
|
type Search = { user_code?: string | string[]; code?: string | string[] };
|
||||||
|
|
||||||
|
export default async function Device({ searchParams }: { searchParams: Promise<Search> }) {
|
||||||
|
const search = await searchParams;
|
||||||
|
// Better Auth's verification link carries user_code; ?code= is shorter to type.
|
||||||
|
const code = [search.user_code, search.code].find((c) => typeof c === "string") ?? "";
|
||||||
|
const me = await getMe();
|
||||||
|
if (!me) {
|
||||||
|
const here = code ? `/device?code=${encodeURIComponent(code)}` : "/device";
|
||||||
|
redirect(`/signin?next=${encodeURIComponent(here)}`);
|
||||||
|
}
|
||||||
|
return <DeviceForm code={code} username={me.username} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function ErrorPage({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Sorry" description="Something went wrong on our side. Try again in a moment.">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={reset}>Try again</Button>
|
||||||
|
<Link href="/" className={buttonVariants({ variant: "outline" })}>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (@media (prefers-color-scheme: dark));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<style>
|
||||||
|
rect { fill: #171717; } text { fill: #fafafa; }
|
||||||
|
@media (prefers-color-scheme: dark) { rect { fill: #fafafa; } text { fill: #171717; } }
|
||||||
|
</style>
|
||||||
|
<rect width="32" height="32" rx="7"/>
|
||||||
|
<text x="16" y="21.5" text-anchor="middle" font-family="system-ui, sans-serif" font-size="14" font-weight="700">T5</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 397 B |
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { Header } from "@/components/header";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const sans = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||||
|
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: { template: "%s · T5DE Hub", default: "T5DE Hub" },
|
||||||
|
description: "One account for all your IMVU avatars.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={cn("font-sans antialiased", sans.variable, mono.variable)}>
|
||||||
|
<body className="flex min-h-dvh flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-10 sm:px-6 sm:py-14">{children}</main>
|
||||||
|
<footer className="border-t">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-6 text-sm text-muted-foreground sm:px-6">
|
||||||
|
T5DE is unofficial: not made by or affiliated with IMVU.
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Not found" description="There's nothing at this address.">
|
||||||
|
<Link href="/" className={buttonVariants({ variant: "outline" })}>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { OnlineList } from "@/components/online-list";
|
||||||
|
import { PageHeader } from "@/components/status";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { api, getMe, isModerator, unwrap } from "@/lib/api";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Online" };
|
||||||
|
|
||||||
|
export default async function Online() {
|
||||||
|
const [me, { online }] = await Promise.all([getMe(), api.online.get().then(unwrap)]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Online in T5DE">IMVU accounts in a T5DE client right now, that chose to show it.</PageHeader>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<OnlineList initial={online} moderator={isModerator(me)} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { api, getMe, unwrap } from "@/lib/api";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const [me, home] = await Promise.all([getMe(), api.home.get().then(unwrap)]);
|
||||||
|
const links = "links" in home ? home.links : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-14">
|
||||||
|
<section className="max-w-2xl space-y-5 pt-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">T5DE Hub</p>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
|
||||||
|
One account for all your IMVU avatars.
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-pretty text-muted-foreground">
|
||||||
|
Link your IMVU accounts, see who's online in T5DE, and soon, community products.
|
||||||
|
</p>
|
||||||
|
{!me && (
|
||||||
|
<div className="flex flex-wrap gap-3 pt-2">
|
||||||
|
<Link href="/signup" className={buttonVariants({ size: "lg", className: "px-4" })}>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
<Link href="/signin" className={buttonVariants({ size: "lg", variant: "outline", className: "px-4" })}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Online in T5DE</CardDescription>
|
||||||
|
<CardTitle className="text-4xl font-semibold tabular-nums">{home.onlineCount}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
people showing that they're online.{" "}
|
||||||
|
<Link href="/online" className="text-foreground underline underline-offset-4">
|
||||||
|
See who
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{links !== undefined ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Your IMVU accounts</CardDescription>
|
||||||
|
<CardTitle className="text-4xl font-semibold tabular-nums">{links}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
linked.{" "}
|
||||||
|
<Link href="/account" className="text-foreground underline underline-offset-4">
|
||||||
|
Manage
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>One account, all your avatars</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">
|
||||||
|
Link any of your IMVU accounts by putting a code in its tagline. Your accounts stay private: nobody else
|
||||||
|
can see they're yours.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
|
import { safeNext } from "@/lib/paths";
|
||||||
|
import { SignInForm } from "./signin-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Sign in" };
|
||||||
|
|
||||||
|
export default async function SignIn({ searchParams }: { searchParams: Promise<{ next?: string | string[] }> }) {
|
||||||
|
const next = safeNext((await searchParams).next);
|
||||||
|
if (await getMe()) redirect(next);
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Sign in" description="to your T5DE account.">
|
||||||
|
<SignInForm next={next} />
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ErrorText } from "@/components/forms";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
/** Signs in with Better Auth, then goes to next (already checked to be a path on this site). */
|
||||||
|
export function SignInForm({ next }: { next: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function submit(form: FormData) {
|
||||||
|
setError("");
|
||||||
|
setPending(true);
|
||||||
|
const { error } = await authClient.signIn.username({
|
||||||
|
username: String(form.get("username")),
|
||||||
|
password: String(form.get("password")),
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
setError(error.message ?? "That didn't work. Try again.");
|
||||||
|
setPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push(next);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit(new FormData(e.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||||
|
<Input id="username" name="username" autoComplete="username" required autoFocus />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" name="password" type="password" autoComplete="current-password" required />
|
||||||
|
</Field>
|
||||||
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Signing in…" : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
<FieldDescription className="text-center">
|
||||||
|
No account? <Link href="/signup">Create one</Link>
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { NarrowCard } from "@/components/narrow-card";
|
||||||
|
import { getMe } from "@/lib/api";
|
||||||
|
import { SignUpForm } from "./signup-form";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Create account" };
|
||||||
|
|
||||||
|
export default async function SignUp() {
|
||||||
|
if (await getMe()) redirect("/account");
|
||||||
|
return (
|
||||||
|
<NarrowCard title="Create account" description="One T5DE account for all your IMVU accounts.">
|
||||||
|
<SignUpForm />
|
||||||
|
</NarrowCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ErrorText } from "@/components/forms";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
export function SignUpForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function submit(form: FormData) {
|
||||||
|
const username = String(form.get("username"));
|
||||||
|
const password = String(form.get("password"));
|
||||||
|
if (password !== form.get("password2")) {
|
||||||
|
setError("The passwords don't match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setPending(true);
|
||||||
|
const { error } = await authClient.signUp.email({
|
||||||
|
username,
|
||||||
|
name: username,
|
||||||
|
email: String(form.get("email")),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
setError(error.message ?? "That didn't work. Try again.");
|
||||||
|
setPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/account?welcome=1");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit(new FormData(e.currentTarget));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="username">Username</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
pattern="[A-Za-z0-9_]{3,32}"
|
||||||
|
title="3 to 32 letters, digits or underscores"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||||
|
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password">Password</FieldLabel>
|
||||||
|
<Input id="password" name="password" type="password" autoComplete="new-password" required minLength={10} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="password2">Password again</FieldLabel>
|
||||||
|
<Input id="password2" name="password2" type="password" autoComplete="new-password" required minLength={10} />
|
||||||
|
<FieldDescription>Not your IMVU password: use a new one.</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Creating account…" : "Create account"}
|
||||||
|
</Button>
|
||||||
|
<FieldDescription className="text-center">
|
||||||
|
Have one? <Link href="/signin">Sign in</Link>
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function CopyButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
// Forms that run a server action and show its answer, with buttons that show when they're working.
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { ActionState } from "@/lib/actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Action = (state: ActionState, form: FormData) => Promise<ActionState>;
|
||||||
|
|
||||||
|
export function ActionForm({
|
||||||
|
action,
|
||||||
|
fields = {},
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
action: Action;
|
||||||
|
/** Hidden fields sent with the form. */
|
||||||
|
fields?: Record<string, string | number>;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [state, formAction] = useActionState(action, null);
|
||||||
|
return (
|
||||||
|
<form action={formAction} className={cn("space-y-3", className)}>
|
||||||
|
{Object.entries(fields).map(([name, value]) => (
|
||||||
|
<input key={name} type="hidden" name={name} value={value} />
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
<FormMessage state={state} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormMessage({ state }: { state: ActionState }) {
|
||||||
|
if (state?.error) return <ErrorText>{state.error}</ErrorText>;
|
||||||
|
if (state?.notice)
|
||||||
|
return (
|
||||||
|
<p role="status" className="text-sm text-muted-foreground">
|
||||||
|
{state.notice}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorText({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="text-sm text-red-700 dark:text-red-400">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A submit button that shows pending while its form is being sent. With name and value, it's one of several
|
||||||
|
* buttons in the form, and only shows pending when it was the one pressed. With confirm, it asks first.
|
||||||
|
*/
|
||||||
|
export function SubmitButton({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
pendingLabel,
|
||||||
|
confirm,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "type" | "name" | "value" | "onClick"> & {
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
pendingLabel?: string;
|
||||||
|
confirm?: string;
|
||||||
|
}) {
|
||||||
|
const status = useFormStatus();
|
||||||
|
const mine = status.pending && (name === undefined || status.data?.get(name) === value);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
disabled={status.pending}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (confirm && !window.confirm(confirm)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{mine ? (pendingLabel ?? children) : children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** A link in a navigation bar, marked when it's the current page (or, unless exact, a page under it). */
|
||||||
|
export function NavLink({
|
||||||
|
href,
|
||||||
|
exact = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
exact?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const here = pathname === href || (!exact && pathname.startsWith(href + "/"));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-current={here ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2.5 py-1.5 text-muted-foreground transition-colors hover:text-foreground",
|
||||||
|
here && "bg-muted text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignOutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={async () => {
|
||||||
|
setPending(true);
|
||||||
|
await authClient.signOut();
|
||||||
|
router.push("/");
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending ? "Signing out…" : "Sign out"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getMe, isModerator } from "@/lib/api";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { NavLink, SignOutButton } from "./header-client";
|
||||||
|
|
||||||
|
export function Logo() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid size-7 place-items-center rounded-md bg-foreground text-[11px] font-bold tracking-tight text-background"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
T5
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Header() {
|
||||||
|
const me = await getMe();
|
||||||
|
return (
|
||||||
|
<header className="border-b">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
|
||||||
|
<Link href="/" className="flex items-center gap-2 font-semibold">
|
||||||
|
<Logo />
|
||||||
|
T5DE Hub
|
||||||
|
</Link>
|
||||||
|
<nav className="order-last flex w-full items-center gap-1 text-sm sm:order-none sm:w-auto">
|
||||||
|
<NavLink href="/online">Online</NavLink>
|
||||||
|
{me && <NavLink href="/account">Account</NavLink>}
|
||||||
|
{isModerator(me) && <NavLink href="/console">Console</NavLink>}
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-sm">
|
||||||
|
{me ? (
|
||||||
|
<>
|
||||||
|
<span className="hidden text-muted-foreground sm:inline">{me.username}</span>
|
||||||
|
<SignOutButton />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link href="/signup" className={buttonVariants()}>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
/** A small centered card: sign-in, sign-up, device sign-in, errors. */
|
||||||
|
export function NarrowCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto w-full max-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">{title}</CardTitle>
|
||||||
|
{description && <CardDescription>{description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
{children && <CardContent>{children}</CardContent>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
// Who's online in T5DE, kept live: the page renders the list, then this follows the API's event stream.
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Initial, Outfit, Tag } from "@/components/status";
|
||||||
|
import { Since } from "@/components/time";
|
||||||
|
import type { OnlineEntry } from "@/lib/api";
|
||||||
|
|
||||||
|
export function OnlineList({ initial, moderator = false }: { initial: OnlineEntry[]; moderator?: boolean }) {
|
||||||
|
const [online, setOnline] = useState(initial);
|
||||||
|
const [live, setLive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const events = new EventSource("/api/v1/online/events");
|
||||||
|
events.addEventListener("online", (e) => {
|
||||||
|
setOnline((JSON.parse(e.data) as { online: OnlineEntry[] }).online);
|
||||||
|
setLive(true);
|
||||||
|
});
|
||||||
|
events.addEventListener("error", () => setLive(false));
|
||||||
|
return () => events.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={live ? "size-2 rounded-full bg-emerald-500" : "size-2 rounded-full bg-muted-foreground/40"}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{live ? "Live" : "Connecting…"}
|
||||||
|
<span className="ml-auto tabular-nums">
|
||||||
|
{online.length} {online.length === 1 ? "account" : "accounts"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{online.length === 0 ? (
|
||||||
|
<p className="py-6 text-sm text-muted-foreground">Nobody is showing that they're online right now.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{online.map((o) => (
|
||||||
|
<li key={o.imvuId} className="flex gap-4 py-4 first:pt-0 last:pb-0">
|
||||||
|
<Initial name={o.username} />
|
||||||
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{o.username}</span>
|
||||||
|
{o.badge && <Tag tone="strong">T5DE</Tag>}
|
||||||
|
<Tag tone="online" dot>
|
||||||
|
online since <Since iso={o.since} />
|
||||||
|
</Tag>
|
||||||
|
{o.hidden && <Tag>hidden: moderators only</Tag>}
|
||||||
|
{moderator && o.adult && <Tag tone="adult">adult</Tag>}
|
||||||
|
{moderator && o.personId && (
|
||||||
|
<Link
|
||||||
|
href={`/console/people/${o.personId}`}
|
||||||
|
className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||||
|
>
|
||||||
|
person
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Outfit items={o.outfit} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Small labels. Color only means something: green is online, blue is age verified or adult, red is banned.
|
||||||
|
import type { Link } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const tones = {
|
||||||
|
neutral: "border-border bg-muted/60 text-muted-foreground",
|
||||||
|
strong: "border-foreground/15 bg-foreground/5 text-foreground",
|
||||||
|
online: "border-emerald-600/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
||||||
|
adult: "border-sky-600/20 bg-sky-500/10 text-sky-700 dark:text-sky-400",
|
||||||
|
danger: "border-red-600/20 bg-red-500/10 text-red-700 dark:text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Tag({
|
||||||
|
tone = "neutral",
|
||||||
|
dot = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tone?: keyof typeof tones;
|
||||||
|
dot?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium whitespace-nowrap",
|
||||||
|
tones[tone],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{dot && <span className="size-1.5 rounded-full bg-current" aria-hidden />}
|
||||||
|
<span>{children}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A round initial standing in for an avatar picture. */
|
||||||
|
export function Initial({ name }: { name: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid size-10 shrink-0 place-items-center rounded-full border bg-muted text-sm font-semibold text-muted-foreground uppercase"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{name.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The community products an account wears. */
|
||||||
|
export function Outfit({ items }: { items: { id: string; name: string }[] }) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-xs text-muted-foreground">Wearing</div>
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="rounded-md border bg-background px-2 py-0.5 text-xs">
|
||||||
|
{item.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page's title and the line under it. */
|
||||||
|
export function PageHeader({ title, children }: { title: React.ReactNode; children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8 space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h1>
|
||||||
|
{children && <div className="text-muted-foreground">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An IMVU account's age status: blue when it can see adult community products. */
|
||||||
|
export function AgeTag({ link }: { link: Pick<Link, "ageVerified" | "accessPass"> }) {
|
||||||
|
if (link.ageVerified) return <Tag tone="adult">Age verified</Tag>;
|
||||||
|
if (link.accessPass) return <Tag tone="adult">Access Pass</Tag>;
|
||||||
|
return <Tag>Not age verified</Tag>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
// Times in the visitor's own time zone and language. The server doesn't know either, so it renders times in UTC and
|
||||||
|
// the browser replaces them once the page is running.
|
||||||
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const noSubscription = () => () => {};
|
||||||
|
|
||||||
|
/** False on the server and while hydrating, true after. */
|
||||||
|
function useInBrowser(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
noSubscription,
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
["year", 365 * 24 * 3600],
|
||||||
|
["month", 30 * 24 * 3600],
|
||||||
|
["week", 7 * 24 * 3600],
|
||||||
|
["day", 24 * 3600],
|
||||||
|
["hour", 3600],
|
||||||
|
["minute", 60],
|
||||||
|
];
|
||||||
|
const relative = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||||
|
|
||||||
|
function ago(iso: string, now: number): string {
|
||||||
|
const seconds = (new Date(iso).getTime() - now) / 1000;
|
||||||
|
for (const [unit, size] of units) {
|
||||||
|
if (Math.abs(seconds) >= size) return relative.format(Math.round(seconds / size), unit);
|
||||||
|
}
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "5 minutes ago", kept up to date. */
|
||||||
|
export function TimeAgo({ iso }: { iso: string }) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 30_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
const local = useInBrowser();
|
||||||
|
return (
|
||||||
|
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
||||||
|
{local ? ago(iso, now) : format(iso, { dateStyle: "medium" }, false)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(iso: string, options: Intl.DateTimeFormatOptions, local: boolean): string {
|
||||||
|
return new Date(iso).toLocaleString(local ? undefined : "en", local ? options : { ...options, timeZone: "UTC" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date (and time). */
|
||||||
|
export function DateTime({ iso, dateOnly = false }: { iso: string; dateOnly?: boolean }) {
|
||||||
|
const local = useInBrowser();
|
||||||
|
const options: Intl.DateTimeFormatOptions = dateOnly
|
||||||
|
? { dateStyle: "medium" }
|
||||||
|
: { dateStyle: "medium", timeStyle: "short" };
|
||||||
|
return <time dateTime={iso}>{format(iso, options, local)}</time>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A time of day ("3:04 PM"), with the date too when it isn't today. */
|
||||||
|
export function Since({ iso }: { iso: string }) {
|
||||||
|
const local = useInBrowser();
|
||||||
|
const today = local && new Date(iso).toDateString() === new Date().toDateString();
|
||||||
|
const options: Intl.DateTimeFormatOptions = today
|
||||||
|
? { hour: "numeric", minute: "2-digit" }
|
||||||
|
: { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" };
|
||||||
|
return (
|
||||||
|
<time dateTime={iso} title={format(iso, { dateStyle: "medium", timeStyle: "short" }, local)}>
|
||||||
|
{format(iso, options, local)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||||
|
horizontal:
|
||||||
|
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
responsive:
|
||||||
|
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1",
|
||||||
|
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueErrors = [
|
||||||
|
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (uniqueErrors?.length == 1) {
|
||||||
|
return uniqueErrors[0]?.message
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{uniqueErrors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}, [children, errors])
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-sm font-normal text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelect({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: NativeSelectProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-slot="native-select-wrapper"
|
||||||
|
data-size={size}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
data-slot="native-select"
|
||||||
|
data-size={size}
|
||||||
|
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"option">) {
|
||||||
|
return (
|
||||||
|
<option
|
||||||
|
data-slot="native-select-option"
|
||||||
|
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NativeSelectOptGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"optgroup">) {
|
||||||
|
return (
|
||||||
|
<optgroup
|
||||||
|
data-slot="native-select-optgroup"
|
||||||
|
className={cn("bg-[Canvas] text-[CanvasText]", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: SwitchPrimitive.Root.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "cn"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Server actions answer forms with an ActionState: an error to show, or a notice.
|
||||||
|
import "server-only";
|
||||||
|
import { ApiError } from "./api";
|
||||||
|
|
||||||
|
export type ActionState = { error?: string; notice?: string } | null;
|
||||||
|
|
||||||
|
/** Runs fn, turning the API's errors into a message for the form; fn may return a notice. */
|
||||||
|
export async function attempt(fn: () => Promise<string | void>): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const notice = await fn();
|
||||||
|
return notice ? { notice } : {};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.message };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A form field as a string ("" when missing). */
|
||||||
|
export function field(form: FormData, name: string): string {
|
||||||
|
const v = form.get(name);
|
||||||
|
return typeof v === "string" ? v.trim() : "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// The hub's API, called from server components and server actions. Calls go to the API's internal address
|
||||||
|
// (API_URL) with the visitor's cookie, so the API sees the same session the browser has.
|
||||||
|
import "server-only";
|
||||||
|
import { treaty } from "@elysiajs/eden";
|
||||||
|
import type { App } from "@t5de/api";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
|
const apiURL = process.env.API_URL ?? "http://localhost:4101";
|
||||||
|
|
||||||
|
/** The visitor's cookie and address, to pass on to the API. */
|
||||||
|
async function forwarded(): Promise<Record<string, string>> {
|
||||||
|
const incoming = await headers();
|
||||||
|
const forward: Record<string, string> = {};
|
||||||
|
for (const name of ["cookie", "x-forwarded-for", "user-agent"]) {
|
||||||
|
const value = incoming.get(name);
|
||||||
|
if (value) forward[name] = value;
|
||||||
|
}
|
||||||
|
return forward;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API's /api/v1 routes, as the person making this request. (Never await the client itself: it's a proxy, and
|
||||||
|
* awaiting it would call a route named "then".)
|
||||||
|
*/
|
||||||
|
export const api = treaty<App>(apiURL, { headers: forwarded }).api.v1;
|
||||||
|
|
||||||
|
type V1 = typeof api;
|
||||||
|
type Problem = { error: string; message: string };
|
||||||
|
/** What a route answers when it succeeds (the API's error shape is part of its declared answers). */
|
||||||
|
type Ok<T> = Exclude<T, Problem>;
|
||||||
|
type Data<Route extends (...args: any[]) => Promise<{ data: unknown }>> = Ok<
|
||||||
|
NonNullable<Awaited<ReturnType<Route>>["data"]>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Person = Data<V1["me"]["get"]>["person"];
|
||||||
|
export type Account = Data<V1["account"]["get"]>;
|
||||||
|
export type Link = Account["links"][number];
|
||||||
|
export type Challenge = Account["challenges"][number];
|
||||||
|
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
|
||||||
|
export type ConsoleData = Data<V1["console"]["get"]>;
|
||||||
|
export type AuditEntry = ConsoleData["audit"][number];
|
||||||
|
|
||||||
|
/** An error the API answered: message is written to be shown as is. */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly slug: string,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Answer<T> = { data: T; error: null } | { data: null; error: { status: unknown; value: unknown } };
|
||||||
|
|
||||||
|
/** The answer's data, or throws ApiError with the API's message. */
|
||||||
|
export function unwrap<T>(answer: Answer<T>): Ok<NonNullable<T>> {
|
||||||
|
if (answer.error) {
|
||||||
|
const status = typeof answer.error.status === "number" ? answer.error.status : 500;
|
||||||
|
const value = answer.error.value as { error?: unknown; message?: unknown } | null;
|
||||||
|
const slug = typeof value?.error === "string" ? value.error : "error";
|
||||||
|
const message =
|
||||||
|
typeof value?.message === "string" && value.message
|
||||||
|
? value.message
|
||||||
|
: "Something went wrong. Try again in a moment.";
|
||||||
|
throw new ApiError(status, slug, message);
|
||||||
|
}
|
||||||
|
return answer.data as Ok<NonNullable<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who's signed in, or undefined. Once per request. */
|
||||||
|
export const getMe = cache(async (): Promise<Person | undefined> => {
|
||||||
|
const { data } = await api.me.get();
|
||||||
|
return data && "person" in data ? data.person : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin";
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Better Auth in the browser: sign-up, sign-in and sign-out. Its requests go to /api/auth on this site, which Next
|
||||||
|
// rewrites to the API, so the session cookie is set for this site.
|
||||||
|
"use client";
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { usernameClient } from "better-auth/client/plugins";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: typeof window === "undefined" ? undefined : window.location.origin,
|
||||||
|
plugins: [usernameClient()],
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** next if it's a path on this site, else fallback (so ?next= can't send people elsewhere). */
|
||||||
|
export function safeNext(next: string | string[] | undefined | null, fallback = "/account"): string {
|
||||||
|
if (typeof next !== "string") return fallback;
|
||||||
|
if (!next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return fallback;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { cn } from "cn";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
// The API's internal address. The browser never calls it directly: /api is rewritten to it, so the API's session
|
||||||
|
// cookies are first-party on the website's origin. Rewrites are fixed when the app is built, so API_URL must be set
|
||||||
|
// at build time as well as at run time (server components call it directly).
|
||||||
|
const apiURL = process.env.API_URL ?? "http://localhost:4101";
|
||||||
|
|
||||||
|
// The monorepo's root: standalone output traces workspace packages from here.
|
||||||
|
const root = path.join(import.meta.dirname, "../..");
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
outputFileTracingRoot: root,
|
||||||
|
turbopack: { root },
|
||||||
|
// Next's gzip holds back the API's event stream (who's online); compress in front of the app instead (Vercel and
|
||||||
|
// most reverse proxies do).
|
||||||
|
compress: false,
|
||||||
|
agentRules: false,
|
||||||
|
async rewrites() {
|
||||||
|
return [{ source: "/api/:path*", destination: `${apiURL}/api/:path*` }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@t5de/web",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 4100",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 4100",
|
||||||
|
"check-types": "next typegen && tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.9.0",
|
||||||
|
"@elysiajs/eden": "1.4.9",
|
||||||
|
"@t5de/api": "workspace:*",
|
||||||
|
"better-auth": "1.7.14",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cn": "^0.5.0",
|
||||||
|
"lucide-react": "^1.64.0",
|
||||||
|
"next": "16.4.3",
|
||||||
|
"react": "19.3.1",
|
||||||
|
"react-dom": "19.3.1",
|
||||||
|
"server-only": "^0.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/node": "^26.6.6",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"elysia": "1.4.30",
|
||||||
|
"shadcn": "^4.23.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) }
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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.")
|
|
||||||
}
|
|
||||||
@@ -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+".")
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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 +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 |
Vendored
-1
@@ -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
|
|
||||||
@@ -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; }
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -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}}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{{template "title" .}} · T5DE Hub</title>
|
|
||||||
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
|
|
||||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/htmx.min.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body hx-headers='{"X-CSRF-Token": "{{.CSRF}}"}'>
|
|
||||||
<header class="top">
|
|
||||||
<div class="wrap">
|
|
||||||
<a class="brand" href="/"><span class="logo">T5DE</span> Hub</a>
|
|
||||||
<nav>
|
|
||||||
<a href="/online"{{if eq .Page "online"}} class="here"{{end}}>Online</a>
|
|
||||||
{{if .Person}}
|
|
||||||
<a href="/account"{{if eq .Page "account"}} class="here"{{end}}>Account</a>
|
|
||||||
{{if .Person.IsModerator}}<a href="/console"{{if eq .Page "console"}} class="here"{{end}}>Console</a>{{end}}
|
|
||||||
<span class="right">
|
|
||||||
<span class="muted small">{{.Person.Username}}</span>
|
|
||||||
<form method="post" action="/signout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="secondary">Sign out</button></form>
|
|
||||||
</span>
|
|
||||||
{{else}}
|
|
||||||
<span class="right">
|
|
||||||
<a href="/signin">Sign in</a>
|
|
||||||
<a class="button" href="/signup">Create account</a>
|
|
||||||
</span>
|
|
||||||
{{end}}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="wrap">
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
|
||||||
{{template "content" .}}
|
|
||||||
</main>
|
|
||||||
<footer class="wrap muted">T5DE is unofficial: not made by or affiliated with IMVU.</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user