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:
2026-03-19 01:43:49 +07:00
parent bd8b21955e
commit b787cd161a
12 changed files with 781 additions and 413 deletions

6
components.d.ts vendored
View File

@@ -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']

View File

@@ -26,7 +26,12 @@ export const client = proxyTinyRpc<RpcRoutes>({
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);
},

View 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>

View File

@@ -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;

View File

@@ -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/<username_bot>?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 () => {

View File

@@ -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(`<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 },
],
},

View File

@@ -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')})` : '' }}
</option>
</select>
<p v-if="isFreePlan" class="text-xs text-foreground/50 mt-0.5">

View 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);
}),
}

View File

@@ -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 });

View File

@@ -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);
// }
// });
// }

View File

@@ -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";
@@ -59,3 +61,64 @@ export const class2Object = <T>(classConvert: 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 });
}

View 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()}`)
}
}