first commit

This commit is contained in:
luoyangwei
2026-06-06 00:49:07 +08:00
commit 2b860d201e
134 changed files with 22773 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
import {
createHash,
createHmac,
randomBytes,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import type { AppUser, PublicAppUser } from "@/types/app/user";
type JwtPayload = {
sub: string;
email: string | null;
iat: number;
exp: number;
typ: "app_user";
};
export type AuthenticatedAppUser = {
id: string;
email: string | null;
displayName: string | null;
avatarUrl: string | null;
authProvider: string;
};
export type AppUserLoginContext = {
ipAddress: string | null;
userAgent: string | null;
acceptLanguage: string | null;
referer: string | null;
origin: string | null;
deviceInfo: Record<string, string | null>;
};
type AppUserLoginLogStatus = "success" | "failed" | "blocked";
type SupabaseServiceRoleClient = ReturnType<
typeof createSupabaseServiceRoleClient
>;
const userSelect =
"id,email,display_name,avatar_url,auth_provider,status,created_at,updated_at";
function getJwtSecret() {
const secret = process.env.APP_JWT_SECRET ?? process.env.AUTH_SECRET;
if (!secret) {
throw new Error("APP_JWT_SECRET or AUTH_SECRET is not configured.");
}
return secret;
}
function base64UrlEncode(value: Buffer | string) {
return Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function base64UrlDecode(value: string) {
const padded = value.padEnd(
value.length + ((4 - (value.length % 4)) % 4),
"=",
);
return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}
function sign(input: string) {
return base64UrlEncode(
createHmac("sha256", getJwtSecret()).update(input).digest(),
);
}
function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
function firstHeaderValue(value: string | null) {
return value?.split(",")[0]?.trim() || null;
}
function getHeader(headers: Headers, name: string) {
return headers.get(name)?.trim() || null;
}
function getRequestIpAddress(headers: Headers) {
return (
getHeader(headers, "cf-connecting-ip") ??
getHeader(headers, "x-real-ip") ??
firstHeaderValue(getHeader(headers, "x-forwarded-for")) ??
getHeader(headers, "x-client-ip")
);
}
function getTokenHash(token: string) {
return createHash("sha256").update(token).digest("hex");
}
export function getAppUserLoginContextFromRequest(
request: Request,
): AppUserLoginContext {
const headers = request.headers;
const forwardedFor = getHeader(headers, "x-forwarded-for");
const userAgent = getHeader(headers, "user-agent");
const acceptLanguage = getHeader(headers, "accept-language");
const referer = getHeader(headers, "referer");
const origin = getHeader(headers, "origin");
return {
ipAddress: getRequestIpAddress(headers),
userAgent,
acceptLanguage,
referer,
origin,
deviceInfo: {
method: request.method,
url: request.url,
host: getHeader(headers, "host"),
userAgent,
acceptLanguage,
referer,
origin,
forwardedFor,
realIp: getHeader(headers, "x-real-ip"),
clientIp: getHeader(headers, "x-client-ip"),
cfConnectingIp: getHeader(headers, "cf-connecting-ip"),
cfIpcountry: getHeader(headers, "cf-ipcountry"),
},
};
}
function mapPublicUser(user: AppUser): PublicAppUser {
return {
id: user.id,
email: user.email,
displayName: user.display_name,
avatarUrl: user.avatar_url,
authProvider: user.auth_provider,
};
}
async function recordAppUserLoginLog({
context,
email,
failureReason,
jwtToken,
status,
supabase,
user,
}: {
context?: AppUserLoginContext;
email: string;
failureReason?: string | null;
jwtToken?: string | null;
status: AppUserLoginLogStatus;
supabase: SupabaseServiceRoleClient;
user?: Pick<AppUser, "auth_provider" | "id"> | null;
}) {
try {
const jwtExpiresAt = jwtToken
? new Date(verifyAppUserToken(jwtToken).exp * 1000).toISOString()
: null;
const { error } = await supabase.from("wg_app_user_login_logs").insert({
user_id: user?.id ?? null,
email,
auth_provider: user?.auth_provider ?? "email",
login_status: status,
failure_reason: failureReason ?? null,
ip_address: context?.ipAddress ?? null,
user_agent: context?.userAgent ?? null,
accept_language: context?.acceptLanguage ?? null,
referer: context?.referer ?? null,
origin: context?.origin ?? null,
device_info: context?.deviceInfo ?? {},
jwt_token: jwtToken ?? null,
jwt_token_hash: jwtToken ? getTokenHash(jwtToken) : null,
jwt_expires_at: jwtExpiresAt,
});
if (error) {
console.error("Failed to record app user login log:", error);
}
} catch (error) {
console.error("Failed to record app user login log:", error);
}
}
export function hashPassword(password: string) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `scrypt:v1:${salt}:${hash}`;
}
export function verifyPassword(password: string, storedHash: string | null) {
if (!storedHash) {
return false;
}
const [algorithm, version, salt, hash] = storedHash.split(":");
if (algorithm !== "scrypt" || version !== "v1" || !salt || !hash) {
return false;
}
const expected = Buffer.from(hash, "hex");
const actual = scryptSync(password, salt, expected.length);
return (
actual.length === expected.length && timingSafeEqual(actual, expected)
);
}
export function createAppUserToken(user: Pick<AppUser, "email" | "id">) {
const now = Math.floor(Date.now() / 1000);
const payload: JwtPayload = {
sub: user.id,
email: user.email,
iat: now,
exp: now + 60 * 60 * 24 * 30,
typ: "app_user",
};
const header = {
alg: "HS256",
typ: "JWT",
};
const input = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(
JSON.stringify(payload),
)}`;
return `${input}.${sign(input)}`;
}
export function verifyAppUserToken(token: string): JwtPayload {
const [header, payload, signature] = token.split(".");
if (!header || !payload || !signature) {
throw new Error("Invalid token.");
}
const input = `${header}.${payload}`;
const expected = sign(input);
const expectedBuffer = Buffer.from(expected);
const signatureBuffer = Buffer.from(signature);
if (
expectedBuffer.length !== signatureBuffer.length ||
!timingSafeEqual(expectedBuffer, signatureBuffer)
) {
throw new Error("Invalid token signature.");
}
const decoded = JSON.parse(
base64UrlDecode(payload).toString("utf8"),
) as JwtPayload;
if (decoded.typ !== "app_user" || !decoded.sub) {
throw new Error("Invalid token payload.");
}
if (decoded.exp < Math.floor(Date.now() / 1000)) {
throw new Error("Token expired.");
}
return decoded;
}
export async function registerAppUser({
displayName,
email,
password,
}: {
displayName?: string | null;
email: string;
password: string;
}) {
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail.includes("@")) {
throw new Error("A valid email is required.");
}
if (password.length < 8) {
throw new Error("Password must be at least 8 characters.");
}
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_app_users")
.insert({
email: normalizedEmail,
password_hash: hashPassword(password),
display_name: displayName?.trim() || null,
auth_provider: "email",
status: "active",
})
.select(userSelect)
.single<AppUser>();
if (error) {
if (error.code === "23505") {
throw new Error("Email is already registered.");
}
throw error;
}
await supabase.from("wg_user_settings").upsert(
{
user_id: data.id,
preferences: {},
download_preferences: {},
},
{ onConflict: "user_id" },
);
return {
user: mapPublicUser(data),
token: createAppUserToken(data),
};
}
export async function loginAppUser({
context,
email,
password,
}: {
context?: AppUserLoginContext;
email: string;
password: string;
}) {
const supabase = createSupabaseServiceRoleClient();
const normalizedEmail = normalizeEmail(email);
const { data, error } = await supabase
.from("wg_app_users")
.select(`${userSelect},password_hash`)
.eq("email", normalizedEmail)
.eq("auth_provider", "email")
.maybeSingle<AppUser & { password_hash: string | null }>();
if (error) {
throw error;
}
if (!data) {
await recordAppUserLoginLog({
context,
email: normalizedEmail,
failureReason: "user_not_found",
status: "failed",
supabase,
});
throw new Error("Invalid email or password.");
}
if (data.status !== "active") {
await recordAppUserLoginLog({
context,
email: normalizedEmail,
failureReason: `user_status:${data.status}`,
status: "blocked",
supabase,
user: data,
});
throw new Error("Invalid email or password.");
}
if (!verifyPassword(password, data.password_hash)) {
await recordAppUserLoginLog({
context,
email: normalizedEmail,
failureReason: "invalid_password",
status: "failed",
supabase,
user: data,
});
throw new Error("Invalid email or password.");
}
const token = createAppUserToken(data);
await supabase
.from("wg_app_users")
.update({ last_login_at: new Date().toISOString() })
.eq("id", data.id);
await recordAppUserLoginLog({
context,
email: normalizedEmail,
jwtToken: token,
status: "success",
supabase,
user: data,
});
return {
user: mapPublicUser(data),
token,
};
}
export async function getAppUserFromRequest(request: Request) {
const authorization = request.headers.get("authorization") ?? "";
const token = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) {
return null;
}
const payload = verifyAppUserToken(token);
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_app_users")
.select(userSelect)
.eq("id", payload.sub)
.maybeSingle<AppUser>();
if (error) {
throw error;
}
if (!data || data.status !== "active") {
return null;
}
return mapPublicUser(data) satisfies AuthenticatedAppUser;
}
export async function getOptionalAppUserFromRequest(request: Request) {
const authorization = request.headers.get("authorization") ?? "";
if (!authorization) {
return null;
}
return getAppUserFromRequest(request);
}
+179
View File
@@ -0,0 +1,179 @@
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
type FavoriteRow = {
photo_id: string;
created_at: string;
};
type FavoritePhotoRow = {
id: string;
unsplash_id: string;
slug: string | null;
description: string | null;
alt_description: string | null;
width: number | null;
height: number | null;
color: string | null;
color_family: string | null;
urls: Record<string, unknown>;
user_info: Record<string, unknown>;
picked_at: string | null;
oss_url: string | null;
};
const favoritePhotoSelect =
"id,unsplash_id,slug,description,alt_description,width,height,color,color_family,urls,user_info,picked_at,oss_url";
function getPhotoUrl(photo: FavoritePhotoRow) {
const urls = photo.urls as Record<string, string> | undefined;
return (
photo.oss_url ??
urls?.regular ??
urls?.small ??
urls?.raw ??
urls?.thumb ??
null
);
}
function mapPhoto(photo: FavoritePhotoRow, favoritedAt: string | null) {
return {
id: photo.id,
unsplashId: photo.unsplash_id,
slug: photo.slug,
description: photo.description,
altDescription: photo.alt_description,
width: photo.width,
height: photo.height,
color: photo.color,
colorFamily: photo.color_family,
url: getPhotoUrl(photo),
sourceUrl: getPhotoUrl({ ...photo, oss_url: null }),
user: photo.user_info,
pickedAt: photo.picked_at,
favoritedAt,
};
}
export async function listUserFavorites({
page,
pageSize,
userId,
}: {
page: number;
pageSize: number;
userId: string;
}) {
const supabase = createSupabaseServiceRoleClient();
const { data, error, count } = await supabase
.from("wg_user_favorites")
.select("photo_id,created_at", { count: "exact" })
.eq("user_id", userId)
.order("created_at", { ascending: false })
.range((page - 1) * pageSize, page * pageSize - 1);
if (error) {
throw error;
}
const favorites = (data ?? []) as FavoriteRow[];
const photoIds = favorites.map((favorite) => favorite.photo_id);
if (photoIds.length === 0) {
return {
data: [],
total: count ?? 0,
};
}
const { data: photos, error: photosError } = await supabase
.from("wg_gallery_photos")
.select(favoritePhotoSelect)
.eq("is_picked", true)
.in("id", photoIds);
if (photosError) {
throw photosError;
}
const favoritedAtByPhotoId = new Map(
favorites.map((favorite) => [favorite.photo_id, favorite.created_at]),
);
const photoById = new Map(
((photos ?? []) as FavoritePhotoRow[]).map((photo) => [
photo.id,
photo,
]),
);
return {
data: photoIds
.map((photoId) => photoById.get(photoId))
.filter((photo): photo is FavoritePhotoRow => Boolean(photo))
.map((photo) =>
mapPhoto(photo, favoritedAtByPhotoId.get(photo.id) ?? null),
),
total: count ?? 0,
};
}
export async function addUserFavorite({
photoId,
userId,
}: {
photoId: string;
userId: string;
}) {
const supabase = createSupabaseServiceRoleClient();
const { data: photo, error: photoError } = await supabase
.from("wg_gallery_photos")
.select(favoritePhotoSelect)
.eq("id", photoId)
.eq("is_picked", true)
.maybeSingle<FavoritePhotoRow>();
if (photoError) {
throw photoError;
}
if (!photo) {
throw new Error("Picked photo was not found.");
}
const { data, error } = await supabase
.from("wg_user_favorites")
.upsert(
{
user_id: userId,
photo_id: photoId,
},
{ onConflict: "user_id,photo_id" },
)
.select("created_at")
.single<{ created_at: string }>();
if (error) {
throw error;
}
return mapPhoto(photo, data.created_at);
}
export async function removeUserFavorite({
photoId,
userId,
}: {
photoId: string;
userId: string;
}) {
const supabase = createSupabaseServiceRoleClient();
const { error } = await supabase
.from("wg_user_favorites")
.delete()
.eq("user_id", userId)
.eq("photo_id", photoId);
if (error) {
throw error;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import type { AppUserSettings } from "@/types/app/user";
export async function getUserSettings(userId: string) {
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_user_settings")
.select("user_id,preferences,download_preferences,updated_at")
.eq("user_id", userId)
.maybeSingle<AppUserSettings>();
if (error) {
throw error;
}
if (data) {
return data;
}
const { data: created, error: createError } = await supabase
.from("wg_user_settings")
.insert({
user_id: userId,
preferences: {},
download_preferences: {},
})
.select("user_id,preferences,download_preferences,updated_at")
.single<AppUserSettings>();
if (createError) {
throw createError;
}
return created;
}
export async function updateUserSettings({
downloadPreferences,
preferences,
userId,
}: {
downloadPreferences?: Record<string, unknown>;
preferences?: Record<string, unknown>;
userId: string;
}) {
const current = await getUserSettings(userId);
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_user_settings")
.update({
preferences: preferences ?? current.preferences,
download_preferences:
downloadPreferences ?? current.download_preferences,
})
.eq("user_id", userId)
.select("user_id,preferences,download_preferences,updated_at")
.single<AppUserSettings>();
if (error) {
throw error;
}
return data;
}