diff --git a/docs/contracts/user.json b/docs/contracts/user.json index 195ce13ae..e397a6d26 100644 --- a/docs/contracts/user.json +++ b/docs/contracts/user.json @@ -13,6 +13,9 @@ "debug-password-boundary": "Email/password authentication is enabled only for local development or the explicit non-production E2E debug mode; production does not expose debug password authentication.", "oauth-callback-integrity": "On a canonical same-origin deployment, OAuth login callbacks must complete state validation and session creation directly, without losing state due to proxy wrapping or container-internal host differences.", "welcome-flow-required": "New users who have not set a name or username on first login must complete the welcome flow before proceeding.", + "welcome-staged-steps": "Welcome is a staged flow that renders one step at a time with a progress indicator: profile (required), section subscriptions (skippable), then platform orientation. The step comes from the URL, so an incomplete profile always resolves to the profile step and a completed profile never returns to it. Only the last step leaves onboarding for the callback target.", + "welcome-avatar-upload": "During welcome, signed-in users may upload a JPG, PNG, WebP, or AVIF avatar up to 5 MB. The Worker uses the Cloudflare Images binding to auto-crop it to a 256-by-256 WebP before storing it in private R2; public avatar delivery is limited to URLs currently referenced by that user's profile.", + "welcome-oauth-refresh": "A user may reauthorize an already linked OAuth provider during welcome to fetch current upstream name and avatar options. Refreshed upstream values fill missing data and add avatar choices without overwriting profile values the user entered.", "welcome-completion-resume": "If the welcome flow was reached from an app-relative callbackUrl, successful completion returns to that page; otherwise completion returns home.", "auth-callback-not-intercepted": "Authorization callback continuation requests carrying OAuth code/error and state must be allowed to complete the protocol redirect; the welcome page interceptor must not break the authorization result redirect.", "post-login-redirect": "After successful login, the user should be redirected back to the originally requested app-relative page whenever possible; unsafe external callbacks fall back to the home page.", @@ -58,8 +61,12 @@ "user.username (Current value display)", "user.image (Current avatar)", "user.profilePictures[] (Avatar selector grid)", - "semesters[] (Semester dropdown options)", - "defaultSemesterId (Preselected semester)" + "Custom avatar upload (server-cropped 256x256 WebP in R2)", + "Linked OAuth providers (reauthorize to refresh upstream profile options)", + "Step progress indicator with back, skip, and finish controls", + "semesters[] (Semester dropdown options, subscriptions step)", + "defaultSemesterId (Preselected semester)", + "Platform guidance for subscriptions, workspace, planning, and account security (final step)" ] } }, diff --git a/messages/en-us.json b/messages/en-us.json index a4690e320..e6f46e69f 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -1337,6 +1337,8 @@ "editProfile": "Edit Profile", "editProfileDescription": "Make changes to your profile here. Click save when you're done.", "profilePicture": "Profile Picture", + "avatarUpload": "Upload your own avatar", + "avatarUploadHint": "JPG, PNG, WebP, or AVIF up to 5 MB. Cloudflare center-crops it to 256×256 and converts it to WebP.", "name": "Name", "namePlaceholder": "Your name", "nameRequired": "Name is required.", @@ -1999,13 +2001,37 @@ "description": "Set your name and username to continue.", "firstSignIn": "First sign-in", "continue": "Continue", + "stepProgress": "Step {current} of {total}", + "stepProfile": "Your profile", + "stepSubscriptions": "Section subscriptions", + "stepFinish": "Get started", + "back": "Back", + "skipForNow": "Skip for now", + "startUsing": "Go to workspace", + "finishTitle": "You're all set", + "finishDescription": "Here's what people use most right after setup.", "success": "Profile completed", "successDescription": "Welcome! Your profile has been set up.", - "nextStepsTitle": "Build your workspace", - "nextStepsDescription": "After saving your profile, you can browse sections, jump in from courses, or match section codes in bulk.", + "nextStepsTitle": "Subscribe to your sections", + "nextStepsDescription": "Pasting section codes for bulk matching is fastest, or browse sections and courses instead. You can do this later.", "browseSections": "Browse Sections", "browseCourses": "Browse Courses", "avatarLater": "You can add uploaded avatars later from settings.", + "avatarUploadInvalid": "Choose a valid JPG, PNG, WebP, or AVIF image.", + "avatarUploadTooLarge": "Avatar files must be 5 MB or smaller.", + "avatarUploadUnavailable": "Avatar processing is temporarily unavailable. Try again later or use an upstream avatar.", + "oauthRefreshTitle": "Refresh from a sign-in account", + "oauthRefreshDescription": "Reauthorizing fetches the latest upstream name and avatar as options without overwriting details you entered yourself.", + "oauthRefreshAction": "Refresh from {provider}", + "oauthRefreshSuccess": "Latest upstream details received. Review your name and avatar, then continue.", + "oauthRefreshFailed": "We couldn't refresh details from that sign-in account. Please try again.", + "oauthRefreshNotLinked": "That sign-in account is not linked to the current user.", + "guideWorkspaceTitle": "Subscriptions and workspace", + "guideWorkspaceDescription": "Subscribe to sections to bring homework, exams, and course events into your workspace.", + "guidePlanningTitle": "Calendar and todos", + "guidePlanningDescription": "See course events beside personal todos, or export an iCal feed to your usual calendar.", + "guideAccountTitle": "Account and security", + "guideAccountDescription": "After setup, link sign-in accounts, add a passkey, and manage authorized applications.", "bulkImportCta": "Bulk Add Subscriptions", "sectionCodesLabel": "Section codes", "confirmImportTitle": "Confirm Section Subscriptions", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 1160874ad..c73d07a1b 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -1303,6 +1303,8 @@ "editProfile": "编辑资料", "editProfileDescription": "在此处更改您的个人资料。完成后点击保存。", "profilePicture": "头像", + "avatarUpload": "上传自己的头像", + "avatarUploadHint": "支持 JPG、PNG、WebP 或 AVIF,最大 5 MB。图片会在 Cloudflare 中心裁切为 256×256 并转换为 WebP。", "name": "姓名", "namePlaceholder": "您的姓名", "nameRequired": "请输入姓名。", @@ -1965,13 +1967,37 @@ "description": "请设置您的姓名和用户名以继续使用。", "firstSignIn": "首次登录", "continue": "继续", + "stepProgress": "第 {current} 步,共 {total} 步", + "stepProfile": "个人资料", + "stepSubscriptions": "教学班订阅", + "stepFinish": "开始使用", + "back": "上一步", + "skipForNow": "暂时跳过", + "startUsing": "进入工作区", + "finishTitle": "准备就绪", + "finishDescription": "以下是接下来最常用的几项功能。", "success": "信息已完善", "successDescription": "欢迎!您的个人信息已设置完成。", - "nextStepsTitle": "继续完善你的学习工作区", - "nextStepsDescription": "保存资料后,你可以先浏览班级、从课程进入,或批量匹配班级代码。", + "nextStepsTitle": "订阅你的教学班", + "nextStepsDescription": "粘贴班级代码批量匹配最快,也可以先浏览班级或课程。这一步可以稍后再做。", "browseSections": "浏览班级", "browseCourses": "浏览课程", "avatarLater": "你可以稍后在设置中添加上传过的头像。", + "avatarUploadInvalid": "请选择有效的 JPG、PNG、WebP 或 AVIF 图片。", + "avatarUploadTooLarge": "头像文件不能超过 5 MB。", + "avatarUploadUnavailable": "头像处理服务暂时不可用,请稍后重试或先使用上游头像。", + "oauthRefreshTitle": "从登录账户更新资料", + "oauthRefreshDescription": "重新授权后会获取最新的上游姓名和头像作为可选资料,不会覆盖你已经手动填写的内容。", + "oauthRefreshAction": "从 {provider} 重新获取", + "oauthRefreshSuccess": "已获取最新的上游资料,请检查姓名和头像后继续。", + "oauthRefreshFailed": "暂时无法从该登录账户更新资料,请稍后重试。", + "oauthRefreshNotLinked": "该登录账户未与当前用户关联。", + "guideWorkspaceTitle": "订阅与工作区", + "guideWorkspaceDescription": "订阅教学班后,作业、考试和课程日历会汇总到你的工作区。", + "guidePlanningTitle": "日历与待办", + "guidePlanningDescription": "把课程事件与个人待办放在一起查看,也可以导出 iCal 到常用日历。", + "guideAccountTitle": "账户与安全", + "guideAccountDescription": "完成设置后可关联多个登录账户、添加通行密钥并管理应用授权。", "bulkImportCta": "批量添加订阅", "sectionCodesLabel": "班级代码", "confirmImportTitle": "确认添加教学班订阅", diff --git a/prisma/migrations/20260808153000_grant_profile_picture_app_runtime/migration.sql b/prisma/migrations/20260808153000_grant_profile_picture_app_runtime/migration.sql new file mode 100644 index 000000000..b96853daa --- /dev/null +++ b/prisma/migrations/20260808153000_grant_profile_picture_app_runtime/migration.sql @@ -0,0 +1,17 @@ +-- OAuth hooks and profile completion append trusted, server-derived avatar +-- URLs. Keep Better Auth's role read-only for this field; only the app runtime +-- receives the narrow column-level write privilege. + +DO $grant_profile_picture_app_runtime$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'life_ustc_runtime' + ) THEN + RAISE NOTICE 'Skipping profilePictures grant; role life_ustc_runtime does not exist.'; + RETURN; + END IF; + + EXECUTE + 'GRANT UPDATE ("profilePictures") ON TABLE "User" TO life_ustc_runtime'; +END +$grant_profile_picture_app_runtime$; diff --git a/prisma/roles/app-runtime-table-grants.sql b/prisma/roles/app-runtime-table-grants.sql index 21b660562..62d0777d8 100644 --- a/prisma/roles/app-runtime-table-grants.sql +++ b/prisma/roles/app-runtime-table-grants.sql @@ -61,5 +61,5 @@ GRANT INSERT ON TABLE "AuditLog" TO life_ustc_runtime; GRANT SELECT, INSERT, UPDATE ON TABLE "UserSuspension" TO life_ustc_runtime; GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE "BusScheduleVersion" TO life_ustc_runtime; -GRANT UPDATE ("name", "username", "isAdmin", "calendarFeedToken", "updatedAt") ON TABLE "User" +GRANT UPDATE ("name", "username", "profilePictures", "isAdmin", "calendarFeedToken", "updatedAt") ON TABLE "User" TO life_ustc_runtime; diff --git a/src/features/profile/server/profile-avatar-service.ts b/src/features/profile/server/profile-avatar-service.ts new file mode 100644 index 000000000..ff8bdc8dc --- /dev/null +++ b/src/features/profile/server/profile-avatar-service.ts @@ -0,0 +1,129 @@ +import { getCloudflareImagesBinding } from "@/lib/adapters/cloudflare-runtime"; +import { prisma } from "@/lib/db/prisma"; +import { + deleteStorageObject, + getStorageObjectResponse, + putStorageObject, +} from "@/lib/storage/r2-object"; + +export const PROFILE_AVATAR_DIMENSION = 256; +export const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024; +export const PROFILE_AVATAR_OUTPUT_TYPE = "image/webp"; + +const PROFILE_AVATAR_INPUT_TYPES = new Set([ + "image/avif", + "image/jpeg", + "image/png", + "image/webp", +]); +const AVATAR_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export type ProfileAvatarUploadFailure = + | "empty" + | "invalid_image" + | "too_large" + | "unavailable"; + +export class ProfileAvatarUploadError extends Error { + constructor(readonly reason: ProfileAvatarUploadFailure) { + super(`Profile avatar upload failed: ${reason}`); + this.name = "ProfileAvatarUploadError"; + } +} + +function avatarUrl(userId: string, avatarId: string) { + return `/media/avatars/${encodeURIComponent(userId)}/${avatarId}.webp`; +} + +function avatarKey(userId: string, avatarId: string) { + return `avatars/${userId}/${avatarId}.webp`; +} + +export async function processProfileAvatarUpload(input: { + file: File; + userId: string; +}) { + if (input.file.size <= 0) { + throw new ProfileAvatarUploadError("empty"); + } + if (input.file.size > PROFILE_AVATAR_MAX_BYTES) { + throw new ProfileAvatarUploadError("too_large"); + } + if (!PROFILE_AVATAR_INPUT_TYPES.has(input.file.type.toLowerCase())) { + throw new ProfileAvatarUploadError("invalid_image"); + } + + const images = getCloudflareImagesBinding(); + if (!images) { + throw new ProfileAvatarUploadError("unavailable"); + } + + const avatarId = crypto.randomUUID(); + const key = avatarKey(input.userId, avatarId); + try { + const transformed = await images + .input(input.file.stream()) + .transform({ + width: PROFILE_AVATAR_DIMENSION, + height: PROFILE_AVATAR_DIMENSION, + fit: "cover", + gravity: "auto", + }) + .output({ + format: PROFILE_AVATAR_OUTPUT_TYPE, + quality: 85, + }); + const response = transformed.response(); + if (!response.ok || !response.body) { + throw new ProfileAvatarUploadError("invalid_image"); + } + await putStorageObject({ + body: response.body, + contentType: PROFILE_AVATAR_OUTPUT_TYPE, + key, + }); + } catch (error) { + if (error instanceof ProfileAvatarUploadError) throw error; + const code = + error && typeof error === "object" && "code" in error + ? Number(error.code) + : null; + throw new ProfileAvatarUploadError( + code === 9412 ? "invalid_image" : "unavailable", + ); + } + + return { + key, + url: avatarUrl(input.userId, avatarId), + }; +} + +export async function deleteProcessedProfileAvatar(key: string) { + await deleteStorageObject(key); +} + +export async function getPublicProfileAvatar(input: { + avatarId: string; + userId: string; +}) { + if (!AVATAR_ID_PATTERN.test(input.avatarId)) return null; + const url = avatarUrl(input.userId, input.avatarId); + const user = await prisma.user.findUnique({ + where: { id: input.userId }, + select: { image: true, profilePictures: true }, + }); + if (!user || (user.image !== url && !user.profilePictures.includes(url))) { + return null; + } + + const response = await getStorageObjectResponse({ + contentDisposition: 'inline; filename="avatar.webp"', + contentType: PROFILE_AVATAR_OUTPUT_TYPE, + key: avatarKey(input.userId, input.avatarId), + }); + if (!response) return null; + response.headers.set("Cache-Control", "public, max-age=31536000, immutable"); + return response; +} diff --git a/src/features/profile/server/profile-update-service.ts b/src/features/profile/server/profile-update-service.ts index 47f156cf8..eb737c5cd 100644 --- a/src/features/profile/server/profile-update-service.ts +++ b/src/features/profile/server/profile-update-service.ts @@ -1,12 +1,14 @@ import { authApi } from "@/lib/auth/core"; import { prisma } from "@/lib/db/prisma"; import { isPrismaUniqueConstraintError } from "@/lib/db/prisma-errors"; +import { logAppEvent } from "@/lib/log/app-logger"; import { isValidProfileUsername } from "../lib/profile-username"; type ProfileUpdateInput = { headers: Headers; image: string | null; name: string; + trustedImageUrl?: string | null; userId: string; username: string; }; @@ -38,6 +40,7 @@ export async function updateOwnProfile( if ( input.image && input.image !== current.image && + input.image !== input.trustedImageUrl && !current.profilePictures.includes(input.image) ) { return { ok: false, reason: "avatar_invalid" }; @@ -66,6 +69,29 @@ export async function updateOwnProfile( headers: input.headers, returnHeaders: true, }); + if ( + input.trustedImageUrl && + !current.profilePictures.includes(input.trustedImageUrl) + ) { + try { + await prisma.user.update({ + where: { id: input.userId }, + data: { + profilePictures: { push: input.trustedImageUrl }, + }, + select: { id: true }, + }); + } catch (error) { + // The current image already points at the processed object, so profile + // completion can succeed even if persisting the reusable option fails. + logAppEvent( + "warn", + "Failed to persist processed avatar as a profile option", + { source: "profile" }, + error, + ); + } + } return { headers: response.headers, ok: true }; } catch (error) { if (!isPrismaUniqueConstraintError(error)) throw error; diff --git a/src/features/welcome/components/WelcomeGuideCard.svelte b/src/features/welcome/components/WelcomeGuideCard.svelte new file mode 100644 index 000000000..104425ab1 --- /dev/null +++ b/src/features/welcome/components/WelcomeGuideCard.svelte @@ -0,0 +1,60 @@ + + + + + {welcomeCopy.finishTitle} + {welcomeCopy.finishDescription} + + + {#each guides as guide (guide.title)} +
+ + + +
+

{guide.title}

+

{guide.description}

+
+
+ {/each} +
+ + {#if backUrl} + + {/if} + + +
diff --git a/src/features/welcome/components/WelcomeNextStepsCard.svelte b/src/features/welcome/components/WelcomeNextStepsCard.svelte index d4c2691f0..0ad5a667d 100644 --- a/src/features/welcome/components/WelcomeNextStepsCard.svelte +++ b/src/features/welcome/components/WelcomeNextStepsCard.svelte @@ -1,20 +1,22 @@ - + {welcomeCopy.nextStepsTitle} - - {welcomeCopy.nextStepsDescription} - + {welcomeCopy.nextStepsDescription} {#if importMessage} @@ -22,8 +24,20 @@ export let welcomeCopy: WelcomeCopy; {importMessage} {/if} + - + + {#if backUrl} + + {/if} + + diff --git a/src/features/welcome/components/WelcomeOAuthProfileCard.svelte b/src/features/welcome/components/WelcomeOAuthProfileCard.svelte new file mode 100644 index 000000000..f82d63c6a --- /dev/null +++ b/src/features/welcome/components/WelcomeOAuthProfileCard.svelte @@ -0,0 +1,40 @@ + + +{#if oauthProviders.length > 0} + + + {welcomeCopy.oauthRefreshTitle} + {welcomeCopy.oauthRefreshDescription} + + + {#if oauthRefreshed} + + {welcomeCopy.oauthRefreshSuccess} + + {/if} +
+ {#each oauthProviders as provider} +
+ + + +
+ {/each} +
+
+
+{/if} diff --git a/src/features/welcome/components/WelcomePageController.svelte b/src/features/welcome/components/WelcomePageController.svelte index 67e51a68e..d626fb857 100644 --- a/src/features/welcome/components/WelcomePageController.svelte +++ b/src/features/welcome/components/WelcomePageController.svelte @@ -1,7 +1,10 @@ -
+ @@ -62,7 +77,7 @@ $: avatarFallback = (user.name ?? user.username ?? "U") {profileCopy.profilePicture}
- + {avatarFallback} {#if avatarOptions.length > 0} @@ -94,6 +109,17 @@ $: avatarFallback = (user.name ?? user.username ?? "U") {/if}
+ + {profileCopy.avatarUpload} + + {profileCopy.avatarUploadHint} + diff --git a/src/features/welcome/components/WelcomeStepper.svelte b/src/features/welcome/components/WelcomeStepper.svelte new file mode 100644 index 000000000..c509b6910 --- /dev/null +++ b/src/features/welcome/components/WelcomeStepper.svelte @@ -0,0 +1,37 @@ + + + diff --git a/src/features/welcome/components/welcome-component-types.ts b/src/features/welcome/components/welcome-component-types.ts index 50ec94aaa..c53346f36 100644 --- a/src/features/welcome/components/welcome-component-types.ts +++ b/src/features/welcome/components/welcome-component-types.ts @@ -1,7 +1,17 @@ import type { SubmitFunction } from "@sveltejs/kit"; import type { WelcomeMatchedSection } from "@/features/welcome/lib/welcome-bulk-import-types"; +import type { WelcomeStep } from "@/features/welcome/lib/welcome-steps"; + +export type WelcomeStepIndicator = { + id: WelcomeStep; + label: string; + number: number; + state: "complete" | "current" | "upcoming"; +}; export type WelcomeProfileCopy = { + avatarUpload: string; + avatarUploadHint: string; name: string; namePlaceholder: string; profilePicture: string; @@ -12,9 +22,18 @@ export type WelcomeProfileCopy = { }; export type WelcomeCopy = Record & { + back: string; browseCourses: string; browseSections: string; bulkImportCta: string; + finishDescription: string; + finishTitle: string; + skipForNow: string; + startUsing: string; + stepFinish: string; + stepProfile: string; + stepProgress: string; + stepSubscriptions: string; confirmImportTitle: string; avatarLater: string; continue: string; @@ -83,11 +102,17 @@ export type WelcomePageUser = WelcomeProfileUser & { }; export type WelcomePageData = { + backUrl: string | null; callbackUrl: string; copy: WelcomePageCopy; defaultSemesterId?: number | string | null; locale: string; + nextUrl: string; + oauthProviders: Array<{ id: string; name: string }>; + oauthRefreshed: boolean; semesters: WelcomeSemester[]; + step: WelcomeStep; + stepIndicators: WelcomeStepIndicator[]; user: WelcomePageUser; }; diff --git a/src/features/welcome/lib/welcome-steps.ts b/src/features/welcome/lib/welcome-steps.ts new file mode 100644 index 000000000..eee04f4a0 --- /dev/null +++ b/src/features/welcome/lib/welcome-steps.ts @@ -0,0 +1,25 @@ +export const WELCOME_STEPS = ["profile", "subscriptions", "finish"] as const; + +export type WelcomeStep = (typeof WELCOME_STEPS)[number]; + +export function parseWelcomeStep(value: unknown): WelcomeStep { + return WELCOME_STEPS.includes(value as WelcomeStep) + ? (value as WelcomeStep) + : "profile"; +} + +export function welcomeStepNumber(step: WelcomeStep) { + return WELCOME_STEPS.indexOf(step) + 1; +} + +export function nextWelcomeStep(step: WelcomeStep): WelcomeStep | null { + return WELCOME_STEPS[welcomeStepNumber(step)] ?? null; +} + +export function previousWelcomeStep(step: WelcomeStep): WelcomeStep | null { + return WELCOME_STEPS[welcomeStepNumber(step) - 2] ?? null; +} + +export function buildWelcomeStepUrl(step: WelcomeStep, callbackUrl: string) { + return `/account/welcome?step=${step}&callbackUrl=${encodeURIComponent(callbackUrl)}`; +} diff --git a/src/features/welcome/server/welcome-complete-action.ts b/src/features/welcome/server/welcome-complete-action.ts index 79c15ed73..7fb8eea0f 100644 --- a/src/features/welcome/server/welcome-complete-action.ts +++ b/src/features/welcome/server/welcome-complete-action.ts @@ -1,6 +1,12 @@ import type { Cookies } from "@sveltejs/kit"; import { fail, redirect } from "@sveltejs/kit"; +import { + deleteProcessedProfileAvatar, + ProfileAvatarUploadError, + processProfileAvatarUpload, +} from "@/features/profile/server/profile-avatar-service"; import { updateOwnProfile } from "@/features/profile/server/profile-update-service"; +import { buildWelcomeStepUrl } from "@/features/welcome/lib/welcome-steps"; import { buildSignInPageUrl } from "@/lib/auth/auth-routing"; import { getSessionFromHeaders } from "@/lib/auth/core"; import { applyAuthResponseCookies } from "@/lib/auth/svelte-auth-actions"; @@ -8,6 +14,14 @@ import { resolveWelcomeCallbackUrl } from "./welcome-callback-url"; import { getWelcomeCopy } from "./welcome-page-copy"; import { parseWelcomeProfileForm } from "./welcome-profile-form"; +async function discardUploadedAvatar(key: string) { + try { + await deleteProcessedProfileAvatar(key); + } catch (error) { + void error; + } +} + export async function completeWelcomeProfile({ locals, request, @@ -19,7 +33,8 @@ export async function completeWelcomeProfile({ }) { const copy = getWelcomeCopy(locals.locale); const form = await request.formData(); - const { callbackUrl, image, name, username } = parseWelcomeProfileForm(form); + const { avatar, callbackUrl, image, name, username } = + parseWelcomeProfileForm(form); const redirectTo = resolveWelcomeCallbackUrl(callbackUrl); const session = await getSessionFromHeaders(request.headers); if (!session?.user?.id) { @@ -31,14 +46,49 @@ export async function completeWelcomeProfile({ ); } - const result = await updateOwnProfile({ - headers: request.headers, - image, - name, - userId: session.user.id, - username, - }); + let uploadedAvatar: Awaited< + ReturnType + > | null = null; + try { + if (avatar) { + uploadedAvatar = await processProfileAvatarUpload({ + file: avatar, + userId: session.user.id, + }); + } + } catch (error) { + if (error instanceof ProfileAvatarUploadError) { + const message = + error.reason === "too_large" + ? copy.welcome.avatarUploadTooLarge + : error.reason === "unavailable" + ? copy.welcome.avatarUploadUnavailable + : copy.welcome.avatarUploadInvalid; + return fail(400, { message }); + } + throw error; + } + + let result: Awaited>; + try { + result = await updateOwnProfile({ + headers: request.headers, + image: uploadedAvatar?.url ?? image, + name, + trustedImageUrl: uploadedAvatar?.url, + userId: session.user.id, + username, + }); + } catch (error) { + if (uploadedAvatar) { + await discardUploadedAvatar(uploadedAvatar.key); + } + throw error; + } if (!result.ok) { + if (uploadedAvatar) { + await discardUploadedAvatar(uploadedAvatar.key); + } if (result.reason === "name_required") { return fail(400, { message: copy.profile.nameRequired }); } @@ -55,5 +105,5 @@ export async function completeWelcomeProfile({ } applyAuthResponseCookies(result.headers, cookies); - throw redirect(303, redirectTo); + throw redirect(303, buildWelcomeStepUrl("subscriptions", redirectTo)); } diff --git a/src/features/welcome/server/welcome-oauth-refresh-action.ts b/src/features/welcome/server/welcome-oauth-refresh-action.ts new file mode 100644 index 000000000..54a04540c --- /dev/null +++ b/src/features/welcome/server/welcome-oauth-refresh-action.ts @@ -0,0 +1,79 @@ +import { type Cookies, fail, redirect } from "@sveltejs/kit"; +import { buildSignInPageUrl } from "@/lib/auth/auth-routing"; +import { getSessionFromHeaders } from "@/lib/auth/core"; +import { linkAccountFromSvelteAction } from "@/lib/auth/svelte-auth-actions"; +import { prisma } from "@/lib/db/prisma"; +import { logServerActionError } from "@/lib/log/app-logger"; +import { resolveWelcomeCallbackUrl } from "./welcome-callback-url"; +import { getWelcomeCopy } from "./welcome-page-copy"; + +const REFRESHABLE_PROVIDERS = new Set(["github", "google", "oidc"]); + +export async function refreshWelcomeOAuthProfile({ + cookies, + locals, + request, +}: { + cookies: Cookies; + locals: App.Locals; + request: Request; +}) { + const form = await request.formData(); + const callbackUrl = resolveWelcomeCallbackUrl(form.get("callbackUrl")); + const session = await getSessionFromHeaders(request.headers); + if (!session?.user?.id) { + throw redirect( + 303, + buildSignInPageUrl( + `/account/welcome?callbackUrl=${encodeURIComponent(callbackUrl)}`, + ), + ); + } + + const providerId = String(form.get("providerId") ?? ""); + if (!REFRESHABLE_PROVIDERS.has(providerId)) { + return fail(400, { + message: getWelcomeCopy(locals.locale).welcome.oauthRefreshFailed, + }); + } + const linkedAccount = await prisma.account.findFirst({ + where: { + userId: session.user.id, + provider: providerId, + }, + select: { id: true }, + }); + if (!linkedAccount) { + return fail(400, { + message: getWelcomeCopy(locals.locale).welcome.oauthRefreshNotLinked, + }); + } + + const returnTo = `/account/welcome?callbackUrl=${encodeURIComponent(callbackUrl)}&oauthRefreshed=1`; + try { + const result = await linkAccountFromSvelteAction({ + providerId, + callbackUrl: returnTo, + headers: request.headers, + cookies, + }); + throw redirect(303, result.url); + } catch (error) { + if ( + error && + typeof error === "object" && + "status" in error && + "location" in error + ) { + throw error; + } + logServerActionError("auth.welcome_oauth_refresh.failed", error, { + action: "refresh-oauth-profile", + requestId: locals.requestId, + route: "/account/welcome", + }); + return fail(400, { + message: getWelcomeCopy(locals.locale).welcome.oauthRefreshFailed, + }); + } +} diff --git a/src/features/welcome/server/welcome-page-server.ts b/src/features/welcome/server/welcome-page-server.ts index 15ad15ed9..b15cee348 100644 --- a/src/features/welcome/server/welcome-page-server.ts +++ b/src/features/welcome/server/welcome-page-server.ts @@ -1,12 +1,49 @@ import { redirect, type ServerLoadEvent } from "@sveltejs/kit"; +import { providerNames } from "@/features/auth/server/signin-page-copy"; import { getCurrentSemester } from "@/features/catalog/server/academic-metadata-read-model"; +import { + buildWelcomeStepUrl, + nextWelcomeStep, + parseWelcomeStep, + previousWelcomeStep, + WELCOME_STEPS, + type WelcomeStep, + welcomeStepNumber, +} from "@/features/welcome/lib/welcome-steps"; import { buildSignInPageUrl } from "@/lib/auth/auth-routing"; import { getSessionFromHeaders } from "@/lib/auth/core"; import { prisma } from "@/lib/db/prisma"; import { resolveWelcomeCallbackUrl } from "./welcome-callback-url"; import { completeWelcomeProfile } from "./welcome-complete-action"; +import { refreshWelcomeOAuthProfile } from "./welcome-oauth-refresh-action"; import { getWelcomeCopy } from "./welcome-page-copy"; +const REFRESHABLE_PROVIDERS = new Set(["github", "google", "oidc"]); + +const STEP_TITLE_KEYS = { + profile: "stepProfile", + subscriptions: "stepSubscriptions", + finish: "stepFinish", +} as const satisfies Record; + +function buildStepIndicators( + step: WelcomeStep, + copy: ReturnType, +) { + const currentNumber = welcomeStepNumber(step); + return WELCOME_STEPS.map((id) => ({ + id, + label: copy.welcome[STEP_TITLE_KEYS[id]], + number: welcomeStepNumber(id), + state: + id === step + ? ("current" as const) + : welcomeStepNumber(id) < currentNumber + ? ("complete" as const) + : ("upcoming" as const), + })); +} + export const loadWelcomePage = async ({ locals, request, @@ -34,6 +71,9 @@ export const loadWelcomePage = async ({ username: true, image: true, profilePictures: true, + accounts: { + select: { provider: true }, + }, }, }), prisma.semester.findMany({ @@ -53,20 +93,59 @@ export const loadWelcomePage = async ({ ); } - if (user.name && user.username) { + // Profile is the only required step, so an incomplete profile always returns + // there and a complete profile never lands back on it. + const hasCompleteProfile = Boolean(user.name && user.username); + const step = hasCompleteProfile + ? parseWelcomeStep(url.searchParams.get("step")) + : "profile"; + if (hasCompleteProfile && step === "profile") { throw redirect(303, callbackUrl); } + const copy = getWelcomeCopy(locals.locale); + const previousStep = previousWelcomeStep(step); + const followingStep = nextWelcomeStep(step); + return { - user, + step, + stepIndicators: buildStepIndicators(step, copy), + backUrl: previousStep + ? buildWelcomeStepUrl(previousStep, callbackUrl) + : null, + nextUrl: followingStep + ? buildWelcomeStepUrl(followingStep, callbackUrl) + : callbackUrl, + user: { + id: user.id, + name: user.name, + username: user.username, + image: user.image, + profilePictures: user.profilePictures, + }, + oauthProviders: Array.from( + new Set( + user.accounts + .map(({ provider }) => provider) + .filter((provider) => REFRESHABLE_PROVIDERS.has(provider)), + ), + ).map((id) => ({ + id, + name: + providerNames(locals.locale)[ + id as keyof ReturnType + ] ?? id, + })), + oauthRefreshed: url.searchParams.get("oauthRefreshed") === "1", semesters, defaultSemesterId: currentSemester?.id ?? null, callbackUrl, locale: locals.locale, - copy: getWelcomeCopy(locals.locale), + copy, }; }; export const welcomeActions = { complete: completeWelcomeProfile, + refreshOAuth: refreshWelcomeOAuthProfile, }; diff --git a/src/features/welcome/server/welcome-profile-form.ts b/src/features/welcome/server/welcome-profile-form.ts index d15859578..125791251 100644 --- a/src/features/welcome/server/welcome-profile-form.ts +++ b/src/features/welcome/server/welcome-profile-form.ts @@ -1,7 +1,12 @@ export function parseWelcomeProfileForm(form: FormData) { const submittedImage = form.get("image"); + const submittedAvatar = form.get("avatar"); return { + avatar: + submittedAvatar instanceof File && submittedAvatar.size > 0 + ? submittedAvatar + : null, callbackUrl: String(form.get("callbackUrl") ?? "").trim(), name: String(form.get("name") ?? "").trim(), username: String(form.get("username") ?? "").trim(), diff --git a/src/lib/adapters/cloudflare-runtime.ts b/src/lib/adapters/cloudflare-runtime.ts index 2b952b33a..49fb79425 100644 --- a/src/lib/adapters/cloudflare-runtime.ts +++ b/src/lib/adapters/cloudflare-runtime.ts @@ -26,6 +26,29 @@ export type CloudflareR2Bucket = { ): Promise; }; +export type CloudflareImageTransformationResult = { + contentType(): string; + image(): ReadableStream; + response(): Response; +}; + +export type CloudflareImageTransformer = { + output(options: { + format: "image/webp"; + quality?: number; + }): Promise; + transform(options: { + fit: "cover"; + gravity?: "auto" | "center" | "face"; + height: number; + width: number; + }): CloudflareImageTransformer; +}; + +export type CloudflareImagesBinding = { + input(stream: ReadableStream): CloudflareImageTransformer; +}; + export type CloudflareAnalyticsEngineDataPoint = { blobs?: ((ArrayBuffer | string) | null)[]; doubles?: number[]; @@ -105,6 +128,7 @@ type CloudflareRuntimeEnv = Record & { HYPERDRIVE_AUTH?: { connectionString?: unknown; }; + IMAGES?: CloudflareImagesBinding; R2_UPLOADS?: CloudflareR2Bucket; USER_BATCH_WRITE_RATE_LIMITER?: CloudflareRateLimiter; USER_WRITE_RATE_LIMITER?: CloudflareRateLimiter; @@ -274,6 +298,10 @@ export function getCloudflareR2UploadsBucket() { return getCurrentCloudflareRuntimeEnv()?.R2_UPLOADS; } +export function getCloudflareImagesBinding() { + return getCurrentCloudflareRuntimeEnv()?.IMAGES; +} + export function getCloudflareAnalyticsEngineDataset() { return getCurrentCloudflareRuntimeEnv()?.ANALYTICS; } diff --git a/src/lib/auth/auth-routing.ts b/src/lib/auth/auth-routing.ts index 1a7d52ce3..1c8184a38 100644 --- a/src/lib/auth/auth-routing.ts +++ b/src/lib/auth/auth-routing.ts @@ -106,6 +106,7 @@ function isOAuthCallbackContinuation(url: URL): boolean { function isNonPageRequestPath(pathname: string): boolean { return ( pathname.startsWith("/api/") || + pathname.startsWith("/media/") || pathname.startsWith("/.well-known/") || pathname.startsWith("/_app/") || pathname === "/llms.txt" || diff --git a/src/lib/auth/oauth-profile-mappers.ts b/src/lib/auth/oauth-profile-mappers.ts index 0ea993ca4..7ef378c55 100644 --- a/src/lib/auth/oauth-profile-mappers.ts +++ b/src/lib/auth/oauth-profile-mappers.ts @@ -27,10 +27,20 @@ export function mapOidcProfileToUser(profile: OAuthProfile) { "email", ]) ?? `USTC User ${accountId}`; + const image = profileImage(profile.picture); + stageSocialVerifiedEmail({ + provider: "oidc", + accountId, + email: null, + emailVerified: false, + name: displayName, + image: image ?? null, + }); + return { email: fallbackEmail("oidc", accountId), name: displayName, - image: profileImage(profile.picture), + image, emailVerified: false, }; } @@ -45,18 +55,16 @@ export function getOidcAccountSubject(profile: OAuthProfile) { export function mapGithubProfileToUser(profile: GithubProfile) { const email = profileEmail(profile.email); - if (isPublishableUserEmail(email)) { - stageSocialVerifiedEmail({ - provider: "github", - accountId: String(profile.id), - email, - // GitHub user:email returns account mailboxes; treat as verified for - // OAuth client publication once stored in VerifiedEmail. - emailVerified: true, - name: profileName(profile.name ?? profile.login) || null, - image: profileImage(profile.avatar_url) ?? null, - }); - } + stageSocialVerifiedEmail({ + provider: "github", + accountId: String(profile.id), + email: isPublishableUserEmail(email) ? email : null, + // GitHub user:email returns account mailboxes; treat as verified for + // OAuth client publication once stored in VerifiedEmail. + emailVerified: isPublishableUserEmail(email), + name: profileName(profile.name ?? profile.login) || null, + image: profileImage(profile.avatar_url) ?? null, + }); return { email: email ?? fallbackEmail("github", profile.id), @@ -73,16 +81,14 @@ export function mapGoogleProfileToUser(profile: GoogleProfile) { ? profile.email_verified : false; - if (isPublishableUserEmail(email) && emailVerified) { - stageSocialVerifiedEmail({ - provider: "google", - accountId: profile.sub, - email, - emailVerified: true, - name: profileName(profile.name) || null, - image: profileImage(profile.picture) ?? null, - }); - } + stageSocialVerifiedEmail({ + provider: "google", + accountId: profile.sub, + email: isPublishableUserEmail(email) && emailVerified ? email : null, + emailVerified, + name: profileName(profile.name) || null, + image: profileImage(profile.picture) ?? null, + }); return { email: email ?? fallbackEmail("google", profile.sub), diff --git a/src/lib/auth/social-verified-email-plugin.ts b/src/lib/auth/social-verified-email-plugin.ts index 3c368936e..9eea3d6f6 100644 --- a/src/lib/auth/social-verified-email-plugin.ts +++ b/src/lib/auth/social-verified-email-plugin.ts @@ -6,8 +6,10 @@ import { import { upsertVerifiedEmail } from "@/lib/auth/oauth-user-email-resolve"; import { consumeStagedSocialVerifiedEmail } from "@/lib/auth/social-verified-email-staging"; import { authPrisma } from "@/lib/db/auth-prisma"; +import { prisma } from "@/lib/db/prisma"; +import { logAppEvent } from "@/lib/log/app-logger"; -const SOCIAL_VERIFIED_EMAIL_PROVIDERS = new Set(["github", "google"]); +const SOCIAL_PROFILE_PROVIDERS = new Set(["github", "google", "oidc"]); type AccountHookPayload = Pick< Account, @@ -16,14 +18,14 @@ type AccountHookPayload = Pick< async function applySocialVerifiedEmailToUser(input: { userId: string; - email: string; + email: string | null; emailVerified: boolean; name: string | null; image: string | null; }) { const current = await authPrisma.user.findUnique({ where: { id: input.userId }, - select: { email: true, name: true, image: true }, + select: { email: true, name: true, image: true, profilePictures: true }, }); if (!current) return; @@ -34,7 +36,11 @@ async function applySocialVerifiedEmailToUser(input: { image?: string | null; } = {}; - if (isPlaceholderUserEmail(current.email)) { + if ( + input.email && + isPublishableUserEmail(input.email) && + isPlaceholderUserEmail(current.email) + ) { profileUpdate.email = input.email; profileUpdate.emailVerified = input.emailVerified; } @@ -44,24 +50,39 @@ async function applySocialVerifiedEmailToUser(input: { if (input.image && !current.image) { profileUpdate.image = input.image; } - - if (Object.keys(profileUpdate).length === 0) return; - - try { - await authPrisma.user.update({ - where: { id: input.userId }, - data: profileUpdate, - }); - } catch { - // Unique email conflicts should not fail social login; VerifiedEmail still - // holds the upstream mailbox for OAuth userinfo resolution. + if (Object.keys(profileUpdate).length > 0) { + try { + await authPrisma.user.update({ + where: { id: input.userId }, + data: profileUpdate, + }); + } catch { + // Unique email conflicts should not fail social login; VerifiedEmail still + // holds the upstream mailbox for OAuth userinfo resolution. + } + } + if (input.image && !current.profilePictures.includes(input.image)) { + try { + await prisma.user.update({ + where: { id: input.userId }, + data: { profilePictures: { push: input.image } }, + select: { id: true }, + }); + } catch (error) { + logAppEvent( + "warn", + "Failed to persist upstream avatar as a profile option", + { source: "auth" }, + error, + ); + } } } export async function syncSocialVerifiedEmailFromAccountHook( account: AccountHookPayload, ) { - if (!SOCIAL_VERIFIED_EMAIL_PROVIDERS.has(account.providerId)) return; + if (!SOCIAL_PROFILE_PROVIDERS.has(account.providerId)) return; const accountId = account.providerAccountId.trim(); if (!accountId) return; @@ -70,14 +91,19 @@ export async function syncSocialVerifiedEmailFromAccountHook( account.providerId, accountId, ); - if (!staged || !isPublishableUserEmail(staged.email)) return; + if (!staged) return; - const email = staged.email.trim(); - await upsertVerifiedEmail({ - userId: account.userId, - provider: account.providerId, - email, - }); + const email = + staged.email && isPublishableUserEmail(staged.email) + ? staged.email.trim() + : null; + if (email) { + await upsertVerifiedEmail({ + userId: account.userId, + provider: account.providerId, + email, + }); + } await applySocialVerifiedEmailToUser({ userId: account.userId, email, diff --git a/src/lib/auth/social-verified-email-staging.ts b/src/lib/auth/social-verified-email-staging.ts index e02523266..cff145eb5 100644 --- a/src/lib/auth/social-verified-email-staging.ts +++ b/src/lib/auth/social-verified-email-staging.ts @@ -1,7 +1,7 @@ export type StagedSocialVerifiedEmail = { provider: string; accountId: string; - email: string; + email: string | null; emailVerified: boolean; name: string | null; image: string | null; diff --git a/src/routes/media/avatars/[userId]/[avatarId].webp/+server.ts b/src/routes/media/avatars/[userId]/[avatarId].webp/+server.ts new file mode 100644 index 000000000..8fdc551f0 --- /dev/null +++ b/src/routes/media/avatars/[userId]/[avatarId].webp/+server.ts @@ -0,0 +1,15 @@ +import type { RequestHandler } from "@sveltejs/kit"; +import { getPublicProfileAvatar } from "@/features/profile/server/profile-avatar-service"; +import { handleRouteError, notFound } from "@/lib/api/helpers"; + +export const GET: RequestHandler = async ({ params }) => { + try { + const response = await getPublicProfileAvatar({ + avatarId: params.avatarId, + userId: params.userId, + }); + return response ?? notFound(); + } catch (error) { + return handleRouteError("Failed to load profile avatar", error); + } +}; diff --git a/tests/e2e/src/app/_shared/page-inventory.ts b/tests/e2e/src/app/_shared/page-inventory.ts index 3c17c15f0..c8d00ab28 100644 --- a/tests/e2e/src/app/_shared/page-inventory.ts +++ b/tests/e2e/src/app/_shared/page-inventory.ts @@ -233,12 +233,42 @@ export const PAGE_INVENTORY: readonly PageInventoryEntry[] = [ name: "/^(用户名|Username)\\b/i", e2eSpec: E2E.welcome, }, + { + id: "welcome-avatar-upload", + role: "button", + name: "/上传自己的头像|Upload your own avatar/i", + e2eSpec: E2E.welcome, + }, + { + id: "welcome-complete", + role: "button", + name: "/继续|Continue/i", + e2eSpec: E2E.welcome, + }, { id: "bulk-import", role: "button", name: "/批量添加订阅|Bulk Add Subscriptions/i", e2eSpec: E2E.welcome, }, + { + id: "welcome-skip-step", + role: "link", + name: "/暂时跳过|Skip for now/i", + e2eSpec: E2E.welcome, + }, + { + id: "welcome-finish", + role: "link", + name: "/进入工作区|Go to workspace/i", + e2eSpec: E2E.welcome, + }, + { + id: "welcome-back-step", + role: "link", + name: "/上一步|Back/i", + e2eSpec: E2E.welcome, + }, ], }, { diff --git a/tests/e2e/src/app/signin/test.ts b/tests/e2e/src/app/signin/test.ts index a3de50e49..c470e4f58 100644 --- a/tests/e2e/src/app/signin/test.ts +++ b/tests/e2e/src/app/signin/test.ts @@ -87,6 +87,25 @@ test("/account/sign-in 显示所有必填字段", async ({ page }, testInfo) => await captureStepScreenshot(page, testInfo, "signin/all-fields"); }); +test("/account/sign-in 显示账户未关联错误", async ({ page }) => { + await gotoAndWaitForReady( + page, + "/account/sign-in?error=OAuthAccountNotLinked", + ); + await expect( + page.getByText(/此账户已关联到其他用户|already linked to another user/i), + ).toBeVisible(); +}); + +test("/account/sign-in 已登录用户直接返回回调页面", async ({ page }) => { + await signInAsDebugUser(page, "/"); + await page.goto( + "/account/sign-in?callbackUrl=%2Faccount%2Fsettings%2Fprofile", + { waitUntil: "domcontentloaded" }, + ); + await expect(page).toHaveURL(/\/account\/settings\/profile(?:\?.*)?$/); +}); + test("/account/sign-in 调试用户按钮可登录", async ({ page }, testInfo) => { await gotoAndWaitForReady(page, "/account/sign-in", { testInfo, diff --git a/tests/e2e/src/app/welcome/test.ts b/tests/e2e/src/app/welcome/test.ts index 9fc3b0748..f5dfa6968 100644 --- a/tests/e2e/src/app/welcome/test.ts +++ b/tests/e2e/src/app/welcome/test.ts @@ -11,13 +11,15 @@ * * ## Features * - Unauthenticated → redirect to /signin - * - Users with no name/username must complete before proceeding to / - * - Avatar selector grid is shown - * - Semester dropdown pre-selects the current semester - * - Links to browse sections / courses and bulk import + * - Staged flow: required profile step, then optional subscriptions and + * orientation steps, each with a progress indicator + * - Users with no name/username must complete the profile step first + * - Avatar selector grid and custom avatar upload are shown + * - Semester dropdown pre-selects the current semester (subscriptions step) * * ## Edge Cases - * - After successful save redirects to / + * - A complete profile requesting the profile step leaves onboarding + * - The final step returns to the original callbackUrl * - Name and username fields are restored to seed values after test */ import { expect, test } from "@playwright/test"; @@ -40,7 +42,9 @@ test("/account/welcome 未登录重定向到登录页", async ({ page }, testInf await captureStepScreenshot(page, testInfo, "welcome/unauthorized"); }); -test("/account/welcome 显示必填字段", async ({ page }, testInfo) => { +test("/account/welcome 资料步骤显示必填字段与进度", async ({ + page, +}, testInfo) => { test.setTimeout(300_000); await signInAsDebugUser(page, "/"); const sessionUser = await getCurrentSessionUser(page); @@ -61,6 +65,9 @@ test("/account/welcome 显示必填字段", async ({ page }, testInfo) => { await expect( page.getByRole("textbox", { name: /^(用户名|Username)\b/i }), ).toBeVisible(); + await expect( + page.getByLabel(/上传自己的头像|Upload your own avatar/i), + ).toBeVisible(); // user.image / user.profilePictures[] — avatar area should be visible const avatarArea = page @@ -68,26 +75,13 @@ test("/account/welcome 显示必填字段", async ({ page }, testInfo) => { .first(); await expect(avatarArea).toBeVisible(); - // semesters[] dropdown options (defaultSemesterId preselected) - // The semester selector is inside the Bulk Import dialog - const bulkImportBtn = page.getByRole("button", { - name: /批量添加订阅|Bulk Add Subscriptions/i, - }); - await expect(bulkImportBtn).toBeVisible(); - await bulkImportBtn.click(); - // Dialog opens, semester selector inside - const dialog = page - .getByRole("dialog") - .or(page.getByRole("alertdialog")) - .first(); - await expect(dialog).toBeVisible({ timeout: 8_000 }); - const semesterSelector = dialog - .getByRole("combobox", { name: /^(学期|Semester)\b/i }) - .first(); - await expect(semesterSelector).toBeVisible(); - await expect(semesterSelector).toContainText(DEV_SEED.semesterNameCn); - // Close dialog - await page.keyboard.press("Escape"); + // Only the current step is rendered, so later steps stay out of the way. + await expect(page.getByText(/第 1 步|Step 1 of/i)).toBeVisible(); + await expect( + page.getByRole("button", { + name: /批量添加订阅|Bulk Add Subscriptions/i, + }), + ).toHaveCount(0); await captureStepScreenshot(page, testInfo, "welcome/fields"); } finally { @@ -99,6 +93,52 @@ test("/account/welcome 显示必填字段", async ({ page }, testInfo) => { } }); +test("/account/welcome 本地图片处理不可用时保留表单并显示错误", async ({ + page, +}) => { + test.setTimeout(300_000); + await signInAsDebugUser(page, "/"); + const sessionUser = await getCurrentSessionUser(page); + const originalUser = await getUserProfileById(sessionUser.id); + await updateUserProfileById(sessionUser.id, { name: null, username: null }); + + try { + await gotoAndWaitForReady(page, "/account/welcome"); + await page + .getByLabel(/上传自己的头像|Upload your own avatar/i) + .setInputFiles({ + name: "avatar.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6n0sAAAAASUVORK5CYII=", + "base64", + ), + }); + await page + .getByRole("textbox", { name: /^(姓名|Name)\b/i }) + .fill(DEV_SEED.debugName); + await page + .getByRole("textbox", { name: /^(用户名|Username)\b/i }) + .fill(DEV_SEED.debugUsername); + await page.getByRole("button", { name: /继续|Continue/i }).click(); + await expect(page).toHaveURL(/\/account\/welcome(?:\?.*)?$/); + await expect( + page.getByText( + /头像处理服务暂时不可用|Avatar processing is temporarily unavailable/i, + ), + ).toBeVisible(); + + const unchangedUser = await getUserProfileById(sessionUser.id); + expect(unchangedUser.image).toBe(originalUser.image); + } finally { + await updateUserProfileById(sessionUser.id, { + name: originalUser.name ?? DEV_SEED.debugName, + username: originalUser.username ?? DEV_SEED.debugUsername, + image: originalUser.image ?? null, + }); + } +}); + test("资料不完整的登录用户从普通页面重定向到 /welcome", async ({ page }) => { test.setTimeout(300_000); await signInAsDebugUser(page, "/"); @@ -158,6 +198,18 @@ test("/account/welcome 完成后返回原回调页面", async ({ page }, testInf await page.getByRole("button", { name: /继续|Continue/i }).click(); + await expect(page).toHaveURL( + /\/account\/welcome\?step=subscriptions&callbackUrl=%2Faccount%2Fsettings$/, + { timeout: 15_000 }, + ); + await page.getByRole("link", { name: /暂时跳过|Skip for now/i }).click(); + await expect(page).toHaveURL( + /\/account\/welcome\?step=finish&callbackUrl=%2Faccount%2Fsettings$/, + ); + await page + .getByRole("link", { name: /进入工作区|Go to workspace/i }) + .click(); + await expect(page).toHaveURL(/\/account\/settings\/profile(?:\?.*)?$/, { timeout: 15_000, }); @@ -202,6 +254,14 @@ test("/account/welcome 未完善资料的用户可完成资料并返回首页", await page.getByRole("button", { name: /继续|Continue/i }).click(); + await expect(page).toHaveURL(/step=subscriptions/, { timeout: 15_000 }); + await expect(page.getByText(/第 2 步|Step 2 of/i)).toBeVisible(); + await page.getByRole("link", { name: /暂时跳过|Skip for now/i }).click(); + await expect(page.getByText(/第 3 步|Step 3 of/i)).toBeVisible(); + await page + .getByRole("link", { name: /进入工作区|Go to workspace/i }) + .click(); + await expect(page).toHaveURL(/\/workspace\/overview(?:\?.*)?$/, { timeout: 15_000, }); @@ -220,46 +280,69 @@ test("/account/welcome 未完善资料的用户可完成资料并返回首页", } }); -test("/account/welcome 提供浏览班级与批量匹配入口", async ({ +test("/account/welcome 订阅步骤提供浏览班级与批量匹配入口", async ({ page, }, testInfo) => { test.setTimeout(300_000); await signInAsDebugUser(page, "/"); - const sessionUser = await getCurrentSessionUser(page); - const originalUser = await getUserProfileById(sessionUser.id); - - await updateUserProfileById(sessionUser.id, { - name: null, - username: null, + await gotoAndWaitForReady( + page, + "/account/welcome?step=subscriptions&callbackUrl=%2Fworkspace%2Foverview", + { testInfo, screenshotLabel: "welcome-subscriptions" }, + ); + + await expect( + page.getByRole("link", { name: /浏览班级|Browse Sections/i }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: /浏览课程|Browse Courses/i }), + ).toBeVisible(); + + // semesters[] dropdown options (defaultSemesterId preselected) + const bulkImportBtn = page.getByRole("button", { + name: /批量添加订阅|Bulk Add Subscriptions/i, }); + await expect(bulkImportBtn).toBeVisible(); + await bulkImportBtn.click(); + const dialog = page + .getByRole("dialog") + .or(page.getByRole("alertdialog")) + .first(); + await expect(dialog).toBeVisible({ timeout: 8_000 }); + const semesterSelector = dialog + .getByRole("combobox", { name: /^(学期|Semester)\b/i }) + .first(); + await expect(semesterSelector).toBeVisible(); + await expect(semesterSelector).toContainText(DEV_SEED.semesterNameCn); + await page.keyboard.press("Escape"); + + await captureStepScreenshot(page, testInfo, "welcome/next-steps"); +}); - try { - await gotoAndWaitForReady(page, "/account/welcome", { - testInfo, - screenshotLabel: "welcome", - }); - - await expect( - page.getByRole("link", { name: /浏览班级|Browse Sections/i }), - ).toBeVisible(); - await expect( - page.getByRole("link", { name: /浏览课程|Browse Courses/i }), - ).toBeVisible(); - await expect( - page.getByRole("button", { - name: /批量添加订阅|Bulk Add Subscriptions/i, - }), - ).toBeVisible(); +test("/account/welcome 最后一步展示平台引导并可返回上一步", async ({ + page, +}, testInfo) => { + test.setTimeout(300_000); + await signInAsDebugUser(page, "/"); - await captureStepScreenshot(page, testInfo, "welcome/next-steps"); - } finally { - await updateUserProfileById(sessionUser.id, { - name: originalUser.name ?? DEV_SEED.debugName, - username: originalUser.username ?? DEV_SEED.debugUsername, - image: originalUser.image ?? null, - }); - } + await gotoAndWaitForReady( + page, + "/account/welcome?step=finish&callbackUrl=%2Fworkspace%2Foverview", + { testInfo, screenshotLabel: "welcome-finish" }, + ); + + await expect( + page.getByText(/订阅与工作区|Subscriptions and workspace/i), + ).toBeVisible(); + await expect(page.getByText(/日历与待办|Calendar and todos/i)).toBeVisible(); + await expect( + page.getByText(/账户与安全|Account and security/i), + ).toBeVisible(); + await captureStepScreenshot(page, testInfo, "welcome/finish"); + + await page.getByRole("link", { name: /上一步|Back/i }).click(); + await expect(page).toHaveURL(/step=subscriptions/); }); test("页面契约", async ({ page }, testInfo) => { diff --git a/tests/e2e/utils/auth.ts b/tests/e2e/utils/auth.ts index a406560dd..cfbc6bef3 100644 --- a/tests/e2e/utils/auth.ts +++ b/tests/e2e/utils/auth.ts @@ -109,6 +109,14 @@ async function completeWelcomeProfileIfNeeded( await nameInput.fill(DEV_SEED.debugName); await usernameInput.fill(DEV_SEED.debugUsername); await page.getByRole("button", { name: /继续|Continue/i }).click(); + // The profile step hands off to the optional onboarding steps, which the + // harness skips by navigating straight to the destination. + await page.waitForURL( + (url) => + !url.pathname.startsWith("/account/welcome") || + url.searchParams.get("step") !== null, + { timeout: 15_000 }, + ); await gotoAndWaitForReady(page, expectedPath); } diff --git a/tests/integration/rls-database-contract.test.ts b/tests/integration/rls-database-contract.test.ts index b9eac9fcb..e3b793492 100644 --- a/tests/integration/rls-database-contract.test.ts +++ b/tests/integration/rls-database-contract.test.ts @@ -124,6 +124,35 @@ describe.skipIf(process.env.RLS_TEST_ENABLED !== "true")( } }); + it("allows the app runtime to append trusted profile picture URLs", async () => { + const marker = `runtime-avatar-${crypto.randomUUID()}`; + const user = await adminPrisma.user.create({ + data: { + email: `${marker}@example.test`, + name: marker, + }, + select: { id: true }, + }); + + try { + await expect( + prisma.user.update({ + where: { id: user.id }, + data: { + profilePictures: { + push: `https://example.test/${marker}.webp`, + }, + }, + select: { profilePictures: true }, + }), + ).resolves.toEqual({ + profilePictures: [`https://example.test/${marker}.webp`], + }); + } finally { + await adminPrisma.user.delete({ where: { id: user.id } }); + } + }); + it("keeps exactly one runtime-applicable owner policy per table", async () => { const policies = await prisma.$queryRaw< { diff --git a/tests/unit/oauth-profile.test.ts b/tests/unit/oauth-profile.test.ts index c1baa59e2..dc13f99a1 100644 --- a/tests/unit/oauth-profile.test.ts +++ b/tests/unit/oauth-profile.test.ts @@ -30,6 +30,14 @@ describe("OAuth 档案映射", () => { image: undefined, emailVerified: false, }); + expect(consumeStagedSocialVerifiedEmail("oidc", "435")).toEqual({ + provider: "oidc", + accountId: "435", + email: null, + emailVerified: false, + name: "USTC User 435", + image: null, + }); }); it("忽略 passport fake_email 占位邮箱并回退到本地邮箱", () => { @@ -116,7 +124,14 @@ describe("OAuth 档案映射", () => { image: undefined, emailVerified: false, }); - expect(consumeStagedSocialVerifiedEmail("github", "octocat")).toBeNull(); + expect(consumeStagedSocialVerifiedEmail("github", "octocat")).toEqual({ + provider: "github", + accountId: "octocat", + email: null, + emailVerified: false, + name: "octocat", + image: null, + }); }); it("仅在邮箱已验证时暂存 Google 邮箱", () => { @@ -150,8 +165,13 @@ describe("OAuth 档案映射", () => { email_verified: false, }).emailVerified, ).toBe(false); - expect( - consumeStagedSocialVerifiedEmail("google", "google-user"), - ).toBeNull(); + expect(consumeStagedSocialVerifiedEmail("google", "google-user")).toEqual({ + provider: "google", + accountId: "google-user", + email: null, + emailVerified: false, + name: null, + image: null, + }); }); }); diff --git a/tests/unit/profile-avatar-service.test.ts b/tests/unit/profile-avatar-service.test.ts new file mode 100644 index 000000000..4a0582d67 --- /dev/null +++ b/tests/unit/profile-avatar-service.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + deleteStorageObjectMock, + getImagesBindingMock, + getStorageObjectResponseMock, + putStorageObjectMock, + userFindUniqueMock, +} = vi.hoisted(() => ({ + deleteStorageObjectMock: vi.fn(), + getImagesBindingMock: vi.fn(), + getStorageObjectResponseMock: vi.fn(), + putStorageObjectMock: vi.fn(), + userFindUniqueMock: vi.fn(), +})); + +vi.mock("@/lib/adapters/cloudflare-runtime", () => ({ + getCloudflareImagesBinding: getImagesBindingMock, +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { + user: { + findUnique: userFindUniqueMock, + }, + }, +})); + +vi.mock("@/lib/storage/r2-object", () => ({ + deleteStorageObject: deleteStorageObjectMock, + getStorageObjectResponse: getStorageObjectResponseMock, + putStorageObject: putStorageObjectMock, +})); + +import { + getPublicProfileAvatar, + PROFILE_AVATAR_MAX_BYTES, + processProfileAvatarUpload, +} from "@/features/profile/server/profile-avatar-service"; + +describe("profile avatar service", () => { + beforeEach(() => { + deleteStorageObjectMock.mockReset(); + getImagesBindingMock.mockReset(); + getStorageObjectResponseMock.mockReset(); + putStorageObjectMock.mockReset(); + userFindUniqueMock.mockReset(); + vi.restoreAllMocks(); + }); + + it("auto-crops an uploaded image and stores a 256px WebP in R2", async () => { + const response = new Response("transformed", { + headers: { "Content-Type": "image/webp" }, + }); + const output = vi.fn().mockResolvedValue({ + response: () => response, + }); + const transform = vi.fn().mockReturnValue({ output }); + const input = vi.fn().mockReturnValue({ transform }); + getImagesBindingMock.mockReturnValue({ input }); + vi.spyOn(crypto, "randomUUID").mockReturnValue( + "123e4567-e89b-12d3-a456-426614174000", + ); + + const result = await processProfileAvatarUpload({ + file: new File(["avatar"], "avatar.png", { type: "image/png" }), + userId: "user-1", + }); + + expect(transform).toHaveBeenCalledWith({ + width: 256, + height: 256, + fit: "cover", + gravity: "auto", + }); + expect(output).toHaveBeenCalledWith({ + format: "image/webp", + quality: 85, + }); + expect(putStorageObjectMock).toHaveBeenCalledWith({ + body: response.body, + contentType: "image/webp", + key: "avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + }); + expect(result).toEqual({ + key: "avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + url: "/media/avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + }); + }); + + it.each([ + [new File([], "empty.png", { type: "image/png" }), "empty"], + [new File(["text"], "avatar.txt", { type: "text/plain" }), "invalid_image"], + [ + new File([new Uint8Array(PROFILE_AVATAR_MAX_BYTES + 1)], "large.png", { + type: "image/png", + }), + "too_large", + ], + ] as const)("rejects invalid uploads", async (file, reason) => { + await expect( + processProfileAvatarUpload({ file, userId: "user-1" }), + ).rejects.toMatchObject({ reason }); + expect(putStorageObjectMock).not.toHaveBeenCalled(); + }); + + it("serves only avatar objects referenced by the owning profile", async () => { + const storedResponse = new Response("avatar"); + userFindUniqueMock.mockResolvedValue({ + image: "/media/avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + profilePictures: [], + }); + getStorageObjectResponseMock.mockResolvedValue(storedResponse); + + const response = await getPublicProfileAvatar({ + avatarId: "123e4567-e89b-12d3-a456-426614174000", + userId: "user-1", + }); + + expect(response?.headers.get("Cache-Control")).toBe( + "public, max-age=31536000, immutable", + ); + expect(getStorageObjectResponseMock).toHaveBeenCalledWith({ + contentDisposition: 'inline; filename="avatar.webp"', + contentType: "image/webp", + key: "avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp", + }); + + userFindUniqueMock.mockResolvedValue({ + image: "https://example.com/upstream.png", + profilePictures: [], + }); + await expect( + getPublicProfileAvatar({ + avatarId: "123e4567-e89b-12d3-a456-426614174000", + userId: "user-1", + }), + ).resolves.toBeNull(); + }); +}); diff --git a/tests/unit/profile-update-service.test.ts b/tests/unit/profile-update-service.test.ts index 28a3ccb3b..38851da85 100644 --- a/tests/unit/profile-update-service.test.ts +++ b/tests/unit/profile-update-service.test.ts @@ -1,17 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { authApiMock, isPrismaUniqueConstraintErrorMock, prismaMock } = - vi.hoisted(() => ({ - authApiMock: { - updateUser: vi.fn(), +const { + authApiMock, + isPrismaUniqueConstraintErrorMock, + logAppEventMock, + prismaMock, +} = vi.hoisted(() => ({ + authApiMock: { + updateUser: vi.fn(), + }, + isPrismaUniqueConstraintErrorMock: vi.fn(), + logAppEventMock: vi.fn(), + prismaMock: { + user: { + findUnique: vi.fn(), + update: vi.fn(), }, - isPrismaUniqueConstraintErrorMock: vi.fn(), - prismaMock: { - user: { - findUnique: vi.fn(), - }, - }, - })); + }, +})); vi.mock("@/lib/auth/core", () => ({ authApi: authApiMock, @@ -25,6 +31,10 @@ vi.mock("@/lib/db/prisma", () => ({ prisma: prismaMock, })); +vi.mock("@/lib/log/app-logger", () => ({ + logAppEvent: logAppEventMock, +})); + const profileInput = { headers: new Headers(), image: null, @@ -39,9 +49,151 @@ describe("updateOwnProfile", () => { isPrismaUniqueConstraintErrorMock.mockReset(); isPrismaUniqueConstraintErrorMock.mockReturnValue(false); prismaMock.user.findUnique.mockReset(); + prismaMock.user.update.mockReset(); + logAppEventMock.mockReset(); vi.resetModules(); }); + it.each([ + [{ ...profileInput, name: "" }, "name_required"], + [{ ...profileInput, username: "Invalid Name" }, "invalid_username"], + ] as const)("rejects invalid profile fields", async (input, reason) => { + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + + await expect(updateOwnProfile(input)).resolves.toEqual({ + ok: false, + reason, + }); + expect(prismaMock.user.findUnique).not.toHaveBeenCalled(); + }); + + it("rejects a missing user", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce(null); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + + await expect(updateOwnProfile(profileInput)).resolves.toEqual({ + ok: false, + reason: "user_not_found", + }); + }); + + it("rejects an avatar that is neither upstream nor server-processed", async () => { + prismaMock.user.findUnique.mockResolvedValueOnce({ + id: "user-1", + image: null, + profilePictures: ["https://example.test/allowed.webp"], + }); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + + await expect( + updateOwnProfile({ + ...profileInput, + image: "https://attacker.example/avatar.webp", + }), + ).resolves.toEqual({ + ok: false, + reason: "avatar_invalid", + }); + }); + + it("rejects a username owned by another user", async () => { + prismaMock.user.findUnique + .mockResolvedValueOnce({ + id: "user-1", + image: null, + profilePictures: [], + }) + .mockResolvedValueOnce({ id: "user-2" }); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + + await expect(updateOwnProfile(profileInput)).resolves.toEqual({ + ok: false, + reason: "username_taken", + }); + }); + + it("accepts a server-processed avatar and returns refreshed auth headers", async () => { + const headers = new Headers({ "set-cookie": "session=updated" }); + prismaMock.user.findUnique + .mockResolvedValueOnce({ + id: "user-1", + image: null, + profilePictures: [], + }) + .mockResolvedValueOnce(null); + authApiMock.updateUser.mockResolvedValueOnce({ headers }); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + const image = + "/media/avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp"; + + await expect( + updateOwnProfile({ + ...profileInput, + image, + trustedImageUrl: image, + }), + ).resolves.toEqual({ headers, ok: true }); + expect(authApiMock.updateUser).toHaveBeenCalledWith({ + body: { + image, + name: profileInput.name, + username: profileInput.username, + }, + headers: profileInput.headers, + returnHeaders: true, + }); + expect(prismaMock.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + profilePictures: { push: image }, + }, + select: { id: true }, + }); + }); + + it("keeps profile completion successful if saving the reusable avatar option fails", async () => { + const headers = new Headers(); + const storageError = new Error("profile picture list unavailable"); + prismaMock.user.findUnique + .mockResolvedValueOnce({ + id: "user-1", + image: null, + profilePictures: [], + }) + .mockResolvedValueOnce(null); + prismaMock.user.update.mockRejectedValueOnce(storageError); + authApiMock.updateUser.mockResolvedValueOnce({ headers }); + const { updateOwnProfile } = await import( + "@/features/profile/server/profile-update-service" + ); + const image = + "/media/avatars/user-1/123e4567-e89b-12d3-a456-426614174000.webp"; + + await expect( + updateOwnProfile({ + ...profileInput, + image, + trustedImageUrl: image, + }), + ).resolves.toEqual({ headers, ok: true }); + expect(logAppEventMock).toHaveBeenCalledWith( + "warn", + "Failed to persist processed avatar as a profile option", + { source: "profile" }, + storageError, + ); + }); + it("将用户名唯一性竞争映射为 username_taken", async () => { const uniqueConflict = new Error("unique conflict"); isPrismaUniqueConstraintErrorMock.mockReturnValueOnce(true); diff --git a/tests/unit/social-verified-email-sync.test.ts b/tests/unit/social-verified-email-sync.test.ts index 6e1c56fe6..b66888c19 100644 --- a/tests/unit/social-verified-email-sync.test.ts +++ b/tests/unit/social-verified-email-sync.test.ts @@ -7,6 +7,7 @@ import { const verifiedEmailUpsertMock = vi.fn(); const userFindUniqueMock = vi.fn(); const userUpdateMock = vi.fn(); +const profilePictureUpdateMock = vi.fn(); vi.mock("@/lib/db/auth-prisma", () => ({ authPrisma: { @@ -20,6 +21,14 @@ vi.mock("@/lib/db/auth-prisma", () => ({ }, })); +vi.mock("@/lib/db/prisma", () => ({ + prisma: { + user: { + update: (...args: unknown[]) => profilePictureUpdateMock(...args), + }, + }, +})); + import { syncSocialVerifiedEmailFromAccountHook } from "@/lib/auth/social-verified-email-plugin"; describe("social verified email sync", () => { @@ -28,6 +37,7 @@ describe("social verified email sync", () => { verifiedEmailUpsertMock.mockReset(); userFindUniqueMock.mockReset(); userUpdateMock.mockReset(); + profilePictureUpdateMock.mockReset(); }); it("persists GitHub email into VerifiedEmail and upgrades placeholder User.email", async () => { @@ -44,6 +54,7 @@ describe("social verified email sync", () => { email: "oidc-1@users.local", name: "USTC User 1", image: null, + profilePictures: [], }); userUpdateMock.mockResolvedValue(undefined); @@ -77,16 +88,55 @@ describe("social verified email sync", () => { image: "https://example.com/octocat.png", }, }); + expect(profilePictureUpdateMock).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + profilePictures: { push: "https://example.com/octocat.png" }, + }, + select: { id: true }, + }); }); - it("skips non-social providers and missing staged emails", async () => { + it("syncs OIDC profile images without publishing a provider email", async () => { + stageSocialVerifiedEmail({ + provider: "oidc", + accountId: "435", + email: null, + emailVerified: false, + name: "Student", + image: "https://example.com/ustc.png", + }); + userFindUniqueMock.mockResolvedValue({ + email: "oidc-435@users.local", + name: "", + image: null, + profilePictures: [], + }); + userUpdateMock.mockResolvedValue(undefined); + await syncSocialVerifiedEmailFromAccountHook({ providerId: "oidc", providerAccountId: "435", userId: "user-1", }); expect(verifiedEmailUpsertMock).not.toHaveBeenCalled(); + expect(userUpdateMock).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + name: "Student", + image: "https://example.com/ustc.png", + }, + }); + expect(profilePictureUpdateMock).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + profilePictures: { push: "https://example.com/ustc.png" }, + }, + select: { id: true }, + }); + }); + it("skips providers without staged profile data", async () => { await syncSocialVerifiedEmailFromAccountHook({ providerId: "google", providerAccountId: "google-user", diff --git a/tests/unit/welcome-complete-action.test.ts b/tests/unit/welcome-complete-action.test.ts new file mode 100644 index 000000000..3e6ddb8ba --- /dev/null +++ b/tests/unit/welcome-complete-action.test.ts @@ -0,0 +1,154 @@ +import type { Cookies } from "@sveltejs/kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + applyAuthResponseCookiesMock, + deleteProcessedProfileAvatarMock, + getSessionFromHeadersMock, + processProfileAvatarUploadMock, + updateOwnProfileMock, +} = vi.hoisted(() => ({ + applyAuthResponseCookiesMock: vi.fn(), + deleteProcessedProfileAvatarMock: vi.fn(), + getSessionFromHeadersMock: vi.fn(), + processProfileAvatarUploadMock: vi.fn(), + updateOwnProfileMock: vi.fn(), +})); + +vi.mock("@/features/profile/server/profile-avatar-service", async () => { + const actual = await vi.importActual< + typeof import("@/features/profile/server/profile-avatar-service") + >("@/features/profile/server/profile-avatar-service"); + return { + ...actual, + deleteProcessedProfileAvatar: deleteProcessedProfileAvatarMock, + processProfileAvatarUpload: processProfileAvatarUploadMock, + }; +}); + +vi.mock("@/features/profile/server/profile-update-service", () => ({ + updateOwnProfile: updateOwnProfileMock, +})); + +vi.mock("@/lib/auth/core", () => ({ + getSessionFromHeaders: getSessionFromHeadersMock, +})); + +vi.mock("@/lib/auth/svelte-auth-actions", () => ({ + applyAuthResponseCookies: applyAuthResponseCookiesMock, +})); + +import { ProfileAvatarUploadError } from "@/features/profile/server/profile-avatar-service"; +import { completeWelcomeProfile } from "@/features/welcome/server/welcome-complete-action"; + +const cookies = {} as Cookies; +const locals = { + authUser: null, + locale: "en-us" as const, + publicSsr: false, + requestId: "request-1", +}; + +function requestWithForm(entries: Record) { + const form = new FormData(); + for (const [key, value] of Object.entries(entries)) form.set(key, value); + return new Request("https://life.example/account/welcome?/complete", { + body: form, + method: "POST", + }); +} + +describe("completeWelcomeProfile", () => { + beforeEach(() => { + applyAuthResponseCookiesMock.mockReset(); + deleteProcessedProfileAvatarMock.mockReset(); + deleteProcessedProfileAvatarMock.mockResolvedValue(undefined); + getSessionFromHeadersMock.mockReset(); + getSessionFromHeadersMock.mockResolvedValue({ user: { id: "user-1" } }); + processProfileAvatarUploadMock.mockReset(); + updateOwnProfileMock.mockReset(); + }); + + it("passes a processed avatar through the trusted profile update boundary", async () => { + const headers = new Headers(); + processProfileAvatarUploadMock.mockResolvedValue({ + key: "avatars/user-1/avatar.webp", + url: "/media/avatars/user-1/avatar.webp", + }); + updateOwnProfileMock.mockResolvedValue({ headers, ok: true }); + + await expect( + completeWelcomeProfile({ + cookies, + locals, + request: requestWithForm({ + avatar: new File(["image"], "avatar.png", { type: "image/png" }), + callbackUrl: "/workspace/overview", + name: "Test User", + username: "test-user", + }), + }), + ).rejects.toMatchObject({ + location: + "/account/welcome?step=subscriptions&callbackUrl=%2Fworkspace%2Foverview", + status: 303, + }); + expect(updateOwnProfileMock).toHaveBeenCalledWith({ + headers: expect.any(Headers), + image: "/media/avatars/user-1/avatar.webp", + name: "Test User", + trustedImageUrl: "/media/avatars/user-1/avatar.webp", + userId: "user-1", + username: "test-user", + }); + expect(applyAuthResponseCookiesMock).toHaveBeenCalledWith(headers, cookies); + }); + + it("removes the processed object when profile validation fails", async () => { + processProfileAvatarUploadMock.mockResolvedValue({ + key: "avatars/user-1/avatar.webp", + url: "/media/avatars/user-1/avatar.webp", + }); + updateOwnProfileMock.mockResolvedValue({ + ok: false, + reason: "username_taken", + }); + + const result = await completeWelcomeProfile({ + cookies, + locals, + request: requestWithForm({ + avatar: new File(["image"], "avatar.png", { type: "image/png" }), + name: "Test User", + username: "taken", + }), + }); + + expect(result.status).toBe(400); + expect(deleteProcessedProfileAvatarMock).toHaveBeenCalledWith( + "avatars/user-1/avatar.webp", + ); + }); + + it("maps image processing failures to a user-visible message", async () => { + processProfileAvatarUploadMock.mockRejectedValue( + new ProfileAvatarUploadError("too_large"), + ); + + const result = await completeWelcomeProfile({ + cookies, + locals, + request: requestWithForm({ + avatar: new File(["image"], "avatar.png", { type: "image/png" }), + name: "Test User", + username: "test-user", + }), + }); + + expect(result.status).toBe(400); + expect(result.data).toEqual({ + message: "Avatar files must be 5 MB or smaller.", + }); + expect(updateOwnProfileMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/welcome-oauth-refresh-action.test.ts b/tests/unit/welcome-oauth-refresh-action.test.ts new file mode 100644 index 000000000..ea849b4ae --- /dev/null +++ b/tests/unit/welcome-oauth-refresh-action.test.ts @@ -0,0 +1,109 @@ +import type { Cookies } from "@sveltejs/kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + accountFindFirstMock, + getSessionFromHeadersMock, + linkAccountFromSvelteActionMock, + logServerActionErrorMock, +} = vi.hoisted(() => ({ + accountFindFirstMock: vi.fn(), + getSessionFromHeadersMock: vi.fn(), + linkAccountFromSvelteActionMock: vi.fn(), + logServerActionErrorMock: vi.fn(), +})); + +vi.mock("@/lib/auth/core", () => ({ + getSessionFromHeaders: getSessionFromHeadersMock, +})); + +vi.mock("@/lib/auth/svelte-auth-actions", () => ({ + linkAccountFromSvelteAction: linkAccountFromSvelteActionMock, +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { + account: { + findFirst: accountFindFirstMock, + }, + }, +})); + +vi.mock("@/lib/log/app-logger", () => ({ + logServerActionError: logServerActionErrorMock, +})); + +import { refreshWelcomeOAuthProfile } from "@/features/welcome/server/welcome-oauth-refresh-action"; + +const cookies = {} as Cookies; +const locals = { + authUser: null, + locale: "en-us" as const, + publicSsr: false, + requestId: "request-1", +}; + +function request(providerId: string) { + return new Request("https://life.example/account/welcome?/refreshOAuth", { + body: new URLSearchParams({ + callbackUrl: "/account/settings", + providerId, + }), + method: "POST", + }); +} + +describe("refreshWelcomeOAuthProfile", () => { + beforeEach(() => { + accountFindFirstMock.mockReset(); + getSessionFromHeadersMock.mockReset(); + linkAccountFromSvelteActionMock.mockReset(); + logServerActionErrorMock.mockReset(); + getSessionFromHeadersMock.mockResolvedValue({ user: { id: "user-1" } }); + }); + + it("reauthorizes only a provider linked to the current user", async () => { + accountFindFirstMock.mockResolvedValue({ id: "account-1" }); + linkAccountFromSvelteActionMock.mockResolvedValue({ + url: "https://provider.example/authorize", + }); + + await expect( + refreshWelcomeOAuthProfile({ + cookies, + locals, + request: request("github"), + }), + ).rejects.toMatchObject({ + location: "https://provider.example/authorize", + status: 303, + }); + expect(accountFindFirstMock).toHaveBeenCalledWith({ + where: { userId: "user-1", provider: "github" }, + select: { id: true }, + }); + expect(linkAccountFromSvelteActionMock).toHaveBeenCalledWith({ + providerId: "github", + callbackUrl: + "/account/welcome?callbackUrl=%2Faccount%2Fsettings&oauthRefreshed=1", + headers: expect.any(Headers), + cookies, + }); + }); + + it("rejects a provider that is not linked to the current user", async () => { + accountFindFirstMock.mockResolvedValue(null); + + const result = await refreshWelcomeOAuthProfile({ + cookies, + locals, + request: request("google"), + }); + + expect(result.status).toBe(400); + expect(result.data).toEqual({ + message: "That sign-in account is not linked to the current user.", + }); + expect(linkAccountFromSvelteActionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/welcome-page-server.test.ts b/tests/unit/welcome-page-server.test.ts new file mode 100644 index 000000000..4c3e3ff2d --- /dev/null +++ b/tests/unit/welcome-page-server.test.ts @@ -0,0 +1,140 @@ +import type { ServerLoadEvent } from "@sveltejs/kit"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + getCurrentSemesterMock, + getSessionFromHeadersMock, + semesterFindManyMock, + userFindUniqueMock, +} = vi.hoisted(() => ({ + getCurrentSemesterMock: vi.fn(), + getSessionFromHeadersMock: vi.fn(), + semesterFindManyMock: vi.fn(), + userFindUniqueMock: vi.fn(), +})); + +vi.mock("@/features/catalog/server/academic-metadata-read-model", () => ({ + getCurrentSemester: getCurrentSemesterMock, +})); + +vi.mock("@/lib/auth/core", () => ({ + getSessionFromHeaders: getSessionFromHeadersMock, +})); + +vi.mock("@/lib/db/prisma", () => ({ + prisma: { + semester: { findMany: semesterFindManyMock }, + user: { findUnique: userFindUniqueMock }, + }, +})); + +import { loadWelcomePage } from "@/features/welcome/server/welcome-page-server"; + +function loadEvent(search: string) { + const url = new URL(`https://life.example/account/welcome${search}`); + return { + locals: { + authUser: null, + locale: "en-us" as const, + publicSsr: false, + requestId: "request-1", + }, + request: new Request(url), + url, + } as unknown as ServerLoadEvent; +} + +describe("loadWelcomePage", () => { + beforeEach(() => { + getCurrentSemesterMock.mockReset(); + getCurrentSemesterMock.mockResolvedValue({ id: 7 }); + getSessionFromHeadersMock.mockReset(); + getSessionFromHeadersMock.mockResolvedValue({ user: { id: "user-1" } }); + semesterFindManyMock.mockReset(); + semesterFindManyMock.mockResolvedValue([{ id: 7, nameCn: "2026 秋" }]); + userFindUniqueMock.mockReset(); + }); + + it("keeps an incomplete profile on the required first step", async () => { + userFindUniqueMock.mockResolvedValue({ + id: "user-1", + name: null, + username: null, + image: null, + profilePictures: [], + accounts: [{ provider: "github" }, { provider: "credential" }], + }); + + const data = await loadWelcomePage(loadEvent("?step=finish")); + + expect(data.step).toBe("profile"); + expect(data.backUrl).toBeNull(); + expect(data.nextUrl).toBe( + "/account/welcome?step=subscriptions&callbackUrl=%2F", + ); + expect(data.stepIndicators).toEqual([ + { id: "profile", label: "Your profile", number: 1, state: "current" }, + { + id: "subscriptions", + label: "Section subscriptions", + number: 2, + state: "upcoming", + }, + { id: "finish", label: "Get started", number: 3, state: "upcoming" }, + ]); + expect(data.oauthProviders).toEqual([{ id: "github", name: "GitHub" }]); + }); + + it("leaves onboarding when a complete profile requests the first step", async () => { + userFindUniqueMock.mockResolvedValue({ + id: "user-1", + name: "Test User", + username: "test-user", + image: null, + profilePictures: [], + accounts: [], + }); + + await expect( + loadWelcomePage(loadEvent("?callbackUrl=%2Faccount%2Fsettings")), + ).rejects.toMatchObject({ + location: "/account/settings", + status: 303, + }); + }); + + it("advances a complete profile through the optional steps", async () => { + userFindUniqueMock.mockResolvedValue({ + id: "user-1", + name: "Test User", + username: "test-user", + image: null, + profilePictures: [], + accounts: [], + }); + + const subscriptions = await loadWelcomePage( + loadEvent("?step=subscriptions&callbackUrl=%2Fworkspace%2Foverview"), + ); + expect(subscriptions.step).toBe("subscriptions"); + expect(subscriptions.backUrl).toBe( + "/account/welcome?step=profile&callbackUrl=%2Fworkspace%2Foverview", + ); + expect(subscriptions.nextUrl).toBe( + "/account/welcome?step=finish&callbackUrl=%2Fworkspace%2Foverview", + ); + expect( + subscriptions.stepIndicators.map(({ id, state }) => [id, state]), + ).toEqual([ + ["profile", "complete"], + ["subscriptions", "current"], + ["finish", "upcoming"], + ]); + + const finish = await loadWelcomePage( + loadEvent("?step=finish&callbackUrl=%2Fworkspace%2Foverview"), + ); + expect(finish.step).toBe("finish"); + expect(finish.nextUrl).toBe("/workspace/overview"); + }); +}); diff --git a/tests/unit/welcome-redirect.test.ts b/tests/unit/welcome-redirect.test.ts index f457684ac..8fa5f2eaa 100644 --- a/tests/unit/welcome-redirect.test.ts +++ b/tests/unit/welcome-redirect.test.ts @@ -24,6 +24,7 @@ describe("欢迎页重定向策略", () => { it("不重定向 API、发现服务或静态资源请求", () => { expect(shouldRedirect("/api/account/profile")).toBe(false); + expect(shouldRedirect("/media/avatars/user-1/avatar.webp")).toBe(false); expect(shouldRedirect("/.well-known/openid-configuration")).toBe(false); expect(shouldRedirect("/_app/immutable/start.js")).toBe(false); expect(shouldRedirect("/llms.txt")).toBe(false); diff --git a/tests/unit/welcome-steps.test.ts b/tests/unit/welcome-steps.test.ts new file mode 100644 index 000000000..6d67446d6 --- /dev/null +++ b/tests/unit/welcome-steps.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + buildWelcomeStepUrl, + nextWelcomeStep, + parseWelcomeStep, + previousWelcomeStep, + welcomeStepNumber, +} from "@/features/welcome/lib/welcome-steps"; + +describe("欢迎流程步骤", () => { + it("将未知步骤解析为必填的资料步骤", () => { + expect(parseWelcomeStep(null)).toBe("profile"); + expect(parseWelcomeStep("")).toBe("profile"); + expect(parseWelcomeStep("finish?")).toBe("profile"); + expect(parseWelcomeStep("subscriptions")).toBe("subscriptions"); + expect(parseWelcomeStep("finish")).toBe("finish"); + }); + + it("按顺序前进和后退", () => { + expect(welcomeStepNumber("profile")).toBe(1); + expect(welcomeStepNumber("finish")).toBe(3); + expect(nextWelcomeStep("profile")).toBe("subscriptions"); + expect(nextWelcomeStep("subscriptions")).toBe("finish"); + expect(nextWelcomeStep("finish")).toBeNull(); + expect(previousWelcomeStep("finish")).toBe("subscriptions"); + expect(previousWelcomeStep("profile")).toBeNull(); + }); + + it("构建步骤 URL 时编码回调地址", () => { + expect( + buildWelcomeStepUrl("subscriptions", "/account/settings?tab=a"), + ).toBe( + "/account/welcome?step=subscriptions&callbackUrl=%2Faccount%2Fsettings%3Ftab%3Da", + ); + }); +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 723d37186..4c12037cf 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-file=/dev/null --include-runtime=false` (hash: 3a7f42782d2c249ab96113b0f41e7b21) +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 2989d39f444b9fccae00dbb39e1d9800) interface __BaseEnv_Env { CALENDAR_EXPORTS: KVNamespace; CATALOG_DETAIL_CORE: KVNamespace; @@ -10,6 +10,7 @@ interface __BaseEnv_Env { CALENDAR_EXPORT_REBUILD: Queue; USER_WRITE_RATE_LIMITER: RateLimit; USER_BATCH_WRITE_RATE_LIMITER: RateLimit; + IMAGES: ImagesBinding; ASSETS: Fetcher; NODE_ENV: "production"; APP_PUBLIC_ORIGIN: "https://life-ustc.tiankaima.dev"; diff --git a/wrangler.dev.jsonc b/wrangler.dev.jsonc index 100a4ed9a..6f9567c1d 100644 --- a/wrangler.dev.jsonc +++ b/wrangler.dev.jsonc @@ -21,6 +21,9 @@ "directory": ".svelte-kit/cloudflare", "binding": "ASSETS" }, + "images": { + "binding": "IMAGES" + }, "observability": { "enabled": false }, diff --git a/wrangler.e2e.jsonc b/wrangler.e2e.jsonc index 744f33a29..8c78cf229 100644 --- a/wrangler.e2e.jsonc +++ b/wrangler.e2e.jsonc @@ -38,6 +38,9 @@ "!/openapi.generated.json" ] }, + "images": { + "binding": "IMAGES" + }, "observability": { "enabled": false }, diff --git a/wrangler.jsonc b/wrangler.jsonc index 1126bb056..9a5c0167a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -30,6 +30,9 @@ "!/openapi.generated.json" ] }, + "images": { + "binding": "IMAGES" + }, "observability": { "enabled": true, "logs": {