first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user