diff --git a/packages/api/src/lib/handle.ts b/packages/api/src/lib/handle.ts index b3f6c57..7617cda 100644 --- a/packages/api/src/lib/handle.ts +++ b/packages/api/src/lib/handle.ts @@ -96,3 +96,39 @@ export function validateUsername(raw: string): UsernameValidation { } return { ok: true, value }; } + +/** + * Wandelt den anzeigefertigen Handle in eine URL-taugliche Form um: + * "KochHeld#A12B" -> "KochHeld-A12B". Das '#' ist als URL-Fragment-Zeichen + * nicht im Pfad nutzbar, daher der Bindestrich als Trenner. + */ +export function handleToSlug( + username: string | null | undefined, + discriminator: string | null | undefined, +): string | null { + if (!username || !discriminator) return null; + return `${username}-${discriminator}`; +} + +/** + * Zerlegt einen URL-Slug ("KochHeld-A12B") zurück in Username + Discriminator. + * Es wird am LETZTEN Bindestrich getrennt, weil der Username selbst Bindestriche + * enthalten darf. Gibt null zurück, wenn der Slug keine gültige Form hat. + */ +export function parseHandleSlug( + slug: string, +): { username: string; discriminator: string } | null { + const trimmed = slug.trim(); + const lastDash = trimmed.lastIndexOf("-"); + if (lastDash <= 0 || lastDash === trimmed.length - 1) return null; + + const username = trimmed.slice(0, lastDash); + const discriminator = trimmed.slice(lastDash + 1).toUpperCase(); + + if (validateUsername(username).ok !== true) return null; + if (discriminator.length !== DISCRIMINATOR_LENGTH) return null; + for (const ch of discriminator) { + if (!DISCRIMINATOR_ALPHABET.includes(ch)) return null; + } + return { username, discriminator }; +} diff --git a/packages/api/src/trpc/handle.test.ts b/packages/api/src/trpc/handle.test.ts index 5c41f95..e945d15 100644 --- a/packages/api/src/trpc/handle.test.ts +++ b/packages/api/src/trpc/handle.test.ts @@ -7,6 +7,8 @@ import { formatHandle, generateDiscriminator, generateUniqueDiscriminator, + handleToSlug, + parseHandleSlug, validateUsername, } from "../lib/handle"; @@ -65,11 +67,11 @@ test("generateUniqueDiscriminator wirft, wenn nach maxAttempts kein freier gefun }); test("formatHandle baut Username#Discriminator", () => { - assert.equal(formatHandle("Held", "A12B"), "Held#A12B"); + assert.equal(formatHandle("Held", "A23B"), "Held#A23B"); }); test("formatHandle liefert null ohne Username", () => { - assert.equal(formatHandle(null, "A12B"), null); + assert.equal(formatHandle(null, "A23B"), null); }); test("formatHandle liefert null ohne Discriminator", () => { @@ -100,3 +102,50 @@ test("validateUsername lehnt Umlaute ab (Handle bleibt URL-tauglich)", () => { test("Bio-Limit ist 280", () => { assert.equal(BIO_MAX_LENGTH, 280); }); + +test("handleToSlug baut Username-Discriminator (URL-tauglich, kein #)", () => { + assert.equal(handleToSlug("KochHeld", "A23B"), "KochHeld-A23B"); +}); + +test("handleToSlug liefert null ohne vollstaendigen Handle", () => { + assert.equal(handleToSlug(null, "A23B"), null); + assert.equal(handleToSlug("KochHeld", null), null); +}); + +test("parseHandleSlug zerlegt am letzten Bindestrich", () => { + assert.deepEqual(parseHandleSlug("KochHeld-A23B"), { + username: "KochHeld", + discriminator: "A23B", + }); +}); + +test("parseHandleSlug behaelt Bindestriche im Username", () => { + assert.deepEqual(parseHandleSlug("Koch-Held-A23B"), { + username: "Koch-Held", + discriminator: "A23B", + }); +}); + +test("parseHandleSlug normalisiert den Discriminator auf Grossschreibung", () => { + assert.deepEqual(parseHandleSlug("KochHeld-a23b"), { + username: "KochHeld", + discriminator: "A23B", + }); +}); + +test("parseHandleSlug lehnt ungueltige Slugs ab", () => { + assert.equal(parseHandleSlug("KochHeld"), null); // kein Discriminator + assert.equal(parseHandleSlug("KochHeld-"), null); // leerer Discriminator + assert.equal(parseHandleSlug("-A23B"), null); // leerer Username + assert.equal(parseHandleSlug("KochHeld-A1"), null); // Discriminator zu kurz + assert.equal(parseHandleSlug("KochHeld-A1IB"), null); // I nicht im Alphabet +}); + +test("handleToSlug und parseHandleSlug sind invers", () => { + const slug = handleToSlug("Koch-Held", "A23B"); + assert.ok(slug); + assert.deepEqual(parseHandleSlug(slug), { + username: "Koch-Held", + discriminator: "A23B", + }); +}); diff --git a/packages/api/src/trpc/routers/users.ts b/packages/api/src/trpc/routers/users.ts index 5718002..6ca11cf 100644 --- a/packages/api/src/trpc/routers/users.ts +++ b/packages/api/src/trpc/routers/users.ts @@ -1,6 +1,6 @@ import { schema } from "@hellth/db"; import { TRPCError } from "@trpc/server"; -import { asc, eq, sql } from "drizzle-orm"; +import { and, asc, eq, gte, sql } from "drizzle-orm"; import { z } from "zod"; import { auth } from "../../lib/auth"; import { @@ -9,6 +9,7 @@ import { USERNAME_MIN_LENGTH, formatHandle, generateUniqueDiscriminator, + parseHandleSlug, validateUsername, } from "../../lib/handle"; import { auditSecurityEvent } from "../../lib/security-audit"; @@ -214,6 +215,91 @@ export const usersRouter = router({ return { ok: true }; }), + // Öffentliches Fremdprofil per Handle-Slug (z. B. "KochHeld-A12B"). + // DATENSCHUTZ: liefert NUR, wenn der Eigentümer profileVisible=true gesetzt + // hat (sonst NOT_FOUND, damit die Existenz nicht durchsickert). Strikte + // Feld-Whitelist — ausschließlich öffentliche Identitäts- und Aggregatdaten, + // NIEMALS Art.-9-Gesundheitsdaten (Gewicht/Kalorien/Ziele) oder Kontaktdaten. + publicProfileByHandle: protectedProcedure + .input(z.object({ handle: z.string().min(1).max(64) })) + .query(async ({ ctx, input }) => { + const parsed = parseHandleSlug(input.handle); + if (!parsed) { + throw new TRPCError({ code: "NOT_FOUND", message: "Profil nicht gefunden" }); + } + + const target = await ctx.db.query.user.findFirst({ + where: (row, { and: andWhere, eq: rowEq }) => + andWhere( + rowEq(row.username, parsed.username), + rowEq(row.discriminator, parsed.discriminator), + ), + columns: { + id: true, + username: true, + discriminator: true, + bio: true, + image: true, + profileVisible: true, + }, + }); + + // Privacy-Gate serverseitig: privates oder nicht existierendes Profil + // ist von außen nicht unterscheidbar (immer NOT_FOUND). + if (!target || !target.profileVisible) { + throw new TRPCError({ code: "NOT_FOUND", message: "Profil nicht gefunden" }); + } + + // Aggregierte Aktivitäts-Punkte (kein Health-Detail). 30-Tage-Fenster + // über das date-Feld (YYYY-MM-DD) der activity_logs. + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); + const since = thirtyDaysAgo.toISOString().slice(0, 10); + + const [totalRow] = await ctx.db + .select({ points: sql`coalesce(sum(${schema.activityLogs.points}), 0)` }) + .from(schema.activityLogs) + .where(eq(schema.activityLogs.userId, target.id)); + + const [recentRow] = await ctx.db + .select({ points: sql`coalesce(sum(${schema.activityLogs.points}), 0)` }) + .from(schema.activityLogs) + .where( + and( + eq(schema.activityLogs.userId, target.id), + gte(schema.activityLogs.date, since), + ), + ); + + // Anonymisierte Aktivitäts-Streak: Anzahl distinkter Tage mit Aktivität + // in den letzten 7 Tagen. Nur "wie aktiv", keine Inhalte/Werte. + const sevenDaysAgo = new Date(); + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); + const since7 = sevenDaysAgo.toISOString().slice(0, 10); + const [streakRow] = await ctx.db + .select({ days: sql`count(distinct ${schema.activityLogs.date})` }) + .from(schema.activityLogs) + .where( + and( + eq(schema.activityLogs.userId, target.id), + gte(schema.activityLogs.date, since7), + ), + ); + + return { + handle: formatHandle(target.username, target.discriminator), + username: target.username, + discriminator: target.discriminator, + bio: target.bio, + image: target.image, + points: { + total: Number(totalRow?.points ?? 0), + last30Days: Number(recentRow?.points ?? 0), + }, + activeDaysLast7: Number(streakRow?.days ?? 0), + }; + }), + setTheme: protectedProcedure .input(z.object({ theme: z.enum(["dark", "light"]).nullable() })) .mutation(async ({ ctx, input }) => { diff --git a/packages/api/src/trpc/users.test.ts b/packages/api/src/trpc/users.test.ts new file mode 100644 index 0000000..8a48ced --- /dev/null +++ b/packages/api/src/trpc/users.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { appRouter } from "./index"; + +// Schlanker Mock nur für users.publicProfileByHandle. Deckt sowohl +// query.user.findFirst (Profil-Lookup) als auch den select().from().where() +// Query-Builder (Punkte-Aggregate) ab. + +type UserRow = { + id: string; + username: string | null; + discriminator: string | null; + bio: string | null; + image: string | null; + profileVisible: boolean; +} | null; + +function createContext(targetUser: UserRow, aggregates: { total: number; recent: number; days: number }) { + const user = { id: "viewer", email: "viewer@example.org", role: "user" }; + + // Reihenfolge der select()-Aufrufe im Endpoint: total, recent(30T), streak(7T). + const selectResults = [ + [{ points: aggregates.total }], + [{ points: aggregates.recent }], + [{ days: aggregates.days }], + ]; + let selectIndex = 0; + + const db = { + query: { + user: { + findFirst: async () => targetUser, + }, + }, + select() { + const result = selectResults[selectIndex++] ?? []; + return { + from() { + return { + where() { + return Promise.resolve(result); + }, + }; + }, + }; + }, + }; + + return { + db, + headers: new Headers(), + session: { user }, + user, + userRole: "user", + isPlatformAdmin: false, + }; +} + +const visibleUser: UserRow = { + id: "target-1", + username: "KochHeld", + discriminator: "A23B", + bio: "Mag Meal Prep.", + image: null, + profileVisible: true, +}; + +test("publicProfileByHandle liefert sichtbares Profil mit Whitelist-Feldern + Aggregaten", async () => { + const caller = appRouter.createCaller( + createContext(visibleUser, { total: 340, recent: 80, days: 5 }) as never, + ); + + const result = await caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" }); + + assert.equal(result.handle, "KochHeld#A23B"); + assert.equal(result.username, "KochHeld"); + assert.equal(result.bio, "Mag Meal Prep."); + assert.equal(result.points.total, 340); + assert.equal(result.points.last30Days, 80); + assert.equal(result.activeDaysLast7, 5); +}); + +test("publicProfileByHandle leakt KEINE Health-/Kontaktfelder", async () => { + const caller = appRouter.createCaller( + createContext(visibleUser, { total: 0, recent: 0, days: 0 }) as never, + ); + + const result = await caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" }); + + for (const forbidden of ["email", "id", "weightKg", "calories", "profileVisible", "role", "createdAt"]) { + assert.equal(forbidden in result, false, `Feld ${forbidden} darf nicht geliefert werden`); + } +}); + +test("publicProfileByHandle wirft NOT_FOUND bei privatem Profil", async () => { + const caller = appRouter.createCaller( + createContext({ ...visibleUser, profileVisible: false }, { total: 0, recent: 0, days: 0 }) as never, + ); + + await assert.rejects( + () => caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" }), + (error: unknown) => { + assert.equal((error as { code?: string }).code, "NOT_FOUND"); + return true; + }, + ); +}); + +test("publicProfileByHandle wirft NOT_FOUND bei unbekanntem Handle", async () => { + const caller = appRouter.createCaller( + createContext(null, { total: 0, recent: 0, days: 0 }) as never, + ); + + await assert.rejects( + () => caller.users.publicProfileByHandle({ handle: "Niemand-Z9Z9" }), + (error: unknown) => { + assert.equal((error as { code?: string }).code, "NOT_FOUND"); + return true; + }, + ); +}); + +test("publicProfileByHandle wirft NOT_FOUND bei ungueltigem Slug", async () => { + const caller = appRouter.createCaller( + createContext(visibleUser, { total: 0, recent: 0, days: 0 }) as never, + ); + + await assert.rejects( + () => caller.users.publicProfileByHandle({ handle: "KeinDiscriminator" }), + (error: unknown) => { + assert.equal((error as { code?: string }).code, "NOT_FOUND"); + return true; + }, + ); +}); + +test("publicProfileByHandle erfordert Authentifizierung", async () => { + const caller = appRouter.createCaller({ + db: {}, + headers: new Headers(), + session: null, + user: null, + userRole: "user", + isPlatformAdmin: false, + } as never); + + await assert.rejects( + () => caller.users.publicProfileByHandle({ handle: "KochHeld-A23B" }), + (error: unknown) => { + assert.equal((error as { code?: string }).code, "UNAUTHORIZED"); + return true; + }, + ); +});