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
+1
View File
@@ -0,0 +1 @@
+91
View File
@@ -0,0 +1,91 @@
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import type { AppUser, AppUserStatus } from "@/types/app/user";
export type AdminAppUser = AppUser & {
favorite_count: number;
};
const adminUserSelect =
"id,email,display_name,avatar_url,auth_provider,status,phone,wechat_openid,wechat_unionid,last_login_at,disabled_at,created_at,updated_at";
export async function listAdminAppUsers() {
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_app_users")
.select(adminUserSelect)
.order("created_at", { ascending: false })
.limit(100);
if (error) {
throw error;
}
const users = (data ?? []) as AppUser[];
if (users.length === 0) {
return [];
}
const { data: favorites, error: favoritesError } = await supabase
.from("wg_user_favorites")
.select("user_id")
.in(
"user_id",
users.map((user) => user.id),
);
if (favoritesError) {
throw favoritesError;
}
const favoriteCountByUserId = (favorites ?? []).reduce(
(counts, favorite) => {
counts[favorite.user_id] = (counts[favorite.user_id] ?? 0) + 1;
return counts;
},
{} as Record<string, number>,
);
return users.map((user) => ({
...user,
favorite_count: favoriteCountByUserId[user.id] ?? 0,
}));
}
export async function updateAdminAppUserStatus({
status,
userId,
}: {
status: AppUserStatus;
userId: string;
}) {
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_app_users")
.update({
status,
disabled_at:
status === "disabled" ? new Date().toISOString() : null,
})
.eq("id", userId)
.select(adminUserSelect)
.single<AppUser>();
if (error) {
throw error;
}
const { data: favorites, error: favoritesError } = await supabase
.from("wg_user_favorites")
.select("user_id")
.eq("user_id", userId);
if (favoritesError) {
throw favoritesError;
}
return {
...data,
favorite_count: favorites?.length ?? 0,
};
}
+97
View File
@@ -0,0 +1,97 @@
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import { uploadRemoteImageToAliyunOss } from "@/server/storage/aliyun-oss";
type GalleryPhotoPickRow = {
id: string;
unsplash_id: string;
urls: Record<string, unknown>;
};
function getMirrorSourceUrl(urls: Record<string, unknown>) {
const candidates = [
urls.full,
urls.regular,
urls.raw,
urls.small,
urls.thumb,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.length > 0) {
return candidate;
}
}
return null;
}
export async function updateGalleryPhotoPickStatus({
isPicked,
pickedBy,
unsplashId,
}: {
isPicked: boolean;
pickedBy: string;
unsplashId: string;
}) {
const supabase = createSupabaseServiceRoleClient();
const { data: photo, error: readError } = await supabase
.from("wg_gallery_photos")
.select("id,unsplash_id,urls")
.eq("unsplash_id", unsplashId)
.maybeSingle<GalleryPhotoPickRow>();
if (readError) {
throw readError;
}
if (!photo) {
throw new Error("Gallery photo was not found.");
}
const now = new Date().toISOString();
const update: Record<string, unknown> = {
is_picked: isPicked,
picked_at: isPicked ? now : null,
picked_by: isPicked ? pickedBy : null,
oss_sync_error: null,
};
if (isPicked) {
const imageUrl = getMirrorSourceUrl(photo.urls);
if (!imageUrl) {
update.oss_sync_error = "No usable source image URL was found.";
} else {
const upload = await uploadRemoteImageToAliyunOss({
imageUrl,
objectKeyPrefix: `gallery/unsplash/${photo.unsplash_id}`,
});
if (upload.ok) {
update.oss_bucket = upload.bucket;
update.oss_object_key = upload.objectKey;
update.oss_url = upload.url;
update.oss_synced_at = now;
} else {
update.oss_sync_error = upload.error;
}
}
}
const { data, error } = await supabase
.from("wg_gallery_photos")
.update(update)
.eq("unsplash_id", unsplashId)
.select(
"id,unsplash_id,slug,description,alt_description,width,height,color,color_family,likes,urls,user_info,photo_created_at,is_picked,picked_at,picked_by,oss_url,oss_object_key,oss_synced_at,oss_sync_error",
)
.single();
if (error) {
console.error("[gallery-pick] Supabase update failed", error);
throw error;
}
return data;
}
+182
View File
@@ -0,0 +1,182 @@
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import {
fetchUnsplashWallpaperPhotos,
type UnsplashPhoto,
} from "@/server/api/unsplash";
import { getGalleryColorFamily } from "@/server/gallery/color";
const SOURCE = "unsplash-wallpapers";
const DEFAULT_PAGE = 1;
const DEFAULT_PER_PAGE = 20;
type GallerySyncState = {
source: string;
next_page: number;
per_page: number;
};
type GalleryPhotoRow = {
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;
blur_hash: string | null;
likes: number | null;
photo_created_at: string | null;
photo_updated_at: string | null;
urls: Record<string, unknown>;
links: Record<string, unknown>;
user_info: Record<string, unknown>;
raw: Record<string, unknown>;
source: string;
};
function normalizeDate(value: string | null | undefined) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function mapPhotoToRow(photo: UnsplashPhoto): GalleryPhotoRow {
const raw = photo as Record<string, unknown>;
return {
unsplash_id: photo.id,
slug: photo.slug ?? null,
description: photo.description ?? null,
alt_description: photo.alt_description ?? null,
width: photo.width ?? null,
height: photo.height ?? null,
color: photo.color ?? null,
color_family: getGalleryColorFamily(photo.color),
blur_hash: photo.blur_hash ?? null,
likes: photo.likes ?? null,
photo_created_at: normalizeDate(photo.created_at),
photo_updated_at: normalizeDate(photo.updated_at),
urls: photo.urls ?? {},
links: photo.links ?? {},
user_info: photo.user ?? {},
raw,
source: SOURCE,
};
}
async function getSyncState() {
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_gallery_sync_state")
.select("source,next_page,per_page")
.eq("source", SOURCE)
.maybeSingle<GallerySyncState>();
if (error) {
throw error;
}
if (data) {
return {
supabase,
state: data,
};
}
const initialState = {
source: SOURCE,
next_page: DEFAULT_PAGE,
per_page: DEFAULT_PER_PAGE,
};
const { error: insertError } = await supabase
.from("wg_gallery_sync_state")
.insert(initialState);
if (insertError) {
throw insertError;
}
return {
supabase,
state: initialState,
};
}
export async function syncUnsplashWallpapersToGallery() {
const { supabase, state } = await getSyncState();
const page = state.next_page || DEFAULT_PAGE;
const perPage = state.per_page || DEFAULT_PER_PAGE;
try {
const photos = await fetchUnsplashWallpaperPhotos({ page, perPage });
const rows = photos.map(mapPhotoToRow);
let insertedCount = 0;
if (rows.length > 0) {
const { data, error } = await supabase
.from("wg_gallery_photos")
.upsert(rows, {
onConflict: "unsplash_id",
ignoreDuplicates: true,
})
.select("unsplash_id");
if (error) {
throw error;
}
insertedCount = data?.length ?? 0;
}
const nextPage = rows.length === 0 ? DEFAULT_PAGE : page + 1;
const { error: updateError } = await supabase
.from("wg_gallery_sync_state")
.upsert(
{
source: SOURCE,
next_page: nextPage,
per_page: perPage,
last_synced_at: new Date().toISOString(),
last_inserted_count: insertedCount,
last_seen_count: photos.length,
last_error: null,
},
{ onConflict: "source" },
);
if (updateError) {
throw updateError;
}
return {
source: SOURCE,
page,
nextPage,
seenCount: photos.length,
insertedCount,
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown sync error.";
await supabase.from("wg_gallery_sync_state").upsert(
{
source: SOURCE,
next_page: page,
per_page: perPage,
last_synced_at: new Date().toISOString(),
last_inserted_count: 0,
last_seen_count: 0,
last_error: message,
},
{ onConflict: "source" },
);
throw error;
}
}
+321
View File
@@ -0,0 +1,321 @@
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import type {
CollectionPhoto,
PhotoCollection,
PhotoCollectionInput,
} from "@/types/admin/collection";
type CollectionRow = {
id: string;
title: string;
slug: string;
description: string | null;
cover_photo_id: string | null;
is_published: boolean;
published_at: string | null;
created_at: string;
updated_at: string;
};
type CollectionItemRow = {
collection_id: string;
photo_id: string;
sort_order: number;
};
const collectionSelect =
"id,title,slug,description,cover_photo_id,is_published,published_at,created_at,updated_at";
const photoSelect =
"id,unsplash_id,slug,description,alt_description,width,height,color,color_family,likes,urls,user_info,photo_created_at,picked_at,oss_url";
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/['"]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function normalizeInput(input: PhotoCollectionInput) {
const title = input.title.trim();
const slug = slugify(input.slug?.trim() || title);
const photoIds = Array.from(new Set(input.photoIds.filter(Boolean)));
const coverPhotoId =
input.coverPhotoId && photoIds.includes(input.coverPhotoId)
? input.coverPhotoId
: (photoIds[0] ?? null);
if (!title) {
throw new Error("Collection title is required.");
}
if (!slug) {
throw new Error("Collection slug is required.");
}
return {
title,
slug,
description: input.description?.trim() || null,
coverPhotoId,
isPublished: input.isPublished,
photoIds,
};
}
async function assertPickedPhotos(photoIds: string[]) {
if (photoIds.length === 0) {
return;
}
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_gallery_photos")
.select("id")
.eq("is_picked", true)
.in("id", photoIds);
if (error) {
throw error;
}
if ((data?.length ?? 0) !== photoIds.length) {
throw new Error("Collections can only include picked photos.");
}
}
async function getPhotosById(photoIds: string[]) {
if (photoIds.length === 0) {
return new Map<string, CollectionPhoto>();
}
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_gallery_photos")
.select(photoSelect)
.in("id", photoIds);
if (error) {
throw error;
}
return new Map(
((data ?? []) as CollectionPhoto[]).map((photo) => [photo.id, photo]),
);
}
async function attachPhotos(
collections: CollectionRow[],
): Promise<PhotoCollection[]> {
if (collections.length === 0) {
return [];
}
const supabase = createSupabaseServiceRoleClient();
const collectionIds = collections.map((collection) => collection.id);
const { data: items, error } = await supabase
.from("wg_photo_collection_items")
.select("collection_id,photo_id,sort_order")
.in("collection_id", collectionIds)
.order("sort_order", { ascending: true })
.order("created_at", { ascending: true });
if (error) {
throw error;
}
const itemRows = (items ?? []) as CollectionItemRow[];
const photoIds = Array.from(
new Set([
...itemRows.map((item) => item.photo_id),
...collections
.map((collection) => collection.cover_photo_id)
.filter((id): id is string => Boolean(id)),
]),
);
const photosById = await getPhotosById(photoIds);
const itemsByCollection = new Map<string, CollectionItemRow[]>();
for (const item of itemRows) {
const current = itemsByCollection.get(item.collection_id) ?? [];
current.push(item);
itemsByCollection.set(item.collection_id, current);
}
return collections.map((collection) => ({
...collection,
cover_photo: collection.cover_photo_id
? (photosById.get(collection.cover_photo_id) ?? null)
: null,
photos: (itemsByCollection.get(collection.id) ?? [])
.map((item) => photosById.get(item.photo_id))
.filter((photo): photo is CollectionPhoto => Boolean(photo)),
}));
}
export async function listAdminPhotoCollections() {
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_photo_collections")
.select(collectionSelect)
.order("created_at", { ascending: false })
.limit(100);
if (error) {
throw error;
}
return attachPhotos((data ?? []) as CollectionRow[]);
}
export async function getAdminPhotoCollection(id: string) {
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_photo_collections")
.select(collectionSelect)
.eq("id", id)
.maybeSingle<CollectionRow>();
if (error) {
throw error;
}
if (!data) {
return null;
}
const [collection] = await attachPhotos([data]);
return collection ?? null;
}
export async function listPickedPhotosForCollections() {
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_gallery_photos")
.select(photoSelect)
.eq("is_picked", true)
.order("picked_at", { ascending: false, nullsFirst: false })
.order("photo_created_at", { ascending: false })
.limit(120);
if (error) {
throw error;
}
return (data ?? []) as CollectionPhoto[];
}
async function replaceCollectionItems(
collectionId: string,
photoIds: string[],
) {
const supabase = createSupabaseServiceRoleClient();
const { error: deleteError } = await supabase
.from("wg_photo_collection_items")
.delete()
.eq("collection_id", collectionId);
if (deleteError) {
throw deleteError;
}
if (photoIds.length === 0) {
return;
}
const { error: insertError } = await supabase
.from("wg_photo_collection_items")
.insert(
photoIds.map((photoId, index) => ({
collection_id: collectionId,
photo_id: photoId,
sort_order: index,
})),
);
if (insertError) {
throw insertError;
}
}
export async function createPhotoCollection(input: PhotoCollectionInput) {
const normalized = normalizeInput(input);
await assertPickedPhotos(normalized.photoIds);
const supabase = createSupabaseServiceRoleClient();
const now = new Date().toISOString();
const { data, error } = await supabase
.from("wg_photo_collections")
.insert({
title: normalized.title,
slug: normalized.slug,
description: normalized.description,
cover_photo_id: normalized.coverPhotoId,
is_published: normalized.isPublished,
published_at: normalized.isPublished ? now : null,
})
.select(collectionSelect)
.single<CollectionRow>();
if (error) {
throw error;
}
await replaceCollectionItems(data.id, normalized.photoIds);
return getAdminPhotoCollection(data.id);
}
export async function updatePhotoCollection(
id: string,
input: PhotoCollectionInput,
) {
const normalized = normalizeInput(input);
await assertPickedPhotos(normalized.photoIds);
const current = await getAdminPhotoCollection(id);
if (!current) {
throw new Error("Collection was not found.");
}
const supabase = createSupabaseServiceRoleClient();
const now = new Date().toISOString();
const { error } = await supabase
.from("wg_photo_collections")
.update({
title: normalized.title,
slug: normalized.slug,
description: normalized.description,
cover_photo_id: normalized.coverPhotoId,
is_published: normalized.isPublished,
published_at:
normalized.isPublished && !current.published_at
? now
: normalized.isPublished
? current.published_at
: null,
})
.eq("id", id);
if (error) {
throw error;
}
await replaceCollectionItems(id, normalized.photoIds);
return getAdminPhotoCollection(id);
}
export async function deletePhotoCollection(id: string) {
const supabase = createSupabaseServiceRoleClient();
const { error } = await supabase
.from("wg_photo_collections")
.delete()
.eq("id", id);
if (error) {
throw error;
}
}
+1
View File
@@ -0,0 +1 @@
+72
View File
@@ -0,0 +1,72 @@
import { z } from "zod";
const UNSPLASH_WALLPAPERS_URL =
"https://api.unsplash.com/topics/wallpapers/photos";
const unsplashPhotoSchema = z
.object({
id: z.string(),
slug: z.string().nullish(),
description: z.string().nullish(),
alt_description: z.string().nullish(),
width: z.number().int().nullish(),
height: z.number().int().nullish(),
color: z.string().nullish(),
blur_hash: z.string().nullish(),
likes: z.number().int().nullish(),
created_at: z.string().nullish(),
updated_at: z.string().nullish(),
urls: z.record(z.string(), z.unknown()).nullish(),
links: z.record(z.string(), z.unknown()).nullish(),
user: z.record(z.string(), z.unknown()).nullish(),
})
.passthrough();
export type UnsplashPhoto = z.infer<typeof unsplashPhotoSchema>;
export async function fetchUnsplashWallpaperPhotos({
page,
perPage,
}: {
page: number;
perPage: number;
}) {
const accessKey = process.env.UNSPLASH_ACCESS_KEY;
if (!accessKey) {
throw new Error("UNSPLASH_ACCESS_KEY is not configured.");
}
const url = new URL(UNSPLASH_WALLPAPERS_URL);
url.searchParams.set("page", String(page));
url.searchParams.set("per_page", String(perPage));
const response = await fetch(url, {
headers: {
accept: "application/json",
authorization: `Client-ID ${accessKey}`,
"user-agent": "WalloraGallerySync/1.0",
},
cache: "no-store",
});
const contentType = response.headers.get("content-type") ?? "";
if (!response.ok) {
throw new Error(`Unsplash API request failed with ${response.status}.`);
}
if (!contentType.includes("application/json")) {
throw new Error(
`Unsplash returned non-JSON content: ${contentType || "unknown"}.`,
);
}
const payload = await response.json();
const parsed = z.array(unsplashPhotoSchema).safeParse(payload);
if (!parsed.success) {
throw new Error("Unsplash response did not match the expected array.");
}
return parsed.data;
}
+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;
}
+93
View File
@@ -0,0 +1,93 @@
export type GalleryColorFamily =
| "black"
| "blue"
| "green"
| "light"
| "orange"
| "purple"
| "red"
| "yellow";
function hexToRgb(hex: string) {
const normalized = hex.trim().replace(/^#/, "");
if (!/^[0-9a-f]{6}$/i.test(normalized)) {
return null;
}
return {
r: parseInt(normalized.slice(0, 2), 16),
g: parseInt(normalized.slice(2, 4), 16),
b: parseInt(normalized.slice(4, 6), 16),
};
}
export function getGalleryColorFamily(
color: string | null | undefined,
): GalleryColorFamily | null {
if (!color) {
return null;
}
const rgb = hexToRgb(color);
if (!rgb) {
return null;
}
const { r, g, b } = rgb;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (max + min) / 510;
if (lightness > 0.78) {
return "light";
}
if (lightness < 0.2) {
return "black";
}
const delta = max - min;
if (delta < 24) {
return lightness > 0.52 ? "light" : "black";
}
let hue = 0;
if (max === r) {
hue = ((g - b) / delta) % 6;
} else if (max === g) {
hue = (b - r) / delta + 2;
} else {
hue = (r - g) / delta + 4;
}
hue = Math.round(hue * 60);
if (hue < 0) {
hue += 360;
}
if (hue < 18 || hue >= 345) {
return "red";
}
if (hue < 45) {
return "orange";
}
if (hue < 70) {
return "yellow";
}
if (hue < 170) {
return "green";
}
if (hue < 255) {
return "blue";
}
if (hue < 345) {
return "purple";
}
return null;
}
+1
View File
@@ -0,0 +1 @@
+167
View File
@@ -0,0 +1,167 @@
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
export type PublicGalleryPhotoRow = {
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;
likes: number | null;
urls: Record<string, unknown>;
user_info: Record<string, unknown>;
photo_created_at: string | null;
picked_at: string | null;
oss_url: string | null;
};
export const publicGalleryPhotoSelect =
"id,unsplash_id,slug,description,alt_description,width,height,color,color_family,likes,urls,user_info,photo_created_at,picked_at,oss_url";
export function getPublicPhotoUrl(photo: PublicGalleryPhotoRow) {
const candidates = [
photo.oss_url,
photo.urls?.regular,
photo.urls?.small,
photo.urls?.raw,
photo.urls?.thumb,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.length > 0) {
return candidate;
}
}
return null;
}
export async function getFavoritePhotoIds({
photoIds,
userId,
}: {
photoIds: string[];
userId: string | null;
}) {
if (!userId || photoIds.length === 0) {
return new Set<string>();
}
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_user_favorites")
.select("photo_id")
.eq("user_id", userId)
.in("photo_id", photoIds);
if (error) {
throw error;
}
return new Set((data ?? []).map((favorite) => favorite.photo_id));
}
export function mapPublicGalleryPhoto({
favoritePhotoIds,
photo,
}: {
favoritePhotoIds?: Set<string>;
photo: PublicGalleryPhotoRow;
}) {
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,
likes: photo.likes,
url: getPublicPhotoUrl(photo),
sourceUrl: getPublicPhotoUrl({ ...photo, oss_url: null }),
user: photo.user_info,
photoCreatedAt: photo.photo_created_at,
pickedAt: photo.picked_at,
isFavorited: favoritePhotoIds?.has(photo.id) ?? false,
};
}
export async function listPickedGalleryPhotos({
colorFamily,
page,
pageSize,
userId,
}: {
colorFamily: string | null;
page: number;
pageSize: number;
userId: string | null;
}) {
const supabase = createSupabaseServiceRoleClient();
let query = supabase
.from("wg_gallery_photos")
.select(publicGalleryPhotoSelect, { count: "exact" })
.eq("is_picked", true);
if (colorFamily && colorFamily !== "all") {
query = query.eq("color_family", colorFamily);
}
const { data, error, count } = await query
.order("picked_at", { ascending: false, nullsFirst: false })
.order("photo_created_at", { ascending: false })
.range((page - 1) * pageSize, page * pageSize - 1);
if (error) {
throw error;
}
const photos = (data ?? []) as PublicGalleryPhotoRow[];
const favoritePhotoIds = await getFavoritePhotoIds({
userId,
photoIds: photos.map((photo) => photo.id),
});
return {
data: photos.map((photo) =>
mapPublicGalleryPhoto({ photo, favoritePhotoIds }),
),
total: count ?? 0,
};
}
export async function getPickedGalleryPhotoById({
id,
userId,
}: {
id: string;
userId: string | null;
}) {
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_gallery_photos")
.select(publicGalleryPhotoSelect)
.eq("id", id)
.eq("is_picked", true)
.maybeSingle<PublicGalleryPhotoRow>();
if (error) {
throw error;
}
if (!data) {
return null;
}
const favoritePhotoIds = await getFavoritePhotoIds({
userId,
photoIds: [data.id],
});
return mapPublicGalleryPhoto({ photo: data, favoritePhotoIds });
}
+225
View File
@@ -0,0 +1,225 @@
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
import {
getFavoritePhotoIds,
mapPublicGalleryPhoto,
} from "@/server/site/gallery-photos";
import type { CollectionPhoto } from "@/types/admin/collection";
type PublicCollectionRow = {
id: string;
title: string;
slug: string;
description: string | null;
cover_photo_id: string | null;
published_at: string | null;
created_at: string;
};
type CollectionItemRow = {
photo_id: string;
sort_order: number;
};
const collectionSelect =
"id,title,slug,description,cover_photo_id,published_at,created_at";
const photoSelect =
"id,unsplash_id,slug,description,alt_description,width,height,color,color_family,likes,urls,user_info,photo_created_at,picked_at,oss_url";
function getPhotoUrl(photo: CollectionPhoto | null) {
if (!photo) {
return null;
}
const urls = photo.urls as Record<string, string> | undefined;
return (
photo.oss_url ??
urls?.regular ??
urls?.small ??
urls?.raw ??
urls?.thumb ??
null
);
}
async function getPhotosById(photoIds: string[]) {
if (photoIds.length === 0) {
return new Map<string, CollectionPhoto>();
}
const supabase = createSupabaseServiceRoleClient();
const { data, error } = await supabase
.from("wg_gallery_photos")
.select(photoSelect)
.eq("is_picked", true)
.in("id", photoIds);
if (error) {
throw error;
}
return new Map(
((data ?? []) as CollectionPhoto[]).map((photo) => [photo.id, photo]),
);
}
export async function listPublishedCollections({
page,
pageSize,
userId,
}: {
page: number;
pageSize: number;
userId: string | null;
}) {
const supabase = createSupabaseServiceRoleClient();
const { data, error, count } = await supabase
.from("wg_photo_collections")
.select(collectionSelect, { count: "exact" })
.eq("is_published", true)
.order("published_at", { ascending: false, nullsFirst: false })
.order("created_at", { ascending: false })
.range((page - 1) * pageSize, page * pageSize - 1);
if (error) {
throw error;
}
const collections = (data ?? []) as PublicCollectionRow[];
const collectionIds = collections.map((collection) => collection.id);
const coverPhotoIds = collections
.map((collection) => collection.cover_photo_id)
.filter((id): id is string => Boolean(id));
const { data: items, error: itemsError } =
collectionIds.length > 0
? await supabase
.from("wg_photo_collection_items")
.select("collection_id,photo_id")
.in("collection_id", collectionIds)
: { data: [], error: null };
if (itemsError) {
throw itemsError;
}
const photosById = await getPhotosById(coverPhotoIds);
const favoritePhotoIds = await getFavoritePhotoIds({
userId,
photoIds: coverPhotoIds,
});
const itemRows = (items ?? []) as Array<{
collection_id: string;
photo_id: string;
}>;
const countByCollection = itemRows.reduce(
(counts, item) => {
counts[item.collection_id] = (counts[item.collection_id] ?? 0) + 1;
return counts;
},
{} as Record<string, number>,
);
return {
data: collections.map((collection) => {
const coverPhoto = collection.cover_photo_id
? (photosById.get(collection.cover_photo_id) ?? null)
: null;
return {
id: collection.id,
title: collection.title,
slug: collection.slug,
description: collection.description,
coverPhoto: coverPhoto
? mapPublicGalleryPhoto({
photo: coverPhoto,
favoritePhotoIds,
})
: null,
coverUrl: getPhotoUrl(coverPhoto),
photoCount: countByCollection[collection.id] ?? 0,
publishedAt: collection.published_at,
};
}),
total: count ?? 0,
};
}
export async function getPublishedCollectionBySlug({
page,
pageSize,
slug,
userId,
}: {
page: number;
pageSize: number;
slug: string;
userId: string | null;
}) {
const supabase = createSupabaseServiceRoleClient();
const { data: collection, error } = await supabase
.from("wg_photo_collections")
.select(collectionSelect)
.eq("slug", slug)
.eq("is_published", true)
.maybeSingle<PublicCollectionRow>();
if (error) {
throw error;
}
if (!collection) {
return null;
}
const {
data: items,
error: itemsError,
count,
} = await supabase
.from("wg_photo_collection_items")
.select("photo_id,sort_order", { count: "exact" })
.eq("collection_id", collection.id)
.order("sort_order", { ascending: true })
.order("created_at", { ascending: true })
.range((page - 1) * pageSize, page * pageSize - 1);
if (itemsError) {
throw itemsError;
}
const itemRows = (items ?? []) as CollectionItemRow[];
const photoIds = [
...itemRows.map((item) => item.photo_id),
...(collection.cover_photo_id ? [collection.cover_photo_id] : []),
];
const photosById = await getPhotosById(Array.from(new Set(photoIds)));
const favoritePhotoIds = await getFavoritePhotoIds({
userId,
photoIds,
});
const coverPhoto = collection.cover_photo_id
? (photosById.get(collection.cover_photo_id) ?? null)
: null;
return {
collection: {
id: collection.id,
title: collection.title,
slug: collection.slug,
description: collection.description,
coverPhoto: coverPhoto
? mapPublicGalleryPhoto({ photo: coverPhoto, favoritePhotoIds })
: null,
coverUrl: getPhotoUrl(coverPhoto),
photoCount: count ?? 0,
publishedAt: collection.published_at,
},
photos: itemRows
.map((item) => photosById.get(item.photo_id))
.filter((photo): photo is CollectionPhoto => Boolean(photo))
.map((photo) => mapPublicGalleryPhoto({ photo, favoritePhotoIds })),
total: count ?? 0,
};
}
+219
View File
@@ -0,0 +1,219 @@
import { createHmac } from "node:crypto";
import { extname } from "node:path";
type OssConfig = {
accessKeyId: string;
accessKeySecret: string;
bucket: string;
endpoint: string;
publicBaseUrl: string | null;
};
export type OssUploadResult =
| {
ok: true;
bucket: string;
objectKey: string;
url: string;
}
| {
ok: false;
error: string;
};
function getOssConfig(): OssConfig | null {
const accessKeyId = process.env.OSS_ACCESS_KEY_ID;
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET;
const bucket = process.env.OSS_BUCKET;
const endpoint = getOssEndpoint();
if (!accessKeyId || !accessKeySecret || !bucket || !endpoint) {
return null;
}
return {
accessKeyId,
accessKeySecret,
bucket,
endpoint,
publicBaseUrl:
process.env.OSS_PUBLIC_BASE_URL ??
process.env.OSS_PUBLIC_URL ??
null,
};
}
function getOssEndpoint() {
const endpoint = process.env.OSS_ENDPOINT?.trim();
if (endpoint) {
return endpoint.startsWith("http") ? endpoint : `https://${endpoint}`;
}
const region = process.env.OSS_REGION?.trim();
if (!region) {
return null;
}
if (region.startsWith("http")) {
return region;
}
return region.startsWith("oss-")
? `https://${region}.aliyuncs.com`
: `https://oss-${region}.aliyuncs.com`;
}
function contentTypeToExtension(contentType: string) {
if (contentType.includes("png")) {
return "png";
}
if (contentType.includes("webp")) {
return "webp";
}
if (contentType.includes("gif")) {
return "gif";
}
if (contentType.includes("avif")) {
return "avif";
}
return "jpg";
}
function extensionFromUrl(url: string, contentType: string) {
const pathname = new URL(url).pathname;
const extension = extname(pathname).replace(".", "").toLowerCase();
if (extension && /^[a-z0-9]{2,5}$/.test(extension)) {
return extension === "jpeg" ? "jpg" : extension;
}
return contentTypeToExtension(contentType);
}
function normalizeBaseUrl(value: string) {
return value.replace(/\/+$/, "");
}
function getBucketHost(config: OssConfig) {
const endpointUrl = new URL(config.endpoint);
return `${config.bucket}.${endpointUrl.host}`;
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "Unknown OSS error.";
}
function signPutObject({
config,
contentType,
date,
objectKey,
}: {
config: OssConfig;
contentType: string;
date: string;
objectKey: string;
}) {
const canonicalizedResource = `/${config.bucket}/${objectKey}`;
const stringToSign = [
"PUT",
"",
contentType,
date,
canonicalizedResource,
].join("\n");
const signature = createHmac("sha1", config.accessKeySecret)
.update(stringToSign)
.digest("base64");
return `OSS ${config.accessKeyId}:${signature}`;
}
export async function uploadRemoteImageToAliyunOss({
imageUrl,
objectKeyPrefix,
}: {
imageUrl: string;
objectKeyPrefix: string;
}): Promise<OssUploadResult> {
try {
const config = getOssConfig();
if (!config) {
return {
ok: false,
error: "Aliyun OSS is not configured.",
};
}
const imageResponse = await fetch(imageUrl, {
headers: {
accept: "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"user-agent": "WalloraGalleryMirror/1.0",
},
cache: "no-store",
}).catch((error: unknown) => {
throw new Error(`Image download failed: ${getErrorMessage(error)}`);
});
if (!imageResponse.ok) {
return {
ok: false,
error: `Image download failed with ${imageResponse.status}.`,
};
}
const contentType =
imageResponse.headers.get("content-type") ?? "image/jpeg";
const extension = extensionFromUrl(imageUrl, contentType);
const objectKey = `${objectKeyPrefix}.${extension}`;
const body = Buffer.from(await imageResponse.arrayBuffer());
const date = new Date().toUTCString();
const bucketHost = getBucketHost(config);
const protocol = new URL(config.endpoint).protocol;
const putUrl = `${protocol}//${bucketHost}/${objectKey}`;
const uploadResponse = await fetch(putUrl, {
method: "PUT",
headers: {
authorization: signPutObject({
config,
contentType,
date,
objectKey,
}),
"content-type": contentType,
date,
},
body,
}).catch((error: unknown) => {
throw new Error(`OSS upload failed: ${getErrorMessage(error)}`);
});
if (!uploadResponse.ok) {
const detail = await uploadResponse.text();
return {
ok: false,
error: `OSS upload failed with ${uploadResponse.status}: ${detail.slice(0, 240)}`,
};
}
return {
ok: true,
bucket: config.bucket,
objectKey,
url: config.publicBaseUrl
? `${normalizeBaseUrl(config.publicBaseUrl)}/${objectKey}`
: putUrl,
};
} catch (error) {
return {
ok: false,
error: getErrorMessage(error),
};
}
}