diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7f569fc..896047d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { Elysia, t } from "elysia"; import { and, + asc, desc, eq, ilike, @@ -151,6 +152,30 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms banReason: p.banReason ?? "", }); + /** A person's picture, for the website's header: their first linked IMVU account's avatar picture (null if none, + * or IMVU can't say). Kept an hour per account, so pages don't each ask IMVU. */ + const pictures = new Map(); + const picture = async (userId: string): Promise => { + const [first] = await db + .select({ imvuId: imvuLink.imvuId }) + .from(imvuLink) + .where(eq(imvuLink.userId, userId)) + .orderBy(asc(imvuLink.linkedAt)) + .limit(1); + if (!first) return null; + const kept = pictures.get(first.imvuId); + if (kept && Date.now() - kept.at < 60 * 60 * 1000) return kept.url; + let url: string | null = kept?.url ?? null; + try { + url = (await imvu.byId(first.imvuId)).picture; + } catch { + // IMVU unavailable: the last known one (or none), asked again next time. + return url; + } + pictures.set(first.imvuId, { url, at: Date.now() }); + return url; + }; + /** Links as the website shows them: with live presence, and age by the one rule. */ const linkViews = async (userId: string, links: Link[]) => { const ids = links.map((l) => l.imvuId); @@ -442,7 +467,7 @@ export function createApp({ db, auth, imvu, realtime: rt, age: ageService, rooms }) // Who's signed in, and the home page. - .get("/me", ({ me }) => ({ person: personView(me) }), { signedIn: true }) + .get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { signedIn: true }) .get("/home", async ({ person }) => { const online = (await visibleOnline(person)).length; if (!person) return { onlineCount: online }; diff --git a/apps/api/src/imvu.ts b/apps/api/src/imvu.ts index d03f622..4608e2c 100644 --- a/apps/api/src/imvu.ts +++ b/apps/api/src/imvu.ts @@ -8,6 +8,7 @@ export type ImvuUser = { tagline: string; ageVerified: boolean; accessPass: boolean; + picture: string | null; // the avatar's picture (https, on *.imvu.com) }; export type ImvuRoom = { @@ -182,5 +183,6 @@ export function parse(answer: unknown): ImvuUser { tagline: typeof data.tagline === "string" ? data.tagline : "", ageVerified: data.is_ageverified === true, accessPass: data.is_ap === true, + picture: imvuImage(data.thumbnail_url), }; } diff --git a/apps/api/test/age.test.ts b/apps/api/test/age.test.ts index 89eeffa..455d983 100644 --- a/apps/api/test/age.test.ts +++ b/apps/api/test/age.test.ts @@ -78,7 +78,7 @@ async function personWithAccounts(name: string, ids: { id: number; ageVerified?: const cookie = { v: "" }; await call(cookie, "POST", "/api/auth/sign-up/email", { email: `${name}@example.test`, password: "a long password", name, username: name }); for (const a of ids) { - accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass }); + accounts.set(a.id, { id: a.id, username: `${name}${a.id}`, tagline: "", ageVerified: !!a.ageVerified, accessPass: !!a.accessPass, picture: null }); const started = await call(cookie, "POST", "/api/v1/account/links", { username: `${name}${a.id}` }); accounts.get(a.id)!.tagline = started.json.challenge.code; expect((await call(cookie, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); diff --git a/apps/api/test/badges.test.ts b/apps/api/test/badges.test.ts index 924f738..eb5c4ca 100644 --- a/apps/api/test/badges.test.ts +++ b/apps/api/test/badges.test.ts @@ -67,7 +67,7 @@ async function person(name: string, ...ids: number[]): Promise { expect(p.token).not.toBe(""); for (const [i, id] of ids.entries()) { const username = `${name}Av${i}`; - accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false }); + accounts.set(id, { id, username, tagline: "", ageVerified: false, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username }); accounts.get(id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${id}/check`)).status).toBe(200); diff --git a/apps/api/test/decorations.test.ts b/apps/api/test/decorations.test.ts index c7e802f..a221859 100644 --- a/apps/api/test/decorations.test.ts +++ b/apps/api/test/decorations.test.ts @@ -70,7 +70,7 @@ async function person(name: string, ...imvu: { id: number; ageVerified?: boolean p.id = (await call(p, "GET", "/api/v1/me")).json.person.id; for (const [i, a] of imvu.entries()) { const username = `${name}Av${i || ""}`; - accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false }); + accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username }); accounts.get(a.id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); diff --git a/apps/api/test/hosted.test.ts b/apps/api/test/hosted.test.ts index 52f0d3f..e67993f 100644 --- a/apps/api/test/hosted.test.ts +++ b/apps/api/test/hosted.test.ts @@ -66,7 +66,7 @@ async function person(name: string, ...imvu: { id: number; ageVerified?: boolean expect(p.token).not.toBe(""); for (const [i, a] of imvu.entries()) { const username = `${name}Av${i || ""}`; - accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false }); + accounts.set(a.id, { id: a.id, username, tagline: "", ageVerified: !!a.ageVerified, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username }); accounts.get(a.id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${a.id}/check`)).status).toBe(200); @@ -330,7 +330,7 @@ test("moderators boot and ban; owners lift bans", async () => { const mod = await person("modr", { id: 602 }); const usr = await person("usrr", { id: 603 }); const md2 = await person("mod2", { id: 604 }); - accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false }); // IMVU only + accounts.set(605, { id: 605, username: "NotOnT5", tagline: "", ageVerified: false, accessPass: false, picture: null }); // IMVU only const n = (await call(own, "POST", "/api/v1/hosted-rooms", newRoom(601))).json.room.n; // Moderators: IDs or names (linked accounts, else IMVU); the owner's own account is left out; up to 20. diff --git a/apps/api/test/hub.test.ts b/apps/api/test/hub.test.ts index 35e37d3..8e85724 100644 --- a/apps/api/test/hub.test.ts +++ b/apps/api/test/hub.test.ts @@ -130,14 +130,14 @@ 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 }); + accounts.set(42, { id: 42, username: "CarolAv", tagline: "", ageVerified: true, accessPass: false, picture: null }); const started = await carol.call("POST", "/api/v1/account/links", { username: "carolav" }); 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 }); + const linked = await link(carol, { id: 42, username: "CarolAv", ageVerified: true, accessPass: false, picture: null }); 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( @@ -148,7 +148,7 @@ describe("linking IMVU accounts", () => { 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 }); + const moved = await link(dave, { id: 42, username: "CarolAv", ageVerified: false, accessPass: false, picture: null }); 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); @@ -160,7 +160,7 @@ describe("linking IMVU accounts", () => { 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 }); + const over = await link(dave, { id: 43, username: "DaveAlt", ageVerified: false, accessPass: false, picture: null }); expect(over.status).toBe(403); expect(over.json.error).toBe("link_cap"); }); @@ -170,7 +170,7 @@ describe("T5DE's proxy", () => { test("signs in with a device code, connects, shows presence; a ban ends it", async () => { const erin = new Browser(); await erin.signup("erin"); - await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false }); + await link(erin, { id: 77, username: "ErinAv", ageVerified: false, accessPass: false, picture: null }); // The proxy asks for a code; until it's approved, the token request waits. const codeRes = await fetch(`${base}/api/auth/device/code`, { @@ -216,7 +216,7 @@ describe("T5DE's proxy", () => { // An account linked while the client runs counts at once. ws.send(JSON.stringify({ type: "session", imvu_id: 78 })); await waitFor(() => messages.filter((m) => m.message === "not_linked").length === 2); - await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false }); + await link(erin, { id: 78, username: "ErinAlt", ageVerified: false, accessPass: false, picture: null }); await waitFor(() => hub.realtime.online().some((o) => o.imvuId === 78)); await erin.call("DELETE", "/api/v1/account/links/78"); await waitFor(() => !hub.realtime.online().some((o) => o.imvuId === 78)); diff --git a/apps/api/test/products.test.ts b/apps/api/test/products.test.ts index 6c73663..277263d 100644 --- a/apps/api/test/products.test.ts +++ b/apps/api/test/products.test.ts @@ -4,7 +4,7 @@ import { createLocalJWKSet, jwtVerify } from "jose"; import { openDatabase, type Database } from "@t5de/db"; import { createApp } from "../src/app"; import { createAuth } from "../src/auth"; -import { ImvuClient, imvuImage, parseProduct, type ImvuUser } from "../src/imvu"; +import { ImvuClient, imvuImage, parse, parseProduct, type ImvuUser } from "../src/imvu"; const webURL = "http://web.test"; const apiURL = "http://api.test"; @@ -77,7 +77,7 @@ async function person(name: string, imvu?: { id: number; ageVerified: boolean }) }); const p = { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }; if (imvu) { - accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false }); + accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: imvu.ageVerified, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` }); accounts.get(imvu.id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200); @@ -270,3 +270,11 @@ test("derived products carry their IMVU parent's image and categories", async () expect((await call(dresser, "PUT", `/api/v1/library/${coatID}`)).status).toBe(200); expect((await call(dresser, "GET", "/api/v1/library")).json.library[0].product).toMatchObject({ image: coat.product_image }); }); + +test("an IMVU account's avatar picture is kept only from IMVU's own hosts", () => { + const user = (thumbnail_url: unknown) => + parse({ denormalized: { "https://api.imvu.com/user/user-5": { data: { legacy_cid: 5, username: "Av", thumbnail_url } } } }); + expect(user("https://webasset-akm.imvu.com/resized_image/p.png").picture).toBe("https://webasset-akm.imvu.com/resized_image/p.png"); + expect(user("https://evil.example/p.png").picture).toBeNull(); + expect(user(undefined).picture).toBeNull(); +}); diff --git a/apps/api/test/rooms.test.ts b/apps/api/test/rooms.test.ts index a633923..a520604 100644 --- a/apps/api/test/rooms.test.ts +++ b/apps/api/test/rooms.test.ts @@ -88,7 +88,7 @@ async function person(name: string, imvu: { id: number; ageVerified?: boolean }) token: res.headers.get("set-auth-token") ?? "", }; expect(p.token).not.toBe(""); - accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false }); + accounts.set(imvu.id, { id: imvu.id, username: `${name}Av`, tagline: "", ageVerified: !!imvu.ageVerified, accessPass: false, picture: null }); const started = await call(p, "POST", "/api/v1/account/links", { username: `${name}Av` }); accounts.get(imvu.id)!.tagline = started.json.challenge.code; expect((await call(p, "POST", `/api/v1/account/links/${imvu.id}/check`)).status).toBe(200); diff --git a/apps/web/app/create/page.tsx b/apps/web/app/create/page.tsx index bae8c3e..4c92e3f 100644 --- a/apps/web/app/create/page.tsx +++ b/apps/web/app/create/page.tsx @@ -1,18 +1,6 @@ -import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { PageHeader } from "@/components/status"; -import { UploadCard } from "@/components/upload-card"; -import { getMe } from "@/lib/api"; -export const metadata: Metadata = { title: "Create" }; - -export default async function Create() { - const me = await getMe(); - if (!me) redirect("/signin?next=/create"); - return ( - <> - Upload it to a catalog. You can edit its info after. - - - ); +// Where creating a product used to be. +export default function Create() { + redirect("/products/new"); } diff --git a/apps/web/app/icon.png b/apps/web/app/icon.png new file mode 100644 index 0000000..058ffec Binary files /dev/null and b/apps/web/app/icon.png differ diff --git a/apps/web/app/icon.svg b/apps/web/app/icon.svg deleted file mode 100644 index 90ca9c0..0000000 --- a/apps/web/app/icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - T5 - diff --git a/apps/web/app/products/new/page.tsx b/apps/web/app/products/new/page.tsx new file mode 100644 index 0000000..c235704 --- /dev/null +++ b/apps/web/app/products/new/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { PageHeader } from "@/components/status"; +import { UploadCard } from "@/components/upload-card"; +import { getMe } from "@/lib/api"; + +export const metadata: Metadata = { title: "New product" }; + +export default async function NewProduct() { + const me = await getMe(); + if (!me) redirect("/signin?next=/products/new"); + return ( +
+ + Upload it to a catalog. You can edit its info after.{" "} + + Back to products + + + +
+ ); +} diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx index f9ab59c..0a9baa9 100644 --- a/apps/web/app/products/page.tsx +++ b/apps/web/app/products/page.tsx @@ -1,20 +1,28 @@ import type { Metadata } from "next"; import { Empty, ProductCard } from "@/components/products"; +import Link from "next/link"; import { PageHeader } from "@/components/status"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { api, unwrap } from "@/lib/api"; +import { api, getMe, unwrap } from "@/lib/api"; export const metadata: Metadata = { title: "Products" }; export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) { const raw = (await searchParams).q; const q = typeof raw === "string" ? raw.trim().slice(0, 100) : ""; - const { products } = await api.products.get({ query: q ? { q } : {} }).then(unwrap); + const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]); return ( <> - Community products for T5DE. +
+ Community products for T5DE. + {me && ( + + New product + + )} +
+ + + + {username} + + + + + + }> + Account + + }> + Library + + {console && ( + }> + Console + + )} + + { + setSigningOut(true); + await authClient.signOut(); + router.push("/"); + router.refresh(); + }} + > + {signingOut ? "Signing out…" : "Sign out"} + + + + + + ); +} + +/** A person's picture (their first linked IMVU account's avatar), or their initial. */ +function Avatar({ username, picture }: { username: string; picture: string | null }) { + const [failed, setFailed] = useState(false); + if (picture && !failed) { + return ( + setFailed(true)} className="size-7 rounded-full bg-muted object-cover object-top" /> + ); + } + return ( + + {username.slice(0, 1)} + ); } diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index a734312..deb45d9 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -1,17 +1,12 @@ +import Image from "next/image"; import Link from "next/link"; import { getMe, isModerator } from "@/lib/api"; import { buttonVariants } from "@/components/ui/button"; -import { NavLink, SignOutButton } from "./header-client"; +import { NavLink, ProfileMenu } from "./header-client"; +import logo from "./t5de.png"; -export function Logo() { - return ( - - T5 - - ); +export function Logo({ className }: { className?: string }) { + return T5DE; } export async function Header() { @@ -19,26 +14,18 @@ export async function Header() { return (
- + - T5DE Hub
{me ? ( - <> - {me.username} - - + ) : ( <> diff --git a/apps/web/components/t5de.png b/apps/web/components/t5de.png new file mode 100644 index 0000000..058ffec Binary files /dev/null and b/apps/web/components/t5de.png differ diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 463b2dd..c79bd72 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -81,4 +81,4 @@ export const getMe = cache(async (): Promise => { return data && "person" in data ? data.person : undefined; }); -export const isModerator = (p: Person | undefined) => p?.role === "moderator" || p?.role === "admin"; +export const isModerator = (p: Pick | undefined) => p?.role === "moderator" || p?.role === "admin"; diff --git a/docs/api.md b/docs/api.md index 21c00ad..2734209 100644 --- a/docs/api.md +++ b/docs/api.md @@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco | `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}` | +| `GET /api/v1/me` | `{person}`, with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` | ## Proxies