Refactor admin routes and implement S3 manifest handling
- Updated video detail modal to use new ad template property naming convention. - Refactored RPC routes to include admin methods for user, video, payment, plan, and ad template management. - Introduced S3 helper functions for manifest creation, saving, fetching, and validation of chunk URLs. - Added new admin methods for managing jobs and agents. - Created a new UserIcon component for better icon management. - Enhanced validation functions to support multiple schemas.
This commit is contained in:
6
components.d.ts
vendored
6
components.d.ts
vendored
@@ -37,6 +37,7 @@ declare module 'vue' {
|
|||||||
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||||
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||||
CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
||||||
|
copy: typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||||
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||||
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||||
@@ -79,7 +80,9 @@ declare module 'vue' {
|
|||||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||||
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||||
|
User2: typeof import('./src/components/icons/User2.vue')['default']
|
||||||
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
||||||
|
'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||||
Video: typeof import('./src/components/icons/Video.vue')['default']
|
Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||||
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||||
VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
||||||
@@ -119,6 +122,7 @@ declare global {
|
|||||||
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||||
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||||
const CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
const CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
||||||
|
const copy: typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||||
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||||
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||||
@@ -161,7 +165,9 @@ declare global {
|
|||||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||||
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||||
|
const User2: typeof import('./src/components/icons/User2.vue')['default']
|
||||||
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
||||||
|
const 'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||||
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||||
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||||
const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ export const client = proxyTinyRpc<RpcRoutes>({
|
|||||||
return await httpClientAdapter({
|
return await httpClientAdapter({
|
||||||
url: `${url}${targetEndpoint}`,
|
url: `${url}${targetEndpoint}`,
|
||||||
pathsForGET: ["health"],
|
pathsForGET: ["health"],
|
||||||
JSON: clientJSON,
|
JSON: {
|
||||||
|
// parse: clientJSON.parse,
|
||||||
|
parse: (v, fn) => JSON.parse(v),
|
||||||
|
// stringify: clientJSON.stringify,
|
||||||
|
stringify: (v, fn) => JSON.stringify(v),
|
||||||
|
},
|
||||||
headers: () => Promise.resolve({})
|
headers: () => Promise.resolve({})
|
||||||
}).send(data);
|
}).send(data);
|
||||||
},
|
},
|
||||||
|
|||||||
10
src/components/icons/UserIcon copy.vue
Normal file
10
src/components/icons/UserIcon copy.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{
|
||||||
|
filled?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="currentColor"/></svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="518" height="532" viewBox="-3 -258 518 532"><path d="M368 120h-33l-22-64H199l-21 64h-34l32-96h160l32 96zM256-8c-35 0-64-29-64-64s29-64 64-64c36 0 64 29 64 64S292-8 256-8zm0-96c-17 0-32 14-32 32s15 32 32 32c18 0 32-14 32-32s-14-32-32-32zm0 368-12-5C92 193 7 26 17-135l1-20 238-93 239 93 1 20c9 161-76 328-227 394l-13 5zM49-133c-7 147 67 302 207 362 140-60 215-215 208-362l-208-81-207 81z" fill="currentColor"/></svg>
|
||||||
|
</template>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { client } from '@/api/rpcclient';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
@@ -282,22 +283,19 @@ export function useUploadQueue() {
|
|||||||
if (!item.file || !item.uploadedUrls) return;
|
if (!item.file || !item.uploadedUrls) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/merge', {
|
const data = await client.merge(item.file.name, item.uploadedUrls, item.file.size);
|
||||||
method: 'POST',
|
// const response = await fetch('/merge', {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// method: 'POST',
|
||||||
body: JSON.stringify({
|
// headers: { 'Content-Type': 'application/json' },
|
||||||
filename: item.file.name,
|
// body: JSON.stringify({
|
||||||
chunks: item.uploadedUrls,
|
// filename: item.file.name,
|
||||||
size: item.file.size
|
// chunks: item.uploadedUrls,
|
||||||
})
|
// size: item.file.size
|
||||||
});
|
// })
|
||||||
|
// });
|
||||||
const data = await response.json();
|
if (!data) {
|
||||||
|
throw new Error('No response from server');
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || 'Merge failed');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item.status = 'complete';
|
item.status = 'complete';
|
||||||
item.progress = 100;
|
item.progress = 100;
|
||||||
item.uploaded = item.total;
|
item.uploaded = item.total;
|
||||||
|
|||||||
@@ -142,24 +142,26 @@ const confirmTwoFactor = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const connectTelegram = async () => {
|
const connectTelegram = async () => {
|
||||||
try {
|
// https://t.me/<username_bot>?start=abc123
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
window.open(`https://t.me/hlstiktok_bot?start=${auth.user?.username}`, "_blank");
|
||||||
telegramConnected.value = true;
|
// try {
|
||||||
telegramUsername.value = '@telegram_user';
|
// await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
toast.add({
|
// telegramConnected.value = true;
|
||||||
severity: 'success',
|
// telegramUsername.value = '@telegram_user';
|
||||||
summary: t('settings.securityConnected.toast.telegramConnectedSummary'),
|
// toast.add({
|
||||||
detail: t('settings.securityConnected.toast.telegramConnectedDetail', { username: telegramUsername.value }),
|
// severity: 'success',
|
||||||
life: 3000
|
// summary: t('settings.securityConnected.toast.telegramConnectedSummary'),
|
||||||
});
|
// detail: t('settings.securityConnected.toast.telegramConnectedDetail', { username: telegramUsername.value }),
|
||||||
} catch (e) {
|
// life: 3000
|
||||||
toast.add({
|
// });
|
||||||
severity: 'error',
|
// } catch (e) {
|
||||||
summary: t('settings.securityConnected.toast.telegramConnectFailedSummary'),
|
// toast.add({
|
||||||
detail: t('settings.securityConnected.toast.telegramConnectFailedDetail'),
|
// severity: 'error',
|
||||||
life: 5000
|
// summary: t('settings.securityConnected.toast.telegramConnectFailedSummary'),
|
||||||
});
|
// detail: t('settings.securityConnected.toast.telegramConnectFailedDetail'),
|
||||||
}
|
// life: 5000
|
||||||
|
// });
|
||||||
|
// }
|
||||||
};
|
};
|
||||||
|
|
||||||
const disconnectTelegram = async () => {
|
const disconnectTelegram = async () => {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ import UserIcon from '@/components/icons/UserIcon.vue';
|
|||||||
import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue';
|
import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { computed } from 'vue';
|
import { computed, createStaticVNode } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -98,7 +98,7 @@ const menuSections = computed<{ title: string; items: { value: string; label: st
|
|||||||
{
|
{
|
||||||
title: t('settings.menu.securityGroup'),
|
title: t('settings.menu.securityGroup'),
|
||||||
items: [
|
items: [
|
||||||
{ value: 'security', label: t('settings.menu.security'), icon: UserIcon },
|
{ value: 'security', label: t('settings.menu.security'), icon: createStaticVNode(`<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -258 596 564"><path d="M144-120c0-44 36-80 80-80s80 36 80 80-36 80-80 80-80-36-80-80zm208 0c0-71-57-128-128-128S96-191 96-120 153 8 224 8s128-57 128-128zM48 232c0-71 57-128 128-128h64V77c0-7 1-14 3-21h-67C79 56 0 135 0 232v8c0 13 11 24 24 24s24-11 24-24v-8zm397 9-13 6V59l96 32v19c0 56-32 107-83 131zM422 12 310 49c-13 4-22 16-22 30v31c0 75 43 142 110 174l19 9c5 2 10 3 15 3s10-1 15-3l19-9c67-32 110-99 110-174V79c0-14-9-26-22-30L442 11c-6-2-14-2-20 0zm0 0z" fill="currentColor"/></svg>`, 1) },
|
||||||
{ value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon },
|
{ value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const errors = ref<{ title?: string }>({});
|
|||||||
const isFreePlan = computed(() => !auth.user?.plan_id);
|
const isFreePlan = computed(() => !auth.user?.plan_id);
|
||||||
|
|
||||||
const activeTemplates = computed(() =>
|
const activeTemplates = computed(() =>
|
||||||
adTemplates.value.filter(t => t.is_active),
|
adTemplates.value.filter(t => t.isActive),
|
||||||
);
|
);
|
||||||
|
|
||||||
const subtitleForm = ref({
|
const subtitleForm = ref({
|
||||||
@@ -239,7 +239,7 @@ watch(() => props.videoId, (newId) => {
|
|||||||
:key="tmpl.id"
|
:key="tmpl.id"
|
||||||
:value="tmpl.id"
|
:value="tmpl.id"
|
||||||
>
|
>
|
||||||
{{ tmpl.name }}{{ tmpl.is_default ? ` (${t('video.detailModal.adTemplateDefault')})` : '' }}
|
{{ tmpl.name }}{{ tmpl.isDefault ? ` (${t('video.detailModal.adTemplateDefault')})` : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<p v-if="isFreePlan" class="text-xs text-foreground/50 mt-0.5">
|
<p v-if="isFreePlan" class="text-xs text-foreground/50 mt-0.5">
|
||||||
|
|||||||
379
src/server/routes/rpc/admin.ts
Normal file
379
src/server/routes/rpc/admin.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { validateFn } from "@hiogawa/tiny-rpc";
|
||||||
|
import { getContext } from "hono/context-storage";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const optionalTrimmed = () => z.string().trim().min(1).optional();
|
||||||
|
export const adminMethods = {
|
||||||
|
getAdminDashboard: async () => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
const response = await adminClient.getAdminDashboard({}, metadata);
|
||||||
|
return response.dashboard ?? null;
|
||||||
|
},
|
||||||
|
listAdminUsers: validateFn(
|
||||||
|
z.object({
|
||||||
|
page: z.number().int().min(1).optional(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
search: optionalTrimmed(),
|
||||||
|
role: optionalTrimmed(),
|
||||||
|
}).optional().default({}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.listAdminUsers(data, metadata);
|
||||||
|
}),
|
||||||
|
createAdminUser: validateFn(
|
||||||
|
z.object({
|
||||||
|
email: z.string().trim().email(),
|
||||||
|
username: optionalTrimmed(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
role: z.string().trim().min(1),
|
||||||
|
planId: optionalTrimmed(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.createAdminUser(data, metadata);
|
||||||
|
}),
|
||||||
|
updateAdminUser: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
email: z.string().trim().email().optional(),
|
||||||
|
username: optionalTrimmed(),
|
||||||
|
password: z.string().min(6).optional(),
|
||||||
|
role: z.string().trim().min(1).optional(),
|
||||||
|
planId: optionalTrimmed(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.updateAdminUser(data, metadata);
|
||||||
|
}),
|
||||||
|
updateAdminUserRole: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
role: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.updateAdminUserRole(data, metadata);
|
||||||
|
}),
|
||||||
|
deleteAdminUser: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.deleteAdminUser(data, metadata);
|
||||||
|
}),
|
||||||
|
listAdminVideos: validateFn(
|
||||||
|
z.object({
|
||||||
|
page: z.number().int().min(1).optional(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
search: optionalTrimmed(),
|
||||||
|
userId: optionalTrimmed(),
|
||||||
|
status: optionalTrimmed(),
|
||||||
|
}).optional().default({}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.listAdminVideos(data, metadata);
|
||||||
|
}),
|
||||||
|
createAdminVideo: validateFn(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().trim().min(1),
|
||||||
|
title: z.string().trim().min(1),
|
||||||
|
description: optionalTrimmed(),
|
||||||
|
url: z.string().trim().url(),
|
||||||
|
size: z.number().min(0).optional(),
|
||||||
|
duration: z.number().min(0).optional(),
|
||||||
|
format: optionalTrimmed(),
|
||||||
|
status: z.string().trim().min(1),
|
||||||
|
adTemplateId: optionalTrimmed(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.createAdminVideo(data, metadata);
|
||||||
|
}),
|
||||||
|
updateAdminVideo: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
userId: z.string().trim().min(1),
|
||||||
|
title: z.string().trim().min(1),
|
||||||
|
description: optionalTrimmed(),
|
||||||
|
url: z.string().trim().url(),
|
||||||
|
size: z.number().min(0).optional(),
|
||||||
|
duration: z.number().min(0).optional(),
|
||||||
|
format: optionalTrimmed(),
|
||||||
|
status: z.string().trim().min(1),
|
||||||
|
adTemplateId: optionalTrimmed(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.updateAdminVideo(data, metadata);
|
||||||
|
}),
|
||||||
|
deleteAdminVideo: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.deleteAdminVideo(data, metadata);
|
||||||
|
}),
|
||||||
|
listAdminPayments: validateFn(
|
||||||
|
z.object({
|
||||||
|
page: z.number().int().min(1).optional(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
userId: optionalTrimmed(),
|
||||||
|
status: optionalTrimmed(),
|
||||||
|
}).optional().default({}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.listAdminPayments(data, metadata);
|
||||||
|
}),
|
||||||
|
createAdminPayment: validateFn(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().trim().min(1),
|
||||||
|
planId: z.string().trim().min(1),
|
||||||
|
termMonths: z.number().int().min(1),
|
||||||
|
paymentMethod: z.string().trim().min(1),
|
||||||
|
topupAmount: z.number().min(0).optional(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.createAdminPayment(data, metadata);
|
||||||
|
}),
|
||||||
|
updateAdminPayment: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
status: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.updateAdminPayment(data, metadata);
|
||||||
|
}),
|
||||||
|
listAdminPlans: async () => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.listAdminPlans({}, metadata);
|
||||||
|
},
|
||||||
|
createAdminPlan: validateFn(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
description: optionalTrimmed(),
|
||||||
|
features: z.array(z.string().trim().min(1)).optional(),
|
||||||
|
price: z.number().min(0),
|
||||||
|
cycle: z.string().trim().min(1),
|
||||||
|
storageLimit: z.number().int().min(1),
|
||||||
|
uploadLimit: z.number().int().min(1),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.createAdminPlan(data, metadata);
|
||||||
|
}),
|
||||||
|
updateAdminPlan: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
description: optionalTrimmed(),
|
||||||
|
features: z.array(z.string().trim().min(1)).optional(),
|
||||||
|
price: z.number().min(0),
|
||||||
|
cycle: z.string().trim().min(1),
|
||||||
|
storageLimit: z.number().int().min(1),
|
||||||
|
uploadLimit: z.number().int().min(1),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.updateAdminPlan(data, metadata);
|
||||||
|
}),
|
||||||
|
deleteAdminPlan: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.deleteAdminPlan(data, metadata);
|
||||||
|
}),
|
||||||
|
listAdminAdTemplates: validateFn(
|
||||||
|
z.object({
|
||||||
|
page: z.number().int().min(1).optional(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
userId: optionalTrimmed(),
|
||||||
|
search: optionalTrimmed(),
|
||||||
|
}).optional().default({}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.listAdminAdTemplates(data, metadata);
|
||||||
|
}),
|
||||||
|
createAdminAdTemplate: validateFn(
|
||||||
|
z.object({
|
||||||
|
userId: z.string().trim().min(1),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
description: optionalTrimmed(),
|
||||||
|
vastTagUrl: z.string().trim().url(),
|
||||||
|
adFormat: z.string().trim().min(1).optional(),
|
||||||
|
duration: z.number().int().min(0).optional(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
isDefault: z.boolean(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.createAdminAdTemplate(data, metadata);
|
||||||
|
}),
|
||||||
|
updateAdminAdTemplate: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
userId: z.string().trim().min(1),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
description: optionalTrimmed(),
|
||||||
|
vastTagUrl: z.string().trim().url(),
|
||||||
|
adFormat: z.string().trim().min(1).optional(),
|
||||||
|
duration: z.number().int().min(0).optional(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
isDefault: z.boolean(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.updateAdminAdTemplate(data, metadata);
|
||||||
|
}),
|
||||||
|
deleteAdminAdTemplate: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.deleteAdminAdTemplate(data, metadata);
|
||||||
|
}),
|
||||||
|
listAdminJobs: validateFn(
|
||||||
|
z.object({
|
||||||
|
offset: z.number().int().min(0).optional(),
|
||||||
|
limit: z.number().int().min(1).max(100).optional(),
|
||||||
|
agentId: optionalTrimmed(),
|
||||||
|
}).optional().default({}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.listAdminJobs(data, metadata);
|
||||||
|
}),
|
||||||
|
getAdminJob: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.getAdminJob(data, metadata);
|
||||||
|
}),
|
||||||
|
getAdminJobLogs: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.getAdminJobLogs(data, metadata);
|
||||||
|
}),
|
||||||
|
createAdminJob: validateFn(
|
||||||
|
z.object({
|
||||||
|
command: z.string().trim().min(1),
|
||||||
|
image: optionalTrimmed(),
|
||||||
|
env: z.record(z.string(), z.string()).optional(),
|
||||||
|
priority: z.number().int().optional(),
|
||||||
|
userId: optionalTrimmed(),
|
||||||
|
name: optionalTrimmed(),
|
||||||
|
timeLimit: z.number().int().min(0).optional(),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.createAdminJob(data, metadata);
|
||||||
|
}),
|
||||||
|
cancelAdminJob: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.cancelAdminJob(data, metadata);
|
||||||
|
}),
|
||||||
|
retryAdminJob: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.retryAdminJob(data, metadata);
|
||||||
|
}),
|
||||||
|
listAdminAgents: async () => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.listAdminAgents({}, metadata);
|
||||||
|
},
|
||||||
|
restartAdminAgent: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.restartAdminAgent(data, metadata);
|
||||||
|
}),
|
||||||
|
updateAdminAgent: validateFn(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
)(async (data) => {
|
||||||
|
const context = getContext();
|
||||||
|
const adminClient = context.get("adminServiceClient");
|
||||||
|
const metadata = context.get("grpcMetadata");
|
||||||
|
return await adminClient.updateAdminAgent(data, metadata);
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { authenticate } from "@/server/middlewares/authenticate";
|
import { authenticate } from "@/server/middlewares/authenticate";
|
||||||
import { getGrpcMetadataFromContext } from "@/server/services/grpcClient";
|
import { getGrpcMetadataFromContext } from "@/server/services/grpcClient";
|
||||||
import { clientJSON, parse, stringify } from "@/shared/secure-json-transformer";
|
import { parse, stringify } from "@/shared/secure-json-transformer";
|
||||||
import { Metadata } from "@grpc/grpc-js";
|
import { Metadata } from "@grpc/grpc-js";
|
||||||
import { exposeTinyRpc, httpServerAdapter } from "@hiogawa/tiny-rpc";
|
import { exposeTinyRpc, httpServerAdapter } from "@hiogawa/tiny-rpc";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { protectedAuthMethods, publicAuthMethods } from "./auth";
|
import { protectedAuthMethods, publicAuthMethods } from "./auth";
|
||||||
import { meMethods } from "./me";
|
import { meMethods } from "./me";
|
||||||
import { getContext } from "hono/context-storage";
|
import { getContext } from "hono/context-storage";
|
||||||
|
import { adminMethods } from "./admin";
|
||||||
|
|
||||||
declare module "hono" {
|
declare module "hono" {
|
||||||
interface ContextVariableMap {
|
interface ContextVariableMap {
|
||||||
@@ -18,6 +19,7 @@ const protectedRoutes = {
|
|||||||
health: () => ({ ok: true }),
|
health: () => ({ ok: true }),
|
||||||
...protectedAuthMethods,
|
...protectedAuthMethods,
|
||||||
...meMethods,
|
...meMethods,
|
||||||
|
...adminMethods
|
||||||
};
|
};
|
||||||
|
|
||||||
const publicRoutes = {
|
const publicRoutes = {
|
||||||
@@ -45,14 +47,16 @@ export function registerRpcRoutes(app: Hono) {
|
|||||||
};
|
};
|
||||||
const protectedHandler = exposeTinyRpc({
|
const protectedHandler = exposeTinyRpc({
|
||||||
routes: protectedRoutes,
|
routes: protectedRoutes,
|
||||||
adapter: httpServerAdapter({ endpoint: "/rpc", JSON: JSONProcessor }),
|
adapter: httpServerAdapter({ endpoint: "/rpc",
|
||||||
|
// JSON: JSONProcessor
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
app.use(publicEndpoint, async (c, next) => {
|
app.use(publicEndpoint, async (c, next) => {
|
||||||
const publicHandler = exposeTinyRpc({
|
const publicHandler = exposeTinyRpc({
|
||||||
routes: publicRoutes,
|
routes: publicRoutes,
|
||||||
adapter: httpServerAdapter({
|
adapter: httpServerAdapter({
|
||||||
endpoint: "/rpc-public",
|
endpoint: "/rpc-public",
|
||||||
JSON: JSONProcessor,
|
// JSON: JSONProcessor,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const res = await publicHandler({ request: c.req.raw });
|
const res = await publicHandler({ request: c.req.raw });
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { validateFn } from "@hiogawa/tiny-rpc";
|
// import { validateFn } from "@hiogawa/tiny-rpc";
|
||||||
|
import { validateFn } from "@/server/utils";
|
||||||
|
import { createManifest, saveManifest, validateChunkUrls } from "@/server/utils/s3Helper";
|
||||||
import { getContext } from "hono/context-storage";
|
import { getContext } from "hono/context-storage";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
@@ -303,376 +305,43 @@ export const meMethods = {
|
|||||||
const metadata = context.get("grpcMetadata");
|
const metadata = context.get("grpcMetadata");
|
||||||
return await paymentsClient.downloadInvoice(data, metadata);
|
return await paymentsClient.downloadInvoice(data, metadata);
|
||||||
}),
|
}),
|
||||||
getAdminDashboard: async () => {
|
merge: validateFn(
|
||||||
const context = getContext();
|
z.string().trim().min(6).includes("."),
|
||||||
const adminClient = context.get("adminServiceClient");
|
z.array(z.url()).min(1).refine(validateChunkUrls, { message: "One or more chunk URLs are invalid or not allowed" }),
|
||||||
const metadata = context.get("grpcMetadata");
|
z.number().min(5 * 1024 * 1024),// min 5mb
|
||||||
const response = await adminClient.getAdminDashboard({}, metadata);
|
)
|
||||||
return response.dashboard ?? null;
|
(async (filename, chunks, size) => {
|
||||||
},
|
const manifest = createManifest(filename, chunks, size);
|
||||||
listAdminUsers: validateFn(
|
await saveManifest(manifest);
|
||||||
z.object({
|
return manifest;
|
||||||
page: z.number().int().min(1).optional(),
|
// return await videosClient.merge({ name, chunks, size }, metadata);
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
|
||||||
search: optionalTrimmed(),
|
|
||||||
role: optionalTrimmed(),
|
|
||||||
}).optional().default({}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.listAdminUsers(data, metadata);
|
|
||||||
}),
|
|
||||||
createAdminUser: validateFn(
|
|
||||||
z.object({
|
|
||||||
email: z.string().trim().email(),
|
|
||||||
username: optionalTrimmed(),
|
|
||||||
password: z.string().min(6),
|
|
||||||
role: z.string().trim().min(1),
|
|
||||||
planId: optionalTrimmed(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.createAdminUser(data, metadata);
|
|
||||||
}),
|
|
||||||
updateAdminUser: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
email: z.string().trim().email().optional(),
|
|
||||||
username: optionalTrimmed(),
|
|
||||||
password: z.string().min(6).optional(),
|
|
||||||
role: z.string().trim().min(1).optional(),
|
|
||||||
planId: optionalTrimmed(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.updateAdminUser(data, metadata);
|
|
||||||
}),
|
|
||||||
updateAdminUserRole: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
role: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.updateAdminUserRole(data, metadata);
|
|
||||||
}),
|
|
||||||
deleteAdminUser: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.deleteAdminUser(data, metadata);
|
|
||||||
}),
|
|
||||||
listAdminVideos: validateFn(
|
|
||||||
z.object({
|
|
||||||
page: z.number().int().min(1).optional(),
|
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
|
||||||
search: optionalTrimmed(),
|
|
||||||
userId: optionalTrimmed(),
|
|
||||||
status: optionalTrimmed(),
|
|
||||||
}).optional().default({}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.listAdminVideos(data, metadata);
|
|
||||||
}),
|
|
||||||
createAdminVideo: validateFn(
|
|
||||||
z.object({
|
|
||||||
userId: z.string().trim().min(1),
|
|
||||||
title: z.string().trim().min(1),
|
|
||||||
description: optionalTrimmed(),
|
|
||||||
url: z.string().trim().url(),
|
|
||||||
size: z.number().min(0).optional(),
|
|
||||||
duration: z.number().min(0).optional(),
|
|
||||||
format: optionalTrimmed(),
|
|
||||||
status: z.string().trim().min(1),
|
|
||||||
adTemplateId: optionalTrimmed(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.createAdminVideo(data, metadata);
|
|
||||||
}),
|
|
||||||
updateAdminVideo: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
userId: z.string().trim().min(1),
|
|
||||||
title: z.string().trim().min(1),
|
|
||||||
description: optionalTrimmed(),
|
|
||||||
url: z.string().trim().url(),
|
|
||||||
size: z.number().min(0).optional(),
|
|
||||||
duration: z.number().min(0).optional(),
|
|
||||||
format: optionalTrimmed(),
|
|
||||||
status: z.string().trim().min(1),
|
|
||||||
adTemplateId: optionalTrimmed(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.updateAdminVideo(data, metadata);
|
|
||||||
}),
|
|
||||||
deleteAdminVideo: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.deleteAdminVideo(data, metadata);
|
|
||||||
}),
|
|
||||||
listAdminPayments: validateFn(
|
|
||||||
z.object({
|
|
||||||
page: z.number().int().min(1).optional(),
|
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
|
||||||
userId: optionalTrimmed(),
|
|
||||||
status: optionalTrimmed(),
|
|
||||||
}).optional().default({}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.listAdminPayments(data, metadata);
|
|
||||||
}),
|
|
||||||
createAdminPayment: validateFn(
|
|
||||||
z.object({
|
|
||||||
userId: z.string().trim().min(1),
|
|
||||||
planId: z.string().trim().min(1),
|
|
||||||
termMonths: z.number().int().min(1),
|
|
||||||
paymentMethod: z.string().trim().min(1),
|
|
||||||
topupAmount: z.number().min(0).optional(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.createAdminPayment(data, metadata);
|
|
||||||
}),
|
|
||||||
updateAdminPayment: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
status: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.updateAdminPayment(data, metadata);
|
|
||||||
}),
|
|
||||||
listAdminPlans: async () => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.listAdminPlans({}, metadata);
|
|
||||||
},
|
|
||||||
createAdminPlan: validateFn(
|
|
||||||
z.object({
|
|
||||||
name: z.string().trim().min(1),
|
|
||||||
description: optionalTrimmed(),
|
|
||||||
features: z.array(z.string().trim().min(1)).optional(),
|
|
||||||
price: z.number().min(0),
|
|
||||||
cycle: z.string().trim().min(1),
|
|
||||||
storageLimit: z.number().int().min(1),
|
|
||||||
uploadLimit: z.number().int().min(1),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.createAdminPlan(data, metadata);
|
|
||||||
}),
|
|
||||||
updateAdminPlan: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
name: z.string().trim().min(1),
|
|
||||||
description: optionalTrimmed(),
|
|
||||||
features: z.array(z.string().trim().min(1)).optional(),
|
|
||||||
price: z.number().min(0),
|
|
||||||
cycle: z.string().trim().min(1),
|
|
||||||
storageLimit: z.number().int().min(1),
|
|
||||||
uploadLimit: z.number().int().min(1),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.updateAdminPlan(data, metadata);
|
|
||||||
}),
|
|
||||||
deleteAdminPlan: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.deleteAdminPlan(data, metadata);
|
|
||||||
}),
|
|
||||||
listAdminAdTemplates: validateFn(
|
|
||||||
z.object({
|
|
||||||
page: z.number().int().min(1).optional(),
|
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
|
||||||
userId: optionalTrimmed(),
|
|
||||||
search: optionalTrimmed(),
|
|
||||||
}).optional().default({}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.listAdminAdTemplates(data, metadata);
|
|
||||||
}),
|
|
||||||
createAdminAdTemplate: validateFn(
|
|
||||||
z.object({
|
|
||||||
userId: z.string().trim().min(1),
|
|
||||||
name: z.string().trim().min(1),
|
|
||||||
description: optionalTrimmed(),
|
|
||||||
vastTagUrl: z.string().trim().url(),
|
|
||||||
adFormat: z.string().trim().min(1).optional(),
|
|
||||||
duration: z.number().int().min(0).optional(),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
isDefault: z.boolean(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.createAdminAdTemplate(data, metadata);
|
|
||||||
}),
|
|
||||||
updateAdminAdTemplate: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
userId: z.string().trim().min(1),
|
|
||||||
name: z.string().trim().min(1),
|
|
||||||
description: optionalTrimmed(),
|
|
||||||
vastTagUrl: z.string().trim().url(),
|
|
||||||
adFormat: z.string().trim().min(1).optional(),
|
|
||||||
duration: z.number().int().min(0).optional(),
|
|
||||||
isActive: z.boolean(),
|
|
||||||
isDefault: z.boolean(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.updateAdminAdTemplate(data, metadata);
|
|
||||||
}),
|
|
||||||
deleteAdminAdTemplate: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.deleteAdminAdTemplate(data, metadata);
|
|
||||||
}),
|
|
||||||
listAdminJobs: validateFn(
|
|
||||||
z.object({
|
|
||||||
offset: z.number().int().min(0).optional(),
|
|
||||||
limit: z.number().int().min(1).max(100).optional(),
|
|
||||||
agentId: optionalTrimmed(),
|
|
||||||
}).optional().default({}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.listAdminJobs(data, metadata);
|
|
||||||
}),
|
|
||||||
getAdminJob: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.getAdminJob(data, metadata);
|
|
||||||
}),
|
|
||||||
getAdminJobLogs: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.getAdminJobLogs(data, metadata);
|
|
||||||
}),
|
|
||||||
createAdminJob: validateFn(
|
|
||||||
z.object({
|
|
||||||
command: z.string().trim().min(1),
|
|
||||||
image: optionalTrimmed(),
|
|
||||||
env: z.record(z.string(), z.string()).optional(),
|
|
||||||
priority: z.number().int().optional(),
|
|
||||||
userId: optionalTrimmed(),
|
|
||||||
name: optionalTrimmed(),
|
|
||||||
timeLimit: z.number().int().min(0).optional(),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.createAdminJob(data, metadata);
|
|
||||||
}),
|
|
||||||
cancelAdminJob: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.cancelAdminJob(data, metadata);
|
|
||||||
}),
|
|
||||||
retryAdminJob: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.retryAdminJob(data, metadata);
|
|
||||||
}),
|
|
||||||
listAdminAgents: async () => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.listAdminAgents({}, metadata);
|
|
||||||
},
|
|
||||||
restartAdminAgent: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.restartAdminAgent(data, metadata);
|
|
||||||
}),
|
|
||||||
updateAdminAgent: validateFn(
|
|
||||||
z.object({
|
|
||||||
id: z.string().trim().min(1),
|
|
||||||
}),
|
|
||||||
)(async (data) => {
|
|
||||||
const context = getContext();
|
|
||||||
const adminClient = context.get("adminServiceClient");
|
|
||||||
const metadata = context.get("grpcMetadata");
|
|
||||||
return await adminClient.updateAdminAgent(data, metadata);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
// export function registerMergeRoutes(app: Hono) {
|
||||||
|
// app.post('/merge', authMiddleware, async (c) => {
|
||||||
|
// try {
|
||||||
|
// const body = await c.req.json();
|
||||||
|
// const { filename, chunks, size } = body;
|
||||||
|
|
||||||
|
// if (!filename || !Array.isArray(chunks) || chunks.length === 0) {
|
||||||
|
// return c.json({ error: 'invalid payload' }, 400);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const hostError = validateChunkUrls(chunks);
|
||||||
|
// if (hostError) return c.json({ error: hostError }, 400);
|
||||||
|
|
||||||
|
// const manifest = createManifest(filename, chunks, size);
|
||||||
|
// await saveManifest(manifest);
|
||||||
|
|
||||||
|
// return c.json({
|
||||||
|
// status: 'ok',
|
||||||
|
// id: manifest.id,
|
||||||
|
// filename: manifest.filename,
|
||||||
|
// total_parts: manifest.total_parts,
|
||||||
|
// size: manifest.size,
|
||||||
|
// });
|
||||||
|
// } catch (e: any) {
|
||||||
|
// return c.json({ error: e?.message ?? String(e) }, 500);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { User } from "@/server/gen/proto/app/v1/common";
|
import type { User } from "@/server/gen/proto/app/v1/common";
|
||||||
|
import { TinyRpcError } from "@hiogawa/tiny-rpc";
|
||||||
|
import { tinyassert } from "@hiogawa/utils";
|
||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { tryGetContext } from "hono/context-storage";
|
import { tryGetContext } from "hono/context-storage";
|
||||||
import { setCookie } from "hono/cookie";
|
import { setCookie } from "hono/cookie";
|
||||||
@@ -59,3 +61,64 @@ export const class2Object = <T>(classConvert: T) => {
|
|||||||
}, {})
|
}, {})
|
||||||
return object as T
|
return object as T
|
||||||
}
|
}
|
||||||
|
// validator agnostic function guard
|
||||||
|
// now supports multiple schemas / multiple args
|
||||||
|
// (it only supports zod for starter)
|
||||||
|
|
||||||
|
export function validateFn<Schemas extends readonly unknown[]>(
|
||||||
|
...schemas: Schemas
|
||||||
|
) {
|
||||||
|
return function decorate<Out>(
|
||||||
|
fn: (...inputs: InferOutputs<Schemas>) => Out
|
||||||
|
): (...inputRaws: InferInputs<Schemas>) => Out {
|
||||||
|
return function wrapper(...inputRaws) {
|
||||||
|
if (inputRaws.length !== schemas.length) {
|
||||||
|
throw new TinyRpcError(
|
||||||
|
`invalid argument count: expected ${schemas.length}, got ${inputRaws.length}`
|
||||||
|
).setStatus(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputs = schemas.map((schema, index) => {
|
||||||
|
const parser = getParser(schema);
|
||||||
|
try {
|
||||||
|
return parser(inputRaws[index]);
|
||||||
|
} catch (e) {
|
||||||
|
throw TinyRpcError.fromUnknown(`Error validating argument at index ${index}: ${e instanceof Error ? e.message : 'Unknown error'}`).setStatus(400);
|
||||||
|
}
|
||||||
|
}) as InferOutputs<Schemas>;
|
||||||
|
|
||||||
|
return fn(...inputs);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// infer input/output from a single parser
|
||||||
|
type InferIO<Parser> = Parser extends { _input: infer I; _output: infer O }
|
||||||
|
? {
|
||||||
|
i: I;
|
||||||
|
o: O;
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
i: never;
|
||||||
|
o: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
// infer tuple of raw inputs from tuple of schemas
|
||||||
|
type InferInputs<Schemas extends readonly unknown[]> = {
|
||||||
|
[K in keyof Schemas]: InferIO<Schemas[K]>["i"];
|
||||||
|
};
|
||||||
|
|
||||||
|
// infer tuple of parsed outputs from tuple of schemas
|
||||||
|
type InferOutputs<Schemas extends readonly unknown[]> = {
|
||||||
|
[K in keyof Schemas]: InferIO<Schemas[K]>["o"];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getParser(schema: unknown): (input: unknown) => unknown {
|
||||||
|
tinyassert(schema && typeof schema === "object");
|
||||||
|
|
||||||
|
if ("parse" in schema && typeof schema.parse === "function") {
|
||||||
|
return schema.parse.bind(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TinyRpcError("unsupported schema", { cause: schema });
|
||||||
|
}
|
||||||
232
src/server/utils/s3Helper.ts
Normal file
232
src/server/utils/s3Helper.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
import { AwsClient } from 'aws4fetch';
|
||||||
|
|
||||||
|
export type Part = {
|
||||||
|
index: number
|
||||||
|
host: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Manifest = {
|
||||||
|
version: 1
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
total_parts: number
|
||||||
|
parts: Part[]
|
||||||
|
createdAt: number
|
||||||
|
expiresAt: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// S3 Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const S3_ENDPOINT = "https://minio1.webtui.vn:9000"
|
||||||
|
const BUCKET_NAME = "bucket-lethdat"
|
||||||
|
|
||||||
|
const aws = new AwsClient({
|
||||||
|
accessKeyId: "lethdat",
|
||||||
|
secretAccessKey: "D@tkhong9",
|
||||||
|
service: 's3',
|
||||||
|
region: 'auto'
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// S3 Operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const OBJECT_KEY = (id: string) => `${id}.json`
|
||||||
|
|
||||||
|
/** Persist a manifest as JSON in MinIO. */
|
||||||
|
export async function saveManifest(manifest: Manifest): Promise<void> {
|
||||||
|
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${OBJECT_KEY(manifest.id)}`;
|
||||||
|
|
||||||
|
const response = await aws.fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(manifest),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save manifest: ${response.status} ${await response.text()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a manifest from MinIO. */
|
||||||
|
export async function getManifest(id: string): Promise<Manifest | null> {
|
||||||
|
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${OBJECT_KEY(id)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await aws.fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get manifest: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
const manifest: Manifest = JSON.parse(text)
|
||||||
|
|
||||||
|
if (manifest.expiresAt < Date.now()) {
|
||||||
|
await deleteManifest(id).catch(() => { })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a manifest object from MinIO. */
|
||||||
|
export async function deleteManifest(id: string): Promise<void> {
|
||||||
|
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${OBJECT_KEY(id)}`;
|
||||||
|
|
||||||
|
const response = await aws.fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(`Failed to delete manifest: ${response.status}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Allowed chunk source hosts
|
||||||
|
const ALLOWED_HOSTS = [
|
||||||
|
'tmpfiles.org',
|
||||||
|
'gofile.io',
|
||||||
|
'pixeldrain.com',
|
||||||
|
'uploadfiles.io',
|
||||||
|
'anonfiles.com',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Returns an error message if any URL is disallowed, otherwise null. */
|
||||||
|
export function validateChunkUrls(chunks: string[]): boolean {
|
||||||
|
for (const u of chunks) {
|
||||||
|
try {
|
||||||
|
const { hostname } = new URL(u)
|
||||||
|
if (!ALLOWED_HOSTS.some(h => hostname.includes(h))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFilename(name: string): string {
|
||||||
|
return name.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectHost(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '')
|
||||||
|
} catch {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUrl(url: string): string {
|
||||||
|
if (url.includes("tmpfiles.org/") && !url.includes("tmpfiles.org/dl/")) {
|
||||||
|
return url.trim().replace("tmpfiles.org/", 'tmpfiles.org/dl/')
|
||||||
|
}
|
||||||
|
return url.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all manifests in bucket (simple implementation). */
|
||||||
|
export async function getListFiles(): Promise<string[]> {
|
||||||
|
// For now return empty array - implement listing if needed
|
||||||
|
// MinIO S3 ListObjectsV2 would require XML parsing
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a new Manifest. */
|
||||||
|
export function createManifest(
|
||||||
|
filename: string,
|
||||||
|
chunks: string[],
|
||||||
|
size: number,
|
||||||
|
ttlMs = 60 * 60 * 1000,
|
||||||
|
): Manifest {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const now = Date.now()
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
id,
|
||||||
|
filename: sanitizeFilename(filename),
|
||||||
|
total_parts: chunks.length,
|
||||||
|
parts: chunks.map((url, index) => ({ index, host: detectHost(url), url: formatUrl(url) })),
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + ttlMs,
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streaming
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Streams all parts in index order as one continuous ReadableStream. */
|
||||||
|
export function streamManifest(manifest: Manifest): ReadableStream<Uint8Array> {
|
||||||
|
const parts = [...manifest.parts].sort((a, b) => a.index - b.index)
|
||||||
|
const RETRY = 3
|
||||||
|
return new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
for (const part of parts) {
|
||||||
|
let attempt = 0
|
||||||
|
let ok = false
|
||||||
|
while (attempt < RETRY && !ok) {
|
||||||
|
attempt++
|
||||||
|
try {
|
||||||
|
const res = await fetch(formatUrl(part.url))
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const reader = res.body!.getReader()
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
controller.enqueue(value)
|
||||||
|
}
|
||||||
|
ok = true
|
||||||
|
} catch (err: any) {
|
||||||
|
if (attempt >= RETRY) {
|
||||||
|
controller.error(new Error(`Part ${part.index} failed: ${err?.message ?? err}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 1000 * attempt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export async function saveImageFromStream(stream: ArrayBuffer, filename: string): Promise<void> {
|
||||||
|
// Implement this function to save the thumbnail image stream to storage and update the database with the thumbnail URL
|
||||||
|
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${filename}.jpg`;
|
||||||
|
|
||||||
|
const response = await aws.fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
body: stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save thumbnail: ${response.status} ${await response.text()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user