develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
12 changed files with 781 additions and 413 deletions
Showing only changes of commit b787cd161a - Show all commits

6
components.d.ts vendored
View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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