develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
17 changed files with 257 additions and 139 deletions
Showing only changes of commit 87c99e64cd - Show all commits

2
components.d.ts vendored
View File

@@ -27,6 +27,7 @@ declare module 'vue' {
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default'] AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default']
BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default'] BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default']
Bell: typeof import('./src/components/icons/Bell.vue')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default']
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
@@ -108,6 +109,7 @@ declare global {
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default'] const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
const AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default']
const BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default'] const BaseTable: typeof import('./src/components/ui/table/BaseTable.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.vue')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default']
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default'] const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']

View File

@@ -716,7 +716,7 @@
}, },
"filters": { "filters": {
"searchPlaceholder": "Search videos...", "searchPlaceholder": "Search videos...",
"rangeOfTotal": "{first}{last} of {{total}}", "rangeOfTotal": "{{first}}{{last}} of {{total}}",
"previousPageAria": "Previous page", "previousPageAria": "Previous page",
"nextPageAria": "Next page", "nextPageAria": "Next page",
"allStatus": "All Status", "allStatus": "All Status",

View File

@@ -11,20 +11,23 @@ export function httpClientAdapter(opts: {
}): TinyRpcClientAdapter { }): TinyRpcClientAdapter {
const JSON: JsonTransformer = { const JSON: JsonTransformer = {
parse: globalThis.JSON.parse, parse: globalThis.JSON.parse,
stringify: globalThis.JSON.stringify, stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
...opts.JSON, ...opts.JSON,
}; };
return { return {
send: async (data) => { send: async (data) => {
const url = [opts.url, data.path].join("/"); const url = [opts.url, data.path].join("/");
const payload = JSON.stringify(data.args); const extraHeaders = opts.headers ? await opts.headers() : {};
console.log("RPC Request:", payload); const payload = JSON.stringify(data.args, (headerObj) => {
if (headerObj) {
Object.assign(extraHeaders, headerObj);
}
});
const method = opts.pathsForGET?.includes(data.path) const method = opts.pathsForGET?.includes(data.path)
? "GET" ? "GET"
: "POST"; : "POST";
const extraHeaders = opts.headers ? await opts.headers() : {};
let req: Request; let req: Request;
if (method === "GET") { if (method === "GET") {
req = new Request( req = new Request(
@@ -47,6 +50,7 @@ export function httpClientAdapter(opts: {
}); });
} }
let res: Response; let res: Response;
res = await fetch(req); res = await fetch(req);
if (!res.ok) { if (!res.ok) {
// throw new Error(`HTTP error: ${res.status}`); // throw new Error(`HTTP error: ${res.status}`);
@@ -60,8 +64,10 @@ export function httpClientAdapter(opts: {
); );
// throw TinyRpcError.deserialize(res.status); // throw TinyRpcError.deserialize(res.status);
} }
const result: Result<unknown, unknown> = JSON.parse( const result: Result<unknown, unknown> = JSON.parse(
await res.text() await res.text(),
() => Object.fromEntries((res.headers as any).entries() ?? [])
); );
if (!result.ok) { if (!result.ok) {
throw TinyRpcError.deserialize(result.value); throw TinyRpcError.deserialize(result.value);

View File

@@ -13,14 +13,18 @@ export function httpClientAdapter(opts: {
}): TinyRpcClientAdapter { }): TinyRpcClientAdapter {
const JSON: JsonTransformer = { const JSON: JsonTransformer = {
parse: globalThis.JSON.parse, parse: globalThis.JSON.parse,
stringify: globalThis.JSON.stringify, stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
...opts.JSON, ...opts.JSON,
}; };
return { return {
send: async (data) => { send: async (data) => {
const url = [opts.url, data.path].join("/"); const url = [opts.url, data.path].join("/");
const payload = JSON.stringify(data.args); const extraHeaders = opts.headers ? await opts.headers() : {};
console.log("RPC Request:", payload); const payload = JSON.stringify(data.args, (headerObj) => {
if (headerObj) {
Object.assign(extraHeaders, headerObj);
}
});
const method = opts.pathsForGET?.includes(data.path) const method = opts.pathsForGET?.includes(data.path)
? "GET" ? "GET"
: "POST"; : "POST";
@@ -29,7 +33,10 @@ export function httpClientAdapter(opts: {
req = new Request( req = new Request(
url + url +
"?" + "?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }) new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
); );
} else { } else {
req = new Request(url, { req = new Request(url, {
@@ -37,6 +44,7 @@ export function httpClientAdapter(opts: {
body: payload, body: payload,
headers: { headers: {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
...extraHeaders,
}, },
credentials: "include", credentials: "include",
}); });
@@ -67,11 +75,9 @@ export function httpClientAdapter(opts: {
); );
// throw TinyRpcError.deserialize(res.status); // throw TinyRpcError.deserialize(res.status);
} }
// if (res.headers.get("set-cookie")) {
// console.log("Response has set-cookie header:", res.headers.get("set-cookie"));
// }
const result: Result<unknown, unknown> = JSON.parse( const result: Result<unknown, unknown> = JSON.parse(
await res.text() await res.text(),
() => Object.fromEntries((res.headers as any).entries() ?? [])
); );
if (!result.ok) { if (!result.ok) {
throw TinyRpcError.deserialize(result.value); throw TinyRpcError.deserialize(result.value);

View File

@@ -27,6 +27,7 @@ export const client = proxyTinyRpc<RpcRoutes>({
url: `${url}${targetEndpoint}`, url: `${url}${targetEndpoint}`,
pathsForGET: ["health"], pathsForGET: ["health"],
JSON: clientJSON, JSON: clientJSON,
headers: () => Promise.resolve({})
}).send(data); }).send(data);
}, },
}, },

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
// Định nghĩa cấu trúc dữ liệu
interface SelectOption {
label: string;
value: string | number;
}
interface Props {
loadOptions: () => Promise<SelectOption[]>;
placeholder?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Vui lòng chọn...',
disabled: false
});
// Sử dụng defineModel thay cho props/emits thủ công
const modelValue = defineModel<string | number>();
const options = ref<SelectOption[]>([]);
const loading = ref<boolean>(false);
const error = ref<string | null>(null);
const fetchData = async () => {
loading.value = true;
error.value = null;
try {
options.value = await props.loadOptions();
} catch (err) {
error.value = 'Lỗi kết nối';
} finally {
loading.value = false;
}
};
onMounted(fetchData);
// Tự động load lại nếu hàm fetch thay đổi
watch(() => props.loadOptions, fetchData);
</script>
<template>
<div class="flex items-center gap-3">
<div class="relative w-full max-w-64">
<select
v-model="modelValue"
:disabled="loading || disabled"
class="w-full appearance-none rounded-lg border border-gray-300 bg-white px-4 py-2 pr-10
text-gray-700 outline-none transition-all
focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20
disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
>
<option value="" disabled>{{ placeholder }}</option>
<option
v-for="opt in options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">
<div class="i-carbon-chevron-down text-lg" />
</div>
</div>
<div v-if="loading" class="flex items-center text-blue-500">
<div class="i-carbon-circle-dash animate-spin text-xl" />
</div>
<button
v-if="error"
@click="fetchData"
class="text-xs text-red-500 underline hover:text-red-600 transition"
>
Thử lại?
</button>
</div>
</template>

View File

@@ -310,24 +310,24 @@ const columns = computed<ColumnDef<AdminAdTemplateRow>[]>(() => [
}, },
]); ]);
useAdminPageHeader(() => ({ // useAdminPageHeader(() => ({
eyebrow: "Advertising", // eyebrow: "Advertising",
badge: `${total.value} total templates`, // badge: `${total.value} total templates`,
actions: [ // actions: [
{ // {
label: "Refresh", // label: "Refresh",
variant: "secondary", // variant: "secondary",
onClick: loadTemplates, // onClick: loadTemplates,
}, // },
{ // {
label: "Create template", // label: "Create template",
onClick: () => { // onClick: () => {
actionError.value = null; // actionError.value = null;
createOpen.value = true; // createOpen.value = true;
}, // },
}, // },
], // ],
})); // }));
onMounted(loadTemplates); onMounted(loadTemplates);
</script> </script>
@@ -363,6 +363,10 @@ onMounted(loadTemplates);
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div> <div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
<SettingsSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass=""> <SettingsSectionCard v-else title="Templates" description="Reusable ad templates and ownership metadata." bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadTemplates">Refresh</AppButton>
<AppButton size="sm" @click="createOpen = true; actionError = null">Create template</AppButton>
</template>
<AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" /> <AdminPlaceholderTable v-if="loading" :columns="6" :rows="4" />
<BaseTable <BaseTable

View File

@@ -9,7 +9,6 @@ import SettingsTableSkeleton from "@/routes/settings/components/SettingsTableSke
import type { ColumnDef } from "@tanstack/vue-table"; import type { ColumnDef } from "@tanstack/vue-table";
import { computed, h, onMounted, ref } from "vue"; import { computed, h, onMounted, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>; type ListAgentsResponse = Awaited<ReturnType<typeof rpcClient.listAdminAgents>>;
type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number]; type AdminAgentRow = NonNullable<ListAgentsResponse["agents"]>[number];
@@ -144,25 +143,6 @@ const statusBadgeClass = (status?: string) => {
return "border-border bg-muted/40 text-foreground/70"; return "border-border bg-muted/40 text-foreground/70";
}; };
useAdminPageHeader(() => ({
eyebrow: "Workers",
badge: `${rows.value.length} agents connected`,
actions: [{
label: "Refresh agents",
variant: "secondary",
onClick: loadAgents,
}],
}));
useAdminPageHeader(() => ({
eyebrow: "Workers",
badge: `${rows.value.length} agents connected`,
actions: [{
label: "Refresh agents",
variant: "secondary",
onClick: loadAgents,
}],
}));
const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [ const columns = computed<ColumnDef<AdminAgentRow>[]>(() => [
{ {
@@ -294,7 +274,10 @@ onMounted(loadAgents);
<div class="space-y-4"> <div class="space-y-4">
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div> <div v-if="error" class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ error }}</div>
<SettingsSectionCard v-else title="Agents" description="Connected workers and runtime health." bodyClass=""> <SettingsSectionCard v-else title="Agents" :description="`${rows.length} agents connected`" bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadAgents">Refresh</AppButton>
</template>
<SettingsTableSkeleton v-if="loading" :columns="8" :rows="4" /> <SettingsTableSkeleton v-if="loading" :columns="8" :rows="4" />
<BaseTable <BaseTable

View File

@@ -1,12 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client as rpcClient } from "@/api/rpcclient";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/app/AppButton.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/app/AppInput.vue";
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue"; import SettingsSectionCard from "@/routes/settings/components/SettingsSectionCard.vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader";
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@@ -60,15 +59,6 @@ useAdminRuntimeMqtt(({ topic, payload }) => {
} }
}); });
useAdminPageHeader(() => ({
eyebrow: "Observability",
badge: activeJobId.value ? "Live tail attached" : "Awaiting job selection",
actions: [{
label: "Load logs",
variant: "secondary",
onClick: loadLogs,
}],
}));
</script> </script>
<template> <template>

View File

@@ -241,24 +241,24 @@ const statusBadgeClass = (status?: string) => {
} }
}; };
useAdminPageHeader(() => ({ // useAdminPageHeader(() => ({
eyebrow: "Finance", // eyebrow: "Finance",
badge: `${total.value} total payments`, // badge: `${total.value} total payments`,
actions: [ // actions: [
{ // {
label: "Refresh", // label: "Refresh",
variant: "secondary", // variant: "secondary",
onClick: loadPayments, // onClick: loadPayments,
}, // },
{ // {
label: "Create payment", // label: "Create payment",
onClick: () => { // onClick: () => {
actionError.value = null; // actionError.value = null;
createOpen.value = true; // createOpen.value = true;
}, // },
}, // },
], // ],
})); // }));
const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [ const columns = computed<ColumnDef<AdminPaymentRow>[]>(() => [
{ {
@@ -386,6 +386,10 @@ onMounted(() => {
</div> </div>
<SettingsSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass=""> <SettingsSectionCard v-else title="Payments" description="Payment records and status operations." bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadPayments">Refresh</AppButton>
<AppButton size="sm" @click="createOpen = true">Create payment</AppButton>
</template>
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" /> <AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
<BaseTable <BaseTable

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { client as rpcClient } from "@/api/rpcclient"; import { client, client as rpcClient } from "@/api/rpcclient";
import AppButton from "@/components/app/AppButton.vue"; import AppButton from "@/components/app/AppButton.vue";
import AppDialog from "@/components/app/AppDialog.vue"; import AppDialog from "@/components/app/AppDialog.vue";
import AppInput from "@/components/app/AppInput.vue"; import AppInput from "@/components/app/AppInput.vue";
@@ -10,6 +10,7 @@ import { computed, h, onMounted, reactive, ref, watch } from "vue";
import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue"; import AdminPlaceholderTable from "./components/AdminPlaceholderTable.vue";
import AdminSectionShell from "./components/AdminSectionShell.vue"; import AdminSectionShell from "./components/AdminSectionShell.vue";
import { useAdminPageHeader } from "./components/useAdminPageHeader"; import { useAdminPageHeader } from "./components/useAdminPageHeader";
import AsyncSelect from "@/components/ui/AsyncSelect.vue";
type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>; type ListUsersResponse = Awaited<ReturnType<typeof rpcClient.listAdminUsers>>;
type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number]; type AdminUserRow = NonNullable<ListUsersResponse["users"]>[number];
@@ -408,6 +409,10 @@ onMounted(loadUsers);
</div> </div>
<SettingsSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass=""> <SettingsSectionCard v-else title="Users" :description="`${total} records across ${totalPages} pages.`" bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadUsers">Refresh</AppButton>
<AppButton size="sm" @click="createOpen = true; actionError = null">Create user</AppButton>
</template>
<AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" /> <AdminPlaceholderTable v-if="loading" :columns="['User', 'Role', 'Plan', 'Videos', 'Created', 'Actions']" :rows="limit" />
<template v-else> <template v-else>
@@ -465,8 +470,8 @@ onMounted(loadUsers);
<AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" /> <AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Plan ID</label> <label class="text-sm font-medium text-gray-700">Plan</label>
<AppInput v-model="createForm.planId" placeholder="Optional" /> <AsyncSelect v-model="editForm.planId" :loadOptions="() => client.listPlans().then(plans => (plans?.plans || []).map(p => ({ label: p.name!, value: p.id! })))" />
</div> </div>
</div> </div>
</div> </div>
@@ -523,8 +528,12 @@ onMounted(loadUsers);
<AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" /> <AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Plan ID</label> <label class="text-sm font-medium text-gray-700">Plan</label>
<AppInput v-model="editForm.planId" placeholder="Optional" /> <AsyncSelect v-model="editForm.planId" :loadOptions="() => client.listPlans().then(plans => (plans?.plans || []).map(p => ({ label: p.name!, value: p.id! })))" />
<!-- <select v-model="editForm.planId" class="w-full rounded-md border border-border bg-header px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
<option v-for="plan in selectedRow?.availablePlans || []" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
</select> -->
<!-- <AppInput v-model="editForm.planId" placeholder="Optional" /> -->
</div> </div>
</div> </div>
</div> </div>

View File

@@ -371,24 +371,24 @@ const columns = computed<ColumnDef<AdminVideoRow>[]>(() => [
}, },
]); ]);
useAdminPageHeader(() => ({ // useAdminPageHeader(() => ({
eyebrow: "Media", // eyebrow: "Media",
badge: `${total.value} total videos`, // badge: `${total.value} total videos`,
actions: [ // actions: [
{ // {
label: "Refresh", // label: "Refresh",
variant: "secondary", // variant: "secondary",
onClick: loadVideos, // onClick: loadVideos,
}, // },
{ // {
label: "Create video", // label: "Create video",
onClick: () => { // onClick: () => {
actionError.value = null; // actionError.value = null;
createOpen.value = true; // createOpen.value = true;
}, // },
}, // },
], // ],
})); // }));
watch(statusFilter, async () => { watch(statusFilter, async () => {
page.value = 1; page.value = 1;
@@ -437,6 +437,10 @@ onMounted(loadVideos);
</div> </div>
<SettingsSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass=""> <SettingsSectionCard v-else title="Videos" description="Video inventory and moderation actions." bodyClass="">
<template #header-actions>
<AppButton size="sm" variant="ghost" @click="loadVideos">Refresh</AppButton>
<AppButton size="sm" @click="createOpen = true; actionError = null">Create video</AppButton>
</template>
<AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" /> <AdminPlaceholderTable v-if="loading" :columns="7" :rows="4" />
<BaseTable <BaseTable

View File

@@ -20,6 +20,7 @@ declare module "hono" {
userId: string; userId: string;
role: string; role: string;
email: string; email: string;
nonce: Uint8Array<ArrayBufferLike>;
} }
} }

View File

@@ -1,11 +1,12 @@
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 } from "@/shared/secure-json-transformer"; import { clientJSON, 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";
declare module "hono" { declare module "hono" {
interface ContextVariableMap { interface ContextVariableMap {
@@ -29,15 +30,30 @@ export const publicEndpoint = "/rpc-public/*";
export const pathsForGET: (keyof typeof protectedRoutes)[] = ["health"]; export const pathsForGET: (keyof typeof protectedRoutes)[] = ["health"];
export function registerRpcRoutes(app: Hono) { export function registerRpcRoutes(app: Hono) {
const JSONProcessor: JsonTransformer = {
parse: (v) => parse(v, () => getContext()?.req.header()),
stringify: (v) =>
stringify(v, (headers) => {
const ctx = getContext();
if (ctx) {
Object.entries(headers).forEach(([k, v]) => {
ctx.header(k, v);
});
// ctx.header()
}
}),
};
const protectedHandler = exposeTinyRpc({ const protectedHandler = exposeTinyRpc({
routes: protectedRoutes, routes: protectedRoutes,
adapter: httpServerAdapter({ endpoint: "/rpc",JSON:clientJSON }), 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({ endpoint: "/rpc-public", JSON:clientJSON }), adapter: httpServerAdapter({
endpoint: "/rpc-public",
JSON: JSONProcessor,
}),
}); });
const res = await publicHandler({ request: c.req.raw }); const res = await publicHandler({ request: c.req.raw });
if (res) { if (res) {
@@ -46,7 +62,6 @@ export function registerRpcRoutes(app: Hono) {
return await next(); return await next();
}); });
app.use(endpoint, authenticate, async (c, next) => { app.use(endpoint, authenticate, async (c, next) => {
c.set("grpcMetadata", getGrpcMetadataFromContext()); c.set("grpcMetadata", getGrpcMetadataFromContext());
const res = await protectedHandler({ request: c.req.raw }); const res = await protectedHandler({ request: c.req.raw });
@@ -55,6 +70,4 @@ export function registerRpcRoutes(app: Hono) {
} }
return await next(); return await next();
}); });
} }

View File

@@ -8,10 +8,13 @@ import { createApp } from '@/main';
import { htmlEscape } from '@/server/utils/htmlEscape'; import { htmlEscape } from '@/server/utils/htmlEscape';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { Hono } from 'hono'; import type { Hono } from 'hono';
import nacl from 'tweetnacl';
import { toBase64 } from '@/shared/secure-json-transformer';
export function registerSSRRoutes(app: Hono) { export function registerSSRRoutes(app: Hono) {
app.get("*", async (c) => { app.get("*", async (c) => {
const nonce = crypto.randomUUID(); const nonce = nacl.randomBytes(nacl.box.nonceLength);
c.set("nonce", nonce);
const url = new URL(c.req.url); const url = new URL(c.req.url);
const lang = c.get("language"); const lang = c.get("language");
const { app: vueApp, router, head, pinia, bodyClass, queryCache } = await createApp(lang); const { app: vueApp, router, head, pinia, bodyClass, queryCache } = await createApp(lang);
@@ -28,7 +31,7 @@ export function registerSSRRoutes(app: Hono) {
return streamText(c, async (stream) => { return streamText(c, async (stream) => {
c.header("Content-Type", "text/html; charset=utf-8"); c.header("Content-Type", "text/html; charset=utf-8");
c.header("Content-Encoding", "Identity"); c.header("Content-Encoding", "Identity");
c.header("nonce", toBase64(nonce));
const ctx: Record<string, any> = {}; const ctx: Record<string, any> = {};
const appStream = renderToWebStream(vueApp, ctx); const appStream = renderToWebStream(vueApp, ctx);
@@ -67,7 +70,7 @@ export function registerSSRRoutes(app: Hono) {
}); });
// App data script // App data script
const appDataScript = `<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape(JSON.stringify(ctx))}</script>`; const appDataScript = `<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${toBase64(nonce)}">${htmlEscape(JSON.stringify(ctx))}</script>`;
await stream.write(appDataScript); await stream.write(appDataScript);
// Close HTML // Close HTML

View File

@@ -2,7 +2,10 @@
import superjson from "superjson"; import superjson from "superjson";
import nacl from "tweetnacl"; import nacl from "tweetnacl";
const secureConfig = { kid: 'xUJh4/ADCkL/mZTsxSofIVTgLrTLw2C8h/X8/StUc0E=', publicKeyBase64: 'hvtS8b4RWXkau3B2UXbWhCV1NxS/97DGLfcftf/0TG8=' }; const kp = nacl.box.keyPair();
const uToBase64 = (u8: Uint8Array) => btoa(String.fromCharCode(...u8));
const secureConfig = { kid: uToBase64(kp.secretKey), publicKeyBase64: uToBase64(kp.publicKey) };
export type SecureEnvelopeV1 = { export type SecureEnvelopeV1 = {
kid: string; kid: string;
nonce: string; // base64 nonce: string; // base64
@@ -15,7 +18,7 @@ export type ServerPublicKeyConfig = {
publicKeyBase64: string; publicKeyBase64: string;
}; };
function toBase64(bytes: Uint8Array): string { export function toBase64(bytes: Uint8Array): string {
if (typeof Buffer !== "undefined") { if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("base64"); return Buffer.from(bytes).toString("base64");
} }
@@ -24,7 +27,7 @@ function toBase64(bytes: Uint8Array): string {
return btoa(s); return btoa(s);
} }
function fromBase64(base64: string): Uint8Array { export function fromBase64(base64: string): Uint8Array {
if (typeof Buffer !== "undefined") { if (typeof Buffer !== "undefined") {
return new Uint8Array(Buffer.from(base64, "base64")); return new Uint8Array(Buffer.from(base64, "base64"));
} }
@@ -95,7 +98,7 @@ export function createEncryptedInputTransformer(opts: {
}, },
}; };
} }
export function stringify(object: any) { export function stringify(object: any, setHeader?: (headers: Record<string, string>) => void): string {
const clientKeypair = createClientKeypair(); const clientKeypair = createClientKeypair();
const payload = superjson.serialize(object); const payload = superjson.serialize(object);
const plaintext = utf8Encode(JSON.stringify(payload)); const plaintext = utf8Encode(JSON.stringify(payload));
@@ -108,35 +111,40 @@ export function stringify(object: any) {
// serverPublicKey, // serverPublicKey,
clientKeypair.secretKey, clientKeypair.secretKey,
); );
setHeader?.({
return JSON.stringify({
kid: secureConfig.kid, kid: secureConfig.kid,
nonce: toBase64(nonce), nonce: toBase64(nonce),
pk: toBase64(clientKeypair.publicKey), pk: toBase64(clientKeypair.publicKey),
data: toBase64(cipher),
}); });
// return JSON.stringify({
// kid: secureConfig.kid,
// nonce: toBase64(nonce),
// pk: toBase64(clientKeypair.publicKey),
// data: toBase64(cipher),
// });
return toBase64(cipher);
} }
export function parse(d: unknown): unknown { export function parse(d: string, getHeader?: () => Record<string, string>): any {
const object = typeof d === "string" ? JSON.parse(d) : d; // const object = typeof d === "string" ? JSON.parse(d) : d;
if (!isSecureEnvelope(object)) { // if (!isSecureEnvelope(object)) {
// console.log("parse RPC payload:", object); // // console.log("parse RPC payload:", object);
return object; // return object;
} // }
const headers = getHeader ? getHeader() : {};
// const serverSecretKey = opts.getSecretKeyByKid(object.kid); // const serverSecretKey = opts.getSecretKeyByKid(object.kid);
// if (!serverSecretKey) { // if (!serverSecretKey) {
// throw new Error(`Unknown secure transformer kid: ${object.kid}`); // throw new Error(`Unknown secure transformer kid: ${object.kid}`);
// } // }
const nonce = fromBase64(object.nonce); const nonce = fromBase64(headers.nonce);
const clientPublicKey = fromBase64(object.pk); const clientPublicKey = fromBase64(headers.pk);
const ciphertext = fromBase64(object.data); const ciphertext = fromBase64(d);
const opened = nacl.box.open( const opened = nacl.box.open(
ciphertext, ciphertext,
nonce, nonce,
clientPublicKey, clientPublicKey,
// serverSecretKey // serverSecretKey
fromBase64(secureConfig.kid), // for testing, should be replaced with real secret key retrieval fromBase64(headers.kid), // for testing, should be replaced with real secret key retrieval
); );
if (!opened) { if (!opened) {
throw new Error("Failed to decrypt tRPC input payload"); throw new Error("Failed to decrypt tRPC input payload");

6
src/type.d.ts vendored
View File

@@ -13,11 +13,11 @@ declare module "@httpClientAdapter" {
url: string; url: string;
pathsForGET?: string[]; pathsForGET?: string[];
JSON?: Partial<JsonTransformer>; JSON?: Partial<JsonTransformer>;
headers?: () => Promise<{ Authorization?: undefined; } | { Authorization: string; }> headers?: () => Promise<Record<string, string>>;
}): TinyRpcClientAdapter; }): TinyRpcClientAdapter;
} }
interface JsonTransformer { interface JsonTransformer {
parse: (v: string) => any; // TODO: eliminate proto pollution at least on server by default cf. https://github.com/fastify/secure-json-parse parse: (v: string, getHeader?: () => Record<string, string>) => any; // TODO: eliminate proto pollution at least on server by default cf. https://github.com/fastify/secure-json-parse
stringify: (v: any) => string; stringify: (v: any, setHeader?: (headers: Record<string, string>) => void) => string;
} }