Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/contracts/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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)"
]
}
},
Expand Down
30 changes: 28 additions & 2 deletions messages/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
30 changes: 28 additions & 2 deletions messages/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -1303,6 +1303,8 @@
"editProfile": "编辑资料",
"editProfileDescription": "在此处更改您的个人资料。完成后点击保存。",
"profilePicture": "头像",
"avatarUpload": "上传自己的头像",
"avatarUploadHint": "支持 JPG、PNG、WebP 或 AVIF,最大 5 MB。图片会在 Cloudflare 中心裁切为 256×256 并转换为 WebP。",
"name": "姓名",
"namePlaceholder": "您的姓名",
"nameRequired": "请输入姓名。",
Expand Down Expand Up @@ -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": "确认添加教学班订阅",
Expand Down
Original file line number Diff line number Diff line change
@@ -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$;
2 changes: 1 addition & 1 deletion prisma/roles/app-runtime-table-grants.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
129 changes: 129 additions & 0 deletions src/features/profile/server/profile-avatar-service.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions src/features/profile/server/profile-update-service.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -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;
Expand Down
60 changes: 60 additions & 0 deletions src/features/welcome/components/WelcomeGuideCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<script lang="ts">
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import CalendarCheck from "@lucide/svelte/icons/calendar-check";
import ShieldCheck from "@lucide/svelte/icons/shield-check";
import Sparkles from "@lucide/svelte/icons/sparkles";
import { Button } from "$lib/components/ui/button/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import type { WelcomeCopy } from "./welcome-component-types";

export let backUrl: string | null;
export let finishUrl: string;
export let welcomeCopy: WelcomeCopy;

$: guides = [
{
icon: Sparkles,
title: welcomeCopy.guideWorkspaceTitle,
description: welcomeCopy.guideWorkspaceDescription,
},
{
icon: CalendarCheck,
title: welcomeCopy.guidePlanningTitle,
description: welcomeCopy.guidePlanningDescription,
},
{
icon: ShieldCheck,
title: welcomeCopy.guideAccountTitle,
description: welcomeCopy.guideAccountDescription,
},
];
</script>

<Card.Root>
<Card.Header>
<Card.Title>{welcomeCopy.finishTitle}</Card.Title>
<Card.Description>{welcomeCopy.finishDescription}</Card.Description>
</Card.Header>
<Card.Content class="grid gap-4">
{#each guides as guide (guide.title)}
<div class="flex items-start gap-3">
<span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-primary">
<guide.icon class="size-4" />
</span>
<div class="grid gap-0.5">
<p class="font-medium text-sm">{guide.title}</p>
<p class="text-muted-foreground text-xs leading-5">{guide.description}</p>
</div>
</div>
{/each}
</Card.Content>
<Card.Footer class="flex flex-wrap justify-between gap-2">
{#if backUrl}
<Button href={backUrl} variant="ghost">
<ArrowLeft data-icon="inline-start" />
{welcomeCopy.back}
</Button>
{/if}
<Button class="ms-auto" href={finishUrl}>{welcomeCopy.startUsing}</Button>
</Card.Footer>
</Card.Root>
Loading
Loading