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 { 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 (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-14">
|
||||
<ProfilePrivacySettings userId={id} status={privacy} />
|
||||
{/* Header */}
|
||||
<Reveal>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
@@ -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