diff --git a/drizzle/migrations/0030_profile_privacy.sql b/drizzle/migrations/0030_profile_privacy.sql new file mode 100644 index 00000000..8b01cb17 --- /dev/null +++ b/drizzle/migrations/0030_profile_privacy.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS `website_profile_privacy` ( + `user_id` int NOT NULL, + `wallet` boolean NOT NULL DEFAULT false, + `online` boolean NOT NULL DEFAULT true, + `friends` boolean NOT NULL DEFAULT true, + `photos` boolean NOT NULL DEFAULT true, + `registered` boolean NOT NULL DEFAULT true, + PRIMARY KEY (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/actions/profile-privacy.test.ts b/src/actions/profile-privacy.test.ts new file mode 100644 index 00000000..d27f294a --- /dev/null +++ b/src/actions/profile-privacy.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + id: "42" as string | undefined, + values: null as Record | null, + fail: false, +})); +vi.mock("@/lib/auth", () => ({ + auth: async () => ({ user: { id: state.id } }), +})); +vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) })); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ + redirect: (path: string) => { + throw new Error(path); + }, +})); +vi.mock("@/lib/db", () => ({ + db: { + insert: () => ({ + values: (values: Record) => { + state.values = values; + return { + onDuplicateKeyUpdate: async () => { + if (state.fail) throw new Error("offline"); + }, + }; + }, + }), + }, +})); + +import { saveProfilePrivacy } from "./profile-privacy"; + +describe("save profile privacy", () => { + beforeEach(() => { + state.id = "42"; + state.values = null; + state.fail = false; + }); + it("uses the session owner even when another user is supplied in the form", async () => { + const form = new FormData(); + form.set("userId", "99"); + form.set("wallet", "on"); + await expect(saveProfilePrivacy(form)).rejects.toThrow( + "/settings?privacy=saved", + ); + expect(state.values).toEqual({ + userId: 42, + wallet: true, + online: false, + friends: false, + photos: false, + registered: false, + }); + }); + it("rejects unauthenticated writes", async () => { + state.id = undefined; + await expect(saveProfilePrivacy(new FormData())).rejects.toThrow("/login"); + expect(state.values).toBeNull(); + }); + it("never reports success after a database failure", async () => { + state.fail = true; + await expect(saveProfilePrivacy(new FormData())).rejects.toThrow( + "/settings?privacy=error", + ); + }); +}); diff --git a/src/actions/profile-privacy.ts b/src/actions/profile-privacy.ts new file mode 100644 index 00000000..29504582 --- /dev/null +++ b/src/actions/profile-privacy.ts @@ -0,0 +1,34 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { WebsiteProfilePrivacy } from "@/db/profile-privacy"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { rateLimit } from "@/lib/rate-limit"; +export async function saveProfilePrivacy(formData: FormData): Promise { + const session = await auth(); + const userId = Number(session?.user?.id); + if (!Number.isSafeInteger(userId) || userId <= 0) redirect("/login"); + let outcome = "error"; + try { + if ((await rateLimit(`profile-privacy:${userId}`, 10, 60_000)).ok) { + const values = { + wallet: formData.get("wallet") === "on", + online: formData.get("online") === "on", + friends: formData.get("friends") === "on", + photos: formData.get("photos") === "on", + registered: formData.get("registered") === "on", + }; + await db + .insert(WebsiteProfilePrivacy) + .values({ userId, ...values }) + .onDuplicateKeyUpdate({ set: values }); + revalidatePath("/u/[username]", "page"); + revalidatePath("/settings"); + outcome = "saved"; + } + } catch { + outcome = "error"; + } + redirect(`/settings?privacy=${outcome}#profile-privacy`); +} diff --git a/src/app/(site)/settings/ProfilePrivacySettings.tsx b/src/app/(site)/settings/ProfilePrivacySettings.tsx new file mode 100644 index 00000000..a01b7206 --- /dev/null +++ b/src/app/(site)/settings/ProfilePrivacySettings.tsx @@ -0,0 +1,60 @@ +import { getTranslations } from "next-intl/server"; +import { saveProfilePrivacy } from "@/actions/profile-privacy"; +import { SurfaceCard } from "@/components/surface-card"; +import { + loadProfilePrivacy, + profilePrivacyKeys, +} from "@/lib/services/profile-privacy"; +export default async function ProfilePrivacySettings({ + userId, + status, +}: { + userId: number; + status?: string; +}) { + const [privacy, t] = await Promise.all([ + loadProfilePrivacy(userId), + getTranslations("pages.profile.privacy"), + ]); + return ( +
+ +

{t("hint")}

+ {status === "saved" &&

{t("saved")}

} + {(privacy.unavailable || status === "error") && ( +

{t("error")}

+ )} +
+
+ {t("title")} + {profilePrivacyKeys.map((key) => ( + + ))} + +
+
+
+
+ ); +} diff --git a/src/app/(site)/settings/page.tsx b/src/app/(site)/settings/page.tsx index 9c30dea7..03f968fb 100644 --- a/src/app/(site)/settings/page.tsx +++ b/src/app/(site)/settings/page.tsx @@ -10,7 +10,13 @@ import { auth } from "@/lib/auth"; import { db, User } from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; -export default async function SettingsPage() { +import ProfilePrivacySettings from "./ProfilePrivacySettings"; +export default async function SettingsPage({ + searchParams, +}: { + searchParams: Promise<{ privacy?: string }>; +}) { + const { privacy } = await searchParams; const t = await getTranslations("pages.settings"); const session = await auth(); if (!session?.user?.id) redirect("/login"); @@ -45,6 +51,7 @@ export default async function SettingsPage() { return (
+ {/* Header */} ({ + id, + url: `/photo-${id}.png`, +})); +describe("profile photo gallery", () => { + it("shows six photos initially and discloses the remaining photos accessibly", () => { + const html = renderToStaticMarkup( + , + ); + expect(html.split("Mostra altre foto"); + if (process.env.ACCOUNT_VISUAL_FIXTURE) { + let css = readFileSync( + "src/app/(site)/u/[username]/profile.module.css", + "utf8", + ); + for (const name of ["photos", "photo", "more"]) { + const scoped = styles[name]; + if (scoped) + css = css.replaceAll( + new RegExp(`\\.${name}(?=[\\s.{:#>])`, "g"), + `.${scoped}`, + ); + } + mkdirSync("storage/account-fixtures", { recursive: true }); + writeFileSync( + "storage/account-fixtures/photos.html", + `

Foto di Alice

${html}
`, + ); + } + }); + it("excludes executable URL schemes from full-photo links", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).not.toContain(" { + expect( + renderToStaticMarkup( + , + ), + ).not.toContain(" { + try { + const url = new URL(photo.url, "https://profile.invalid"); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + }); + const gallery = (rows: typeof photos) => ( + + ); + return ( + <> + {gallery(safePhotos.slice(0, 6))} + {safePhotos.length > 6 && ( +
+ {moreLabel} + {gallery(safePhotos.slice(6))} +
+ )} + + ); +} diff --git a/src/app/(site)/u/[username]/page.tsx b/src/app/(site)/u/[username]/page.tsx index 206b579c..35d6247c 100644 --- a/src/app/(site)/u/[username]/page.tsx +++ b/src/app/(site)/u/[username]/page.tsx @@ -25,6 +25,8 @@ import { } from "@/lib/db"; import { avatarImageUrl } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; +import { loadProfilePrivacy } from "@/lib/services/profile-privacy"; +import { ProfilePhotoGallery } from "./ProfilePhotoGallery"; import styles from "./profile.module.css"; type SearchParams = Promise<{ @@ -111,6 +113,9 @@ export default async function ProfilePage({ const meId = Number(session?.user?.id); const isLoggedIn = Number.isInteger(meId) && meId > 0; const isSelf = isLoggedIn && meId === user.id; + const privacy = await loadProfilePrivacy(user.id); + const visible = (key: keyof typeof privacy.values) => + isSelf || privacy.values[key]; // Friend / request state relative to the signed-in viewer (if any). let isFriend = false; @@ -193,30 +198,37 @@ export default async function ProfilePage({ // itself; the alt-currencies sit in the users_currency ledger keyed by type. // All sub-sections run in parallel (each query keeps its own .catch so a DB // hiccup still degrades only that section instead of 500-ing the profile). - const [currencyRows, friendships, rooms, badges, photos, guestbook] = + const [currencyRows, friendships, rooms, badges, photoRows, guestbook] = await Promise.all([ - db - .select({ - type: UsersCurrency.type, - amount: UsersCurrency.amount, - }) - .from(UsersCurrency) - .where( - and( - eq(UsersCurrency.userId, user.id), - inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]), - ), - ) - .catch(() => []), + visible("wallet") + ? db + .select({ + type: UsersCurrency.type, + amount: UsersCurrency.amount, + }) + .from(UsersCurrency) + .where( + and( + eq(UsersCurrency.userId, user.id), + inArray(UsersCurrency.type, [ + CURRENCY_DUCKETS, + CURRENCY_DIAMONDS, + ]), + ), + ) + .catch(() => []) + : Promise.resolve([]), // Friends. messenger_friendships stores one directed row per pair // keyed on user_one_id; resolve the user_two_id side below. - db - .select({ userTwoId: MessengerFriendships.userTwoId }) - .from(MessengerFriendships) - .where(eq(MessengerFriendships.userOneId, user.id)) - .orderBy(desc(MessengerFriendships.friendsSince)) - .limit(18) - .catch(() => []), + visible("friends") + ? db + .select({ userTwoId: MessengerFriendships.userTwoId }) + .from(MessengerFriendships) + .where(eq(MessengerFriendships.userOneId, user.id)) + .orderBy(desc(MessengerFriendships.friendsSince)) + .limit(18) + .catch(() => []) + : Promise.resolve([]), // Rooms owned by this user. db .select({ @@ -237,13 +249,15 @@ export default async function ProfilePage({ .where(eq(UsersBadges.userId, user.id)) .orderBy(asc(UsersBadges.slotId)) .catch(() => []), - db - .select() - .from(CameraWeb) - .where(eq(CameraWeb.userId, user.id)) - .orderBy(desc(CameraWeb.timestamp)) - .limit(6) - .catch(() => []), + visible("photos") + ? db + .select() + .from(CameraWeb) + .where(eq(CameraWeb.userId, user.id)) + .orderBy(desc(CameraWeb.timestamp)) + .limit(24) + .catch(() => []) + : Promise.resolve([]), db .select() .from(WebsiteUserGuestbooks) @@ -252,6 +266,7 @@ export default async function ProfilePage({ .limit(30) .catch(() => []), ]); + const photos = photoRows ?? []; const ducketsAmount = currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0; const diamondsAmount = @@ -364,10 +379,14 @@ export default async function ProfilePage({ {user.motto || {t("noMotto")}}

- - - {t("statRegistered")}: {registered} - + {visible("online") && ( + + )} + {visible("registered") && ( + + {t("statRegistered")}: {registered} + + )}
{isLoggedIn && !isSelf ? ( @@ -400,110 +419,126 @@ export default async function ProfilePage({
{/* Rooms */} @@ -561,42 +596,22 @@ export default async function ProfilePage({ )} - {/* ── Photos ────────────────────── */} - - {photos.length === 0 ? ( - {t("photosEmpty")} - ) : ( -
- {photos.map((p) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`by -

- {formatDate(new Date(p.timestamp * 1000), "date")} -

-
- ))} -
- )} -
- + {visible("photos") && ( + + {photoRows === null ? ( +

{t("photosUnavailable")}

+ ) : photos.length === 0 ? ( + {t("photosEmpty")} + ) : ( + + )} +
+ )} {/* ── Guestbook ───────────────────── */} ({ self: false, tables: [] as unknown[] })); +vi.mock("@/actions/guestbook", () => ({ postGuestbook: vi.fn() })); +vi.mock("@/actions/social", () => ({ sendFriendRequest: vi.fn() })); +vi.mock("@/lib/auth", () => ({ + auth: async () => (state.self ? { user: { id: "42" } } : null), +})); +vi.mock("@/lib/services/profile-privacy", () => ({ + loadProfilePrivacy: async () => ({ + values: { + wallet: false, + online: false, + friends: false, + photos: false, + registered: false, + }, + unavailable: false, + }), +})); +vi.mock("next-intl/server", () => ({ + getTranslations: async () => + Object.assign((key: string) => key, { has: () => true }), +})); +vi.mock("@/lib/format-date", () => ({ + formatDate: () => "PRIVATE_REGISTRATION_DATE", +})); +vi.mock("@/components/link", () => ({ + default: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); +vi.mock("@/components/public/ui", () => ({ + ContentCard: ({ + title, + children, + }: { + title: string; + children: ReactNode; + }) => ( +
+

{title}

+ {children} +
+ ), + EmptyState: ({ children }: { children: ReactNode }) =>

{children}

, + OnlineBadge: () => PRIVATE_ONLINE_STATUS, +})); +vi.mock("@/components/shared/currency-icon", () => ({ + CurrencyIcon: () => currency-icon, +})); +vi.mock("@/lib/db", async () => { + const schema = await import("@/db/schema"); + return { + ...schema, + db: { + select: () => { + let rows: unknown[] = []; + const chain = { + from: (table: unknown) => { + state.tables.push(table); + rows = + table === schema.User + ? [ + { + id: 42, + username: "Alice", + motto: "Hello", + look: "", + credits: 987654321, + online: "1", + accountCreated: 100, + }, + ] + : []; + return chain; + }, + where: () => chain, + orderBy: () => chain, + limit: () => chain, + catch: () => Promise.resolve(rows), + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable. + then: (resolve: (rows: unknown[]) => unknown) => + Promise.resolve(rows).then(resolve), + }; + return chain; + }, + }, + }; +}); + +import { CameraWeb, MessengerFriendships, UsersCurrency } from "@/lib/db"; +import ProfilePage from "./page"; + +describe("profile visibility rendering", () => { + beforeEach(() => { + state.self = false; + state.tables = []; + }); + it("omits private fields from visitor HTML and skips sensitive section queries", async () => { + const html = renderToStaticMarkup( + await ProfilePage({ + params: Promise.resolve({ username: "Alice" }), + searchParams: Promise.resolve({}), + }), + ); + expect(html).toContain("Alice"); + expect(html).not.toContain("walletTitle"); + expect(html).not.toContain("PRIVATE_ONLINE_STATUS"); + expect(html).not.toContain("PRIVATE_REGISTRATION_DATE"); + expect(html).not.toContain("photosTitle"); + expect(html).not.toContain("friendsTitle"); + expect(state.tables).not.toContain(CameraWeb); + expect(state.tables).not.toContain(MessengerFriendships); + expect(state.tables).not.toContain(UsersCurrency); + }); + it("lets the owner inspect their data with an explicit visibility-settings link", async () => { + state.self = true; + const html = renderToStaticMarkup( + await ProfilePage({ + params: Promise.resolve({ username: "Alice" }), + searchParams: Promise.resolve({}), + }), + ); + expect(html).toContain("walletTitle"); + expect(html).toContain("PRIVATE_ONLINE_STATUS"); + expect(html).toContain("/settings#profile-privacy"); + }); +}); diff --git a/src/app/(site)/u/[username]/profile.module.css b/src/app/(site)/u/[username]/profile.module.css index 2a45550c..7157aad3 100644 --- a/src/app/(site)/u/[username]/profile.module.css +++ b/src/app/(site)/u/[username]/profile.module.css @@ -174,3 +174,33 @@ height: 120px; } } +.photos { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); + gap: 12px; +} +.photos a { + min-width: 0; + overflow-wrap: anywhere; +} +.photo { + width: 100%; + height: auto; + aspect-ratio: 1; + object-fit: cover; + border-radius: 10px; +} +.more { + min-width: 0; + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 12px; +} +.more summary { + cursor: pointer; + font-weight: 700; + padding: 8px; +} +.more[open] > summary { + margin-bottom: 12px; +} diff --git a/src/db/profile-privacy.ts b/src/db/profile-privacy.ts new file mode 100644 index 00000000..420de754 --- /dev/null +++ b/src/db/profile-privacy.ts @@ -0,0 +1,10 @@ +import { boolean, int, mysqlTable } from "drizzle-orm/mysql-core"; + +export const WebsiteProfilePrivacy = mysqlTable("website_profile_privacy", { + userId: int("user_id").primaryKey().notNull(), + wallet: boolean("wallet").notNull().default(false), + online: boolean("online").notNull().default(true), + friends: boolean("friends").notNull().default(true), + photos: boolean("photos").notNull().default(true), + registered: boolean("registered").notNull().default(true), +}); diff --git a/src/lib/services/profile-privacy.test.ts b/src/lib/services/profile-privacy.test.ts new file mode 100644 index 00000000..2bde609f --- /dev/null +++ b/src/lib/services/profile-privacy.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + fail: false, + rows: [] as unknown[][], + query: "", + params: [] as unknown[], +})); +vi.mock("@/lib/db", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + return { + db: drizzle(async (query, params) => { + state.query = query; + state.params = params; + if (state.fail) throw new Error("offline"); + return { rows: state.rows }; + }), + }; +}); + +import { loadProfilePrivacy } from "./profile-privacy"; + +describe("profile privacy", () => { + beforeEach(() => { + state.fail = false; + state.rows = []; + }); + it("defaults the wallet to private while retaining public social sections", async () => { + expect(await loadProfilePrivacy(42)).toEqual({ + unavailable: false, + values: { + wallet: false, + online: true, + friends: true, + photos: true, + registered: true, + }, + }); + expect(state.params).toEqual([42, 1]); + }); + it("fails closed if saved visibility cannot be read", async () => { + state.fail = true; + expect(await loadProfilePrivacy(42)).toEqual({ + unavailable: true, + values: { + wallet: false, + online: false, + friends: false, + photos: false, + registered: false, + }, + }); + }); + it("reads the specific profile owners explicit visibility choices", async () => { + state.rows = [[42, 1, 0, 0, 0, 0]]; + const result = await loadProfilePrivacy(42); + expect(result.values).toMatchObject({ + wallet: true, + online: false, + friends: false, + photos: false, + registered: false, + }); + }); +}); diff --git a/src/lib/services/profile-privacy.ts b/src/lib/services/profile-privacy.ts new file mode 100644 index 00000000..3e9ebe3e --- /dev/null +++ b/src/lib/services/profile-privacy.ts @@ -0,0 +1,42 @@ +import "server-only"; +import { eq } from "drizzle-orm"; +import { WebsiteProfilePrivacy } from "@/db/profile-privacy"; +import { db } from "@/lib/db"; +export const profilePrivacyKeys = [ + "wallet", + "online", + "friends", + "photos", + "registered", +] as const; +export type ProfilePrivacy = Record< + (typeof profilePrivacyKeys)[number], + boolean +>; +export const defaultProfilePrivacy: ProfilePrivacy = { + wallet: false, + online: true, + friends: true, + photos: true, + registered: true, +}; +const hiddenProfilePrivacy: ProfilePrivacy = { + wallet: false, + online: false, + friends: false, + photos: false, + registered: false, +}; +export async function loadProfilePrivacy(userId: number) { + try { + const [row] = await db + .select() + .from(WebsiteProfilePrivacy) + .where(eq(WebsiteProfilePrivacy.userId, userId)) + .limit(1); + return { values: row ?? defaultProfilePrivacy, unavailable: false }; + } catch { + // A failed privacy lookup must never expose fields whose visibility is unknown. + return { values: hiddenProfilePrivacy, unavailable: true }; + } +}