feat(profile): add privacy controls and progressive photo gallery
This commit is contained in:
1 parent
ba9c61d808
commit
74223984dc
13 files changed
+764
-167
No files matched your search
@@ -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;
|
||||||
@@ -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<string, unknown> | 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<string, unknown>) => {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section id="profile-privacy">
|
||||||
|
<SurfaceCard title={t("title")} bodyClassName="p-5">
|
||||||
|
<p className="muted">{t("hint")}</p>
|
||||||
|
{status === "saved" && <p role="status">{t("saved")}</p>}
|
||||||
|
{(privacy.unavailable || status === "error") && (
|
||||||
|
<p role="alert">{t("error")}</p>
|
||||||
|
)}
|
||||||
|
<form action={saveProfilePrivacy}>
|
||||||
|
<fieldset
|
||||||
|
disabled={privacy.unavailable}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gap: 12,
|
||||||
|
margin: "16px 0",
|
||||||
|
border: 0,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<legend className="sr-only">{t("title")}</legend>
|
||||||
|
{profilePrivacyKeys.map((key) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{ display: "flex", gap: 10, alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={key}
|
||||||
|
defaultChecked={privacy.values[key]}
|
||||||
|
/>
|
||||||
|
{t(key)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
{t("save")}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</SurfaceCard>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,13 @@ import { auth } from "@/lib/auth";
|
|||||||
import { db, User } from "@/lib/db";
|
import { db, User } from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
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 t = await getTranslations("pages.settings");
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) redirect("/login");
|
if (!session?.user?.id) redirect("/login");
|
||||||
@@ -45,6 +51,7 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-14">
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-14">
|
||||||
|
<ProfilePrivacySettings userId={id} status={privacy} />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<SurfaceCard
|
<SurfaceCard
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ProfilePhotoGallery } from "./ProfilePhotoGallery";
|
||||||
|
import styles from "./profile.module.css";
|
||||||
|
|
||||||
|
const photos = Array.from({ length: 10 }, (_, id) => ({
|
||||||
|
id,
|
||||||
|
url: `/photo-${id}.png`,
|
||||||
|
}));
|
||||||
|
describe("profile photo gallery", () => {
|
||||||
|
it("shows six photos initially and discloses the remaining photos accessibly", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ProfilePhotoGallery
|
||||||
|
photos={photos}
|
||||||
|
username="Alice"
|
||||||
|
moreLabel="Mostra altre foto"
|
||||||
|
openLabel="Apri foto completa"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html.split("<details")[0].match(/<a /g)).toHaveLength(6);
|
||||||
|
expect(html.split("<details")[1].match(/<a /g)).toHaveLength(4);
|
||||||
|
expect(html).toContain('rel="noopener noreferrer"');
|
||||||
|
expect(html).toContain("<summary>Mostra altre foto</summary>");
|
||||||
|
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",
|
||||||
|
`<!doctype html><html lang="it"><base href="http://fixtures.local/"><meta name="viewport" content="width=device-width, initial-scale=1"><style>*{box-sizing:border-box}body{font:16px Arial;margin:0;padding:20px;background:#f7f5f2}main{max-width:1000px;margin:auto}a{color:#943712}img{background:#dec5ab}${css}</style><main><h1>Foto di Alice</h1>${html}</main></html>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("excludes executable URL schemes from full-photo links", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ProfilePhotoGallery
|
||||||
|
photos={[
|
||||||
|
{ id: 1, url: "javascript:alert(1)" },
|
||||||
|
{ id: 2, url: "data:text/html,test" },
|
||||||
|
]}
|
||||||
|
username="Alice"
|
||||||
|
moreLabel="More"
|
||||||
|
openLabel="Open"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("<a ");
|
||||||
|
});
|
||||||
|
it("does not show an empty expansion when there are only six photos", () => {
|
||||||
|
expect(
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<ProfilePhotoGallery
|
||||||
|
photos={photos.slice(0, 6)}
|
||||||
|
username="Alice"
|
||||||
|
moreLabel="More"
|
||||||
|
openLabel="Open"
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
).not.toContain("<details");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ProfileImage } from "@/components/shared/profile-image";
|
||||||
|
import styles from "./profile.module.css";
|
||||||
|
export function ProfilePhotoGallery({
|
||||||
|
photos,
|
||||||
|
username,
|
||||||
|
moreLabel,
|
||||||
|
openLabel,
|
||||||
|
}: {
|
||||||
|
photos: { id: number; url: string }[];
|
||||||
|
username: string;
|
||||||
|
moreLabel: string;
|
||||||
|
openLabel: string;
|
||||||
|
}) {
|
||||||
|
const safePhotos = photos.filter((photo) => {
|
||||||
|
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) => (
|
||||||
|
<div className={styles.photos}>
|
||||||
|
{rows.map((photo) => (
|
||||||
|
<a
|
||||||
|
key={photo.id}
|
||||||
|
href={photo.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`${openLabel} · ${username}`}
|
||||||
|
>
|
||||||
|
<ProfileImage
|
||||||
|
src={photo.url}
|
||||||
|
alt={username}
|
||||||
|
width={320}
|
||||||
|
height={320}
|
||||||
|
className={styles.photo}
|
||||||
|
/>
|
||||||
|
<span>{openLabel} ↗</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{gallery(safePhotos.slice(0, 6))}
|
||||||
|
{safePhotos.length > 6 && (
|
||||||
|
<details className={styles.more}>
|
||||||
|
<summary>{moreLabel}</summary>
|
||||||
|
{gallery(safePhotos.slice(6))}
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { avatarImageUrl } from "@/lib/format";
|
import { avatarImageUrl } from "@/lib/format";
|
||||||
import { formatDate } from "@/lib/format-date";
|
import { formatDate } from "@/lib/format-date";
|
||||||
|
import { loadProfilePrivacy } from "@/lib/services/profile-privacy";
|
||||||
|
import { ProfilePhotoGallery } from "./ProfilePhotoGallery";
|
||||||
import styles from "./profile.module.css";
|
import styles from "./profile.module.css";
|
||||||
|
|
||||||
type SearchParams = Promise<{
|
type SearchParams = Promise<{
|
||||||
@@ -111,6 +113,9 @@ export default async function ProfilePage({
|
|||||||
const meId = Number(session?.user?.id);
|
const meId = Number(session?.user?.id);
|
||||||
const isLoggedIn = Number.isInteger(meId) && meId > 0;
|
const isLoggedIn = Number.isInteger(meId) && meId > 0;
|
||||||
const isSelf = isLoggedIn && meId === user.id;
|
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).
|
// Friend / request state relative to the signed-in viewer (if any).
|
||||||
let isFriend = false;
|
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.
|
// 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
|
// 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).
|
// 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([
|
await Promise.all([
|
||||||
db
|
visible("wallet")
|
||||||
.select({
|
? db
|
||||||
type: UsersCurrency.type,
|
.select({
|
||||||
amount: UsersCurrency.amount,
|
type: UsersCurrency.type,
|
||||||
})
|
amount: UsersCurrency.amount,
|
||||||
.from(UsersCurrency)
|
})
|
||||||
.where(
|
.from(UsersCurrency)
|
||||||
and(
|
.where(
|
||||||
eq(UsersCurrency.userId, user.id),
|
and(
|
||||||
inArray(UsersCurrency.type, [CURRENCY_DUCKETS, CURRENCY_DIAMONDS]),
|
eq(UsersCurrency.userId, user.id),
|
||||||
),
|
inArray(UsersCurrency.type, [
|
||||||
)
|
CURRENCY_DUCKETS,
|
||||||
.catch(() => []),
|
CURRENCY_DIAMONDS,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
// Friends. messenger_friendships stores one directed row per pair
|
// Friends. messenger_friendships stores one directed row per pair
|
||||||
// keyed on user_one_id; resolve the user_two_id side below.
|
// keyed on user_one_id; resolve the user_two_id side below.
|
||||||
db
|
visible("friends")
|
||||||
.select({ userTwoId: MessengerFriendships.userTwoId })
|
? db
|
||||||
.from(MessengerFriendships)
|
.select({ userTwoId: MessengerFriendships.userTwoId })
|
||||||
.where(eq(MessengerFriendships.userOneId, user.id))
|
.from(MessengerFriendships)
|
||||||
.orderBy(desc(MessengerFriendships.friendsSince))
|
.where(eq(MessengerFriendships.userOneId, user.id))
|
||||||
.limit(18)
|
.orderBy(desc(MessengerFriendships.friendsSince))
|
||||||
.catch(() => []),
|
.limit(18)
|
||||||
|
.catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
// Rooms owned by this user.
|
// Rooms owned by this user.
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -237,13 +249,15 @@ export default async function ProfilePage({
|
|||||||
.where(eq(UsersBadges.userId, user.id))
|
.where(eq(UsersBadges.userId, user.id))
|
||||||
.orderBy(asc(UsersBadges.slotId))
|
.orderBy(asc(UsersBadges.slotId))
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
db
|
visible("photos")
|
||||||
.select()
|
? db
|
||||||
.from(CameraWeb)
|
.select()
|
||||||
.where(eq(CameraWeb.userId, user.id))
|
.from(CameraWeb)
|
||||||
.orderBy(desc(CameraWeb.timestamp))
|
.where(eq(CameraWeb.userId, user.id))
|
||||||
.limit(6)
|
.orderBy(desc(CameraWeb.timestamp))
|
||||||
.catch(() => []),
|
.limit(24)
|
||||||
|
.catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(WebsiteUserGuestbooks)
|
.from(WebsiteUserGuestbooks)
|
||||||
@@ -252,6 +266,7 @@ export default async function ProfilePage({
|
|||||||
.limit(30)
|
.limit(30)
|
||||||
.catch(() => []),
|
.catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
const photos = photoRows ?? [];
|
||||||
const ducketsAmount =
|
const ducketsAmount =
|
||||||
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
|
currencyRows.find((c) => c.type === CURRENCY_DUCKETS)?.amount ?? 0;
|
||||||
const diamondsAmount =
|
const diamondsAmount =
|
||||||
@@ -364,10 +379,14 @@ export default async function ProfilePage({
|
|||||||
{user.motto || <span className="muted">{t("noMotto")}</span>}
|
{user.motto || <span className="muted">{t("noMotto")}</span>}
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
<OnlineBadge online={user.online === "1"} />
|
{visible("online") && (
|
||||||
<span>
|
<OnlineBadge online={user.online === "1"} />
|
||||||
{t("statRegistered")}: {registered}
|
)}
|
||||||
</span>
|
{visible("registered") && (
|
||||||
|
<span>
|
||||||
|
{t("statRegistered")}: {registered}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLoggedIn && !isSelf ? (
|
{isLoggedIn && !isSelf ? (
|
||||||
@@ -400,110 +419,126 @@ export default async function ProfilePage({
|
|||||||
|
|
||||||
<div className={styles.columns}>
|
<div className={styles.columns}>
|
||||||
<aside className={styles.sidebar}>
|
<aside className={styles.sidebar}>
|
||||||
|
{isSelf && (
|
||||||
|
<p className="muted">
|
||||||
|
{t("privacy.ownerHint")}{" "}
|
||||||
|
<Link href="/settings#profile-privacy">{t("privacy.title")}</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{/* Wallet */}
|
{/* Wallet */}
|
||||||
<ContentCard title={t("walletTitle")}>
|
{visible("wallet") && (
|
||||||
<div className={styles.wallet}>
|
<ContentCard title={t("walletTitle")}>
|
||||||
{wallet.map((w) => (
|
<div className={styles.wallet}>
|
||||||
<div key={w.key} className={styles.currency}>
|
{wallet.map((w) => (
|
||||||
<CurrencyIcon kind={w.key} size={28} alt="" />
|
<div key={w.key} className={styles.currency}>
|
||||||
<div>
|
<CurrencyIcon kind={w.key} size={28} alt="" />
|
||||||
<strong>{w.value.toLocaleString()}</strong>
|
<div>
|
||||||
<span className="muted">{w.label}</span>
|
<strong>{w.value.toLocaleString()}</strong>
|
||||||
|
<span className="muted">{w.label}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ContentCard>
|
|
||||||
|
|
||||||
{/* ── Friends ──────────────────────── */}
|
|
||||||
<ContentCard
|
|
||||||
icon="👥"
|
|
||||||
title={t("friendsTitle")}
|
|
||||||
subtitle={t("friendsSubtitle", { count: friends.length })}
|
|
||||||
padded={true}
|
|
||||||
>
|
|
||||||
{friends.length === 0 ? (
|
|
||||||
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))",
|
|
||||||
gap: "0.75rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{friends.map((f) => (
|
|
||||||
<Link
|
|
||||||
key={f.id}
|
|
||||||
href={`/u/${encodeURIComponent(f.username)}`}
|
|
||||||
className="card"
|
|
||||||
title={f.username}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "0.35rem",
|
|
||||||
padding: "0.6rem 0.4rem",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserAvatarThumbnail figure={f.look} alt={f.username} />
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
maxWidth: "100%",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{f.username}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</ContentCard>
|
||||||
</ContentCard>
|
)}
|
||||||
|
|
||||||
|
{/* ── Friends ──────────────────────── */}
|
||||||
|
{visible("friends") && (
|
||||||
|
<details className={styles.more}>
|
||||||
|
<summary>{t("friendsTitle")}</summary>
|
||||||
|
<ContentCard
|
||||||
|
icon="👥"
|
||||||
|
title={t("friendsTitle")}
|
||||||
|
subtitle={t("friendsSubtitle", { count: friends.length })}
|
||||||
|
padded={true}
|
||||||
|
>
|
||||||
|
{friends.length === 0 ? (
|
||||||
|
<EmptyState icon="👥">{t("friendsEmpty")}</EmptyState>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns:
|
||||||
|
"repeat(auto-fill, minmax(72px, 1fr))",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{friends.map((f) => (
|
||||||
|
<Link
|
||||||
|
key={f.id}
|
||||||
|
href={`/u/${encodeURIComponent(f.username)}`}
|
||||||
|
className="card"
|
||||||
|
title={f.username}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.35rem",
|
||||||
|
padding: "0.6rem 0.4rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAvatarThumbnail figure={f.look} alt={f.username} />
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
maxWidth: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.username}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
{/* Badges */}
|
{/* Badges */}
|
||||||
<ContentCard icon="🏅" title={t("badgesTitle")}>
|
<details className={styles.more}>
|
||||||
{badges.length === 0 ? (
|
<summary>{t("badgesTitle")}</summary>
|
||||||
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
|
<ContentCard icon="🏅" title={t("badgesTitle")}>
|
||||||
) : (
|
{badges.length === 0 ? (
|
||||||
<div className={styles.badges}>
|
<EmptyState icon="🏅">{t("badgesEmpty")}</EmptyState>
|
||||||
{badges.map((b) => {
|
) : (
|
||||||
const details = badgeByCode.get(b.badgeCode);
|
<div className={styles.badges}>
|
||||||
const name = details?.badgeName || b.badgeCode;
|
{badges.map((b) => {
|
||||||
return (
|
const details = badgeByCode.get(b.badgeCode);
|
||||||
<details key={b.id} className={styles.badge}>
|
const name = details?.badgeName || b.badgeCode;
|
||||||
<summary title={name}>
|
return (
|
||||||
<img
|
<details key={b.id} className={styles.badge}>
|
||||||
src={
|
<summary title={name}>
|
||||||
BADGE_IMG_BASE +
|
<img
|
||||||
"/" +
|
src={
|
||||||
encodeURIComponent(b.badgeCode) +
|
BADGE_IMG_BASE +
|
||||||
".gif"
|
"/" +
|
||||||
}
|
encodeURIComponent(b.badgeCode) +
|
||||||
alt={name}
|
".gif"
|
||||||
width={40}
|
}
|
||||||
height={40}
|
alt={name}
|
||||||
loading="lazy"
|
width={40}
|
||||||
/>
|
height={40}
|
||||||
</summary>
|
loading="lazy"
|
||||||
<div className={styles.badgeInfo}>
|
/>
|
||||||
<strong>{name}</strong>
|
</summary>
|
||||||
{details?.badgeDescription && (
|
<div className={styles.badgeInfo}>
|
||||||
<p>{details.badgeDescription}</p>
|
<strong>{name}</strong>
|
||||||
)}
|
{details?.badgeDescription && (
|
||||||
<small>{b.badgeCode}</small>
|
<p>{details.badgeDescription}</p>
|
||||||
</div>
|
)}
|
||||||
</details>
|
<small>{b.badgeCode}</small>
|
||||||
);
|
</div>
|
||||||
})}
|
</details>
|
||||||
</div>
|
);
|
||||||
)}
|
})}
|
||||||
</ContentCard>
|
</div>
|
||||||
|
)}
|
||||||
|
</ContentCard>
|
||||||
|
</details>
|
||||||
</aside>
|
</aside>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{/* Rooms */}
|
{/* Rooms */}
|
||||||
@@ -561,42 +596,22 @@ export default async function ProfilePage({
|
|||||||
)}
|
)}
|
||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
{/* ── Photos ────────────────────── */}
|
{visible("photos") && (
|
||||||
<ContentCard
|
<ContentCard icon="📸" title={t("photosTitle")}>
|
||||||
icon="📸"
|
{photoRows === null ? (
|
||||||
title={t("photosTitle")}
|
<p role="status">{t("photosUnavailable")}</p>
|
||||||
padded={photos.length === 0}
|
) : photos.length === 0 ? (
|
||||||
>
|
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
|
||||||
{photos.length === 0 ? (
|
) : (
|
||||||
<EmptyState icon="📸">{t("photosEmpty")}</EmptyState>
|
<ProfilePhotoGallery
|
||||||
) : (
|
photos={photos}
|
||||||
<div className="card-grid sm-2 lg-3" style={{ padding: "1rem" }}>
|
username={user.username}
|
||||||
{photos.map((p) => (
|
moreLabel={t("morePhotos")}
|
||||||
<div
|
openLabel={t("openPhoto")}
|
||||||
key={p.id}
|
/>
|
||||||
className="card"
|
)}
|
||||||
style={{ padding: "0.5rem" }}
|
</ContentCard>
|
||||||
>
|
)}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={p.url}
|
|
||||||
alt={`by ${user.username}`}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "auto",
|
|
||||||
display: "block",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="muted" style={{ margin: "0.4rem 0 0" }}>
|
|
||||||
{formatDate(new Date(p.timestamp * 1000), "date")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ContentCard>
|
|
||||||
|
|
||||||
{/* ── Guestbook ───────────────────── */}
|
{/* ── Guestbook ───────────────────── */}
|
||||||
<ContentCard
|
<ContentCard
|
||||||
icon="📝"
|
icon="📝"
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({ 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 }) => (
|
||||||
|
<a href={href}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/public/ui", () => ({
|
||||||
|
ContentCard: ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) => (
|
||||||
|
<section>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
EmptyState: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||||
|
OnlineBadge: () => <span>PRIVATE_ONLINE_STATUS</span>,
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/shared/currency-icon", () => ({
|
||||||
|
CurrencyIcon: () => <span>currency-icon</span>,
|
||||||
|
}));
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -174,3 +174,33 @@
|
|||||||
height: 120px;
|
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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user