From b787cd161adeb6c8e2cdc5da801abb051003f7f7 Mon Sep 17 00:00:00 2001 From: lethdat Date: Thu, 19 Mar 2026 01:43:49 +0700 Subject: [PATCH] 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. --- components.d.ts | 6 + src/api/rpcclient.ts | 7 +- src/components/icons/UserIcon copy.vue | 10 + src/composables/useUploadQueue.ts | 28 +- .../SecurityNConnected/SecurityNConnected.vue | 38 +- src/routes/settings/Settings.vue | 4 +- src/routes/video/DetailVideoModal.vue | 4 +- src/server/routes/rpc/admin.ts | 379 ++++++++++++++++ src/server/routes/rpc/index.ts | 10 +- src/server/routes/rpc/me.ts | 413 ++---------------- src/server/utils/index.ts | 63 +++ src/server/utils/s3Helper.ts | 232 ++++++++++ 12 files changed, 781 insertions(+), 413 deletions(-) create mode 100644 src/components/icons/UserIcon copy.vue create mode 100644 src/server/routes/rpc/admin.ts create mode 100644 src/server/utils/s3Helper.ts diff --git a/components.d.ts b/components.d.ts index 70611a9..5378848 100644 --- a/components.d.ts +++ b/components.d.ts @@ -37,6 +37,7 @@ declare module 'vue' { CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default'] ClientOnly: typeof import('./src/components/ClientOnly.tsx')['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'] CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.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'] Upload: typeof import('./src/components/icons/Upload.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 copy': typeof import('./src/components/icons/UserIcon copy.vue')['default'] Video: typeof import('./src/components/icons/Video.vue')['default'] VideoIcon: typeof import('./src/components/icons/VideoIcon.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 ClientOnly: typeof import('./src/components/ClientOnly.tsx')['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 CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.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 Upload: typeof import('./src/components/icons/Upload.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 copy': typeof import('./src/components/icons/UserIcon copy.vue')['default'] const Video: typeof import('./src/components/icons/Video.vue')['default'] const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default'] const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default'] diff --git a/src/api/rpcclient.ts b/src/api/rpcclient.ts index 2004f86..561b174 100644 --- a/src/api/rpcclient.ts +++ b/src/api/rpcclient.ts @@ -26,7 +26,12 @@ export const client = proxyTinyRpc({ return await httpClientAdapter({ url: `${url}${targetEndpoint}`, 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({}) }).send(data); }, diff --git a/src/components/icons/UserIcon copy.vue b/src/components/icons/UserIcon copy.vue new file mode 100644 index 0000000..53f8003 --- /dev/null +++ b/src/components/icons/UserIcon copy.vue @@ -0,0 +1,10 @@ + + + diff --git a/src/composables/useUploadQueue.ts b/src/composables/useUploadQueue.ts index 2dead24..996f7b9 100644 --- a/src/composables/useUploadQueue.ts +++ b/src/composables/useUploadQueue.ts @@ -1,3 +1,4 @@ +import { client } from '@/api/rpcclient'; import { computed, ref } from 'vue'; export interface QueueItem { @@ -282,22 +283,19 @@ export function useUploadQueue() { if (!item.file || !item.uploadedUrls) return; try { - const response = await fetch('/merge', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: item.file.name, - chunks: item.uploadedUrls, - size: item.file.size - }) - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.error || 'Merge failed'); + const data = await client.merge(item.file.name, item.uploadedUrls, item.file.size); + // const response = await fetch('/merge', { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ + // filename: item.file.name, + // chunks: item.uploadedUrls, + // size: item.file.size + // }) + // }); + if (!data) { + throw new Error('No response from server'); } - item.status = 'complete'; item.progress = 100; item.uploaded = item.total; diff --git a/src/routes/settings/SecurityNConnected/SecurityNConnected.vue b/src/routes/settings/SecurityNConnected/SecurityNConnected.vue index 3b935cc..967c1c8 100644 --- a/src/routes/settings/SecurityNConnected/SecurityNConnected.vue +++ b/src/routes/settings/SecurityNConnected/SecurityNConnected.vue @@ -142,24 +142,26 @@ const confirmTwoFactor = async () => { }; const connectTelegram = async () => { - try { - await new Promise(resolve => setTimeout(resolve, 1000)); - telegramConnected.value = true; - telegramUsername.value = '@telegram_user'; - toast.add({ - severity: 'success', - summary: t('settings.securityConnected.toast.telegramConnectedSummary'), - detail: t('settings.securityConnected.toast.telegramConnectedDetail', { username: telegramUsername.value }), - life: 3000 - }); - } catch (e) { - toast.add({ - severity: 'error', - summary: t('settings.securityConnected.toast.telegramConnectFailedSummary'), - detail: t('settings.securityConnected.toast.telegramConnectFailedDetail'), - life: 5000 - }); - } + // https://t.me/?start=abc123 + window.open(`https://t.me/hlstiktok_bot?start=${auth.user?.username}`, "_blank"); + // try { + // await new Promise(resolve => setTimeout(resolve, 1000)); + // telegramConnected.value = true; + // telegramUsername.value = '@telegram_user'; + // toast.add({ + // severity: 'success', + // summary: t('settings.securityConnected.toast.telegramConnectedSummary'), + // detail: t('settings.securityConnected.toast.telegramConnectedDetail', { username: telegramUsername.value }), + // life: 3000 + // }); + // } catch (e) { + // toast.add({ + // severity: 'error', + // summary: t('settings.securityConnected.toast.telegramConnectFailedSummary'), + // detail: t('settings.securityConnected.toast.telegramConnectFailedDetail'), + // life: 5000 + // }); + // } }; const disconnectTelegram = async () => { diff --git a/src/routes/settings/Settings.vue b/src/routes/settings/Settings.vue index d3c8a7d..27bf111 100644 --- a/src/routes/settings/Settings.vue +++ b/src/routes/settings/Settings.vue @@ -75,7 +75,7 @@ import UserIcon from '@/components/icons/UserIcon.vue'; import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue'; import { useAuthStore } from '@/stores/auth'; import { useTranslation } from 'i18next-vue'; -import { computed } from 'vue'; +import { computed, createStaticVNode } from 'vue'; import { useRoute } from 'vue-router'; const route = useRoute(); @@ -98,7 +98,7 @@ const menuSections = computed<{ title: string; items: { value: string; label: st { title: t('settings.menu.securityGroup'), items: [ - { value: 'security', label: t('settings.menu.security'), icon: UserIcon }, + { value: 'security', label: t('settings.menu.security'), icon: createStaticVNode(``, 1) }, { value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon }, ], }, diff --git a/src/routes/video/DetailVideoModal.vue b/src/routes/video/DetailVideoModal.vue index da61768..209419c 100644 --- a/src/routes/video/DetailVideoModal.vue +++ b/src/routes/video/DetailVideoModal.vue @@ -42,7 +42,7 @@ const errors = ref<{ title?: string }>({}); const isFreePlan = computed(() => !auth.user?.plan_id); const activeTemplates = computed(() => - adTemplates.value.filter(t => t.is_active), + adTemplates.value.filter(t => t.isActive), ); const subtitleForm = ref({ @@ -239,7 +239,7 @@ watch(() => props.videoId, (newId) => { :key="tmpl.id" :value="tmpl.id" > - {{ tmpl.name }}{{ tmpl.is_default ? ` (${t('video.detailModal.adTemplateDefault')})` : '' }} + {{ tmpl.name }}{{ tmpl.isDefault ? ` (${t('video.detailModal.adTemplateDefault')})` : '' }}

diff --git a/src/server/routes/rpc/admin.ts b/src/server/routes/rpc/admin.ts new file mode 100644 index 0000000..d43a114 --- /dev/null +++ b/src/server/routes/rpc/admin.ts @@ -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); + }), +} \ No newline at end of file diff --git a/src/server/routes/rpc/index.ts b/src/server/routes/rpc/index.ts index 43bc3fe..bcff6c1 100644 --- a/src/server/routes/rpc/index.ts +++ b/src/server/routes/rpc/index.ts @@ -1,12 +1,13 @@ import { authenticate } from "@/server/middlewares/authenticate"; 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 { exposeTinyRpc, httpServerAdapter } from "@hiogawa/tiny-rpc"; import { Hono } from "hono"; import { protectedAuthMethods, publicAuthMethods } from "./auth"; import { meMethods } from "./me"; import { getContext } from "hono/context-storage"; +import { adminMethods } from "./admin"; declare module "hono" { interface ContextVariableMap { @@ -18,6 +19,7 @@ const protectedRoutes = { health: () => ({ ok: true }), ...protectedAuthMethods, ...meMethods, + ...adminMethods }; const publicRoutes = { @@ -45,14 +47,16 @@ export function registerRpcRoutes(app: Hono) { }; const protectedHandler = exposeTinyRpc({ routes: protectedRoutes, - adapter: httpServerAdapter({ endpoint: "/rpc", JSON: JSONProcessor }), + adapter: httpServerAdapter({ endpoint: "/rpc", + // JSON: JSONProcessor + }), }); app.use(publicEndpoint, async (c, next) => { const publicHandler = exposeTinyRpc({ routes: publicRoutes, adapter: httpServerAdapter({ endpoint: "/rpc-public", - JSON: JSONProcessor, + // JSON: JSONProcessor, }), }); const res = await publicHandler({ request: c.req.raw }); diff --git a/src/server/routes/rpc/me.ts b/src/server/routes/rpc/me.ts index 2558d3d..5bef40a 100644 --- a/src/server/routes/rpc/me.ts +++ b/src/server/routes/rpc/me.ts @@ -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 z from "zod"; @@ -303,376 +305,43 @@ export const meMethods = { const metadata = context.get("grpcMetadata"); return await paymentsClient.downloadInvoice(data, metadata); }), - 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); + merge: validateFn( + z.string().trim().min(6).includes("."), + z.array(z.url()).min(1).refine(validateChunkUrls, { message: "One or more chunk URLs are invalid or not allowed" }), + z.number().min(5 * 1024 * 1024),// min 5mb + ) + (async (filename, chunks, size) => { + const manifest = createManifest(filename, chunks, size); + await saveManifest(manifest); + return manifest; + // return await videosClient.merge({ name, chunks, size }, 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); +// } +// }); +// } \ No newline at end of file diff --git a/src/server/utils/index.ts b/src/server/utils/index.ts index b58d386..aa6de07 100644 --- a/src/server/utils/index.ts +++ b/src/server/utils/index.ts @@ -1,4 +1,6 @@ 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 { tryGetContext } from "hono/context-storage"; import { setCookie } from "hono/cookie"; @@ -58,4 +60,65 @@ export const class2Object = (classConvert: T) => { return classAsObj }, {}) return object as T +} +// validator agnostic function guard +// now supports multiple schemas / multiple args +// (it only supports zod for starter) + +export function validateFn( + ...schemas: Schemas +) { + return function decorate( + fn: (...inputs: InferOutputs) => Out + ): (...inputRaws: InferInputs) => 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; + + return fn(...inputs); + }; + }; +} + +// infer input/output from a single parser +type InferIO = 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 = { + [K in keyof Schemas]: InferIO["i"]; +}; + +// infer tuple of parsed outputs from tuple of schemas +type InferOutputs = { + [K in keyof Schemas]: InferIO["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 }); } \ No newline at end of file diff --git a/src/server/utils/s3Helper.ts b/src/server/utils/s3Helper.ts new file mode 100644 index 0000000..b3a8c68 --- /dev/null +++ b/src/server/utils/s3Helper.ts @@ -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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + // 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()}`) + } +} \ No newline at end of file