feat(server): 实现设备授权与报告 ZIP 签名打包接口
This commit is contained in:
@@ -24,30 +24,4 @@ const getORPCClient = createIsomorphicFn()
|
|||||||
|
|
||||||
const client: RouterClient = getORPCClient()
|
const client: RouterClient = getORPCClient()
|
||||||
|
|
||||||
export const orpc = createTanstackQueryUtils(client, {
|
export const orpc = createTanstackQueryUtils(client)
|
||||||
experimental_defaults: {
|
|
||||||
todo: {
|
|
||||||
create: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
remove: {
|
|
||||||
mutationOptions: {
|
|
||||||
onSuccess: (_, __, ___, ctx) => {
|
|
||||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,192 +1,20 @@
|
|||||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import type { ChangeEventHandler, SubmitEventHandler } from 'react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { orpc } from '@/client/orpc'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: Todos,
|
component: Home,
|
||||||
loader: async ({ context }) => {
|
|
||||||
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function Todos() {
|
function Home() {
|
||||||
const [newTodoTitle, setNewTodoTitle] = useState('')
|
|
||||||
|
|
||||||
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
|
|
||||||
const createMutation = useMutation(orpc.todo.create.mutationOptions())
|
|
||||||
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
|
|
||||||
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
|
|
||||||
|
|
||||||
const handleCreateTodo: SubmitEventHandler<HTMLFormElement> = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (newTodoTitle.trim()) {
|
|
||||||
createMutation.mutate({ title: newTodoTitle.trim() })
|
|
||||||
setNewTodoTitle('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
setNewTodoTitle(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
id,
|
|
||||||
data: { completed: !currentCompleted },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteTodo = (id: string) => {
|
|
||||||
deleteMutation.mutate({ id })
|
|
||||||
}
|
|
||||||
|
|
||||||
const todos = listQuery.data
|
|
||||||
const completedCount = todos.filter((todo) => todo.completed).length
|
|
||||||
const totalCount = todos.length
|
|
||||||
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
|
<div className="min-h-screen bg-slate-50 flex items-center justify-center font-sans">
|
||||||
<div className="max-w-2xl mx-auto space-y-8">
|
<div className="text-center space-y-4">
|
||||||
{/* Header */}
|
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">UX Server</h1>
|
||||||
<div className="flex items-end justify-between">
|
<p className="text-slate-500">
|
||||||
<div>
|
API:
|
||||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">我的待办</h1>
|
<a href="/api" className="text-indigo-600 hover:text-indigo-700 underline">
|
||||||
<p className="text-slate-500 mt-1">保持专注,逐个击破</p>
|
/api
|
||||||
</div>
|
</a>
|
||||||
<div className="text-right">
|
</p>
|
||||||
<div className="text-2xl font-semibold text-slate-900">
|
|
||||||
{completedCount}
|
|
||||||
<span className="text-slate-400 text-lg">/{totalCount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">已完成</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Todo Form */}
|
|
||||||
<form onSubmit={handleCreateTodo} className="relative group z-10">
|
|
||||||
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newTodoTitle}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder="添加新任务..."
|
|
||||||
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending || !newTodoTitle.trim()}
|
|
||||||
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? '添加中' : '添加'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Progress Bar (Only visible when there are tasks) */}
|
|
||||||
{totalCount > 0 && (
|
|
||||||
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Todo List */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{todos.length === 0 ? (
|
|
||||||
<div className="py-20 text-center">
|
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-8 h-8 text-slate-400"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-500 text-lg font-medium">没有待办事项</p>
|
|
||||||
<p className="text-slate-400 text-sm mt-1">输入上方内容添加您的第一个任务</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
todos.map((todo) => (
|
|
||||||
<div
|
|
||||||
key={todo.id}
|
|
||||||
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
|
|
||||||
todo.completed ? 'bg-slate-50/50' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleTodo(todo.id, todo.completed)}
|
|
||||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
|
||||||
todo.completed
|
|
||||||
? 'bg-indigo-500 border-indigo-500'
|
|
||||||
: 'border-slate-300 hover:border-indigo-500 bg-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.completed && (
|
|
||||||
<svg
|
|
||||||
className="w-3.5 h-3.5 text-white"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={3}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p
|
|
||||||
className={`text-lg transition-all duration-200 truncate ${
|
|
||||||
todo.completed
|
|
||||||
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
|
|
||||||
: 'text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{todo.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
|
|
||||||
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
|
|
||||||
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteTodo(todo.id)}
|
|
||||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-5 h-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
66
apps/server/src/server/api/contracts/crypto.contract.ts
Normal file
66
apps/server/src/server/api/contracts/crypto.contract.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const encryptDeviceInfo = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
deviceId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
encrypted: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const decryptTask = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
deviceId: z.string().min(1),
|
||||||
|
encryptedData: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
taskId: z.string(),
|
||||||
|
enterpriseId: z.string(),
|
||||||
|
orgName: z.string(),
|
||||||
|
inspectionId: z.string(),
|
||||||
|
inspectionPerson: z.string(),
|
||||||
|
issuedAt: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const encryptSummary = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
deviceId: z.string().min(1),
|
||||||
|
taskId: z.string().min(1),
|
||||||
|
enterpriseId: z.string().min(1),
|
||||||
|
inspectionId: z.string().min(1),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
qrContent: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const signAndPackReport = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
deviceId: z.string().min(1),
|
||||||
|
taskId: z.string().min(1),
|
||||||
|
enterpriseId: z.string().min(1),
|
||||||
|
inspectionId: z.string().min(1),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
rawZipBase64: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
deviceSignature: z.string(),
|
||||||
|
signedZipBase64: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
30
apps/server/src/server/api/contracts/device.contract.ts
Normal file
30
apps/server/src/server/api/contracts/device.contract.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const deviceOutput = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
licence: z.string(),
|
||||||
|
fingerprint: z.string(),
|
||||||
|
platformPublicKey: z.string(),
|
||||||
|
pgpPublicKey: z.string().nullable(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const register = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
licence: z.string().min(1),
|
||||||
|
platformPublicKey: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(deviceOutput)
|
||||||
|
|
||||||
|
export const get = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
licence: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(deviceOutput)
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import * as todo from './todo.contract'
|
import * as crypto from './crypto.contract'
|
||||||
|
import * as device from './device.contract'
|
||||||
|
import * as task from './task.contract'
|
||||||
|
|
||||||
export const contract = {
|
export const contract = {
|
||||||
todo,
|
device,
|
||||||
|
crypto,
|
||||||
|
task,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Contract = typeof contract
|
export type Contract = typeof contract
|
||||||
|
|||||||
47
apps/server/src/server/api/contracts/task.contract.ts
Normal file
47
apps/server/src/server/api/contracts/task.contract.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { oc } from '@orpc/contract'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const taskOutput = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
deviceId: z.string(),
|
||||||
|
taskId: z.string(),
|
||||||
|
enterpriseId: z.string().nullable(),
|
||||||
|
orgName: z.string().nullable(),
|
||||||
|
inspectionId: z.string().nullable(),
|
||||||
|
inspectionPerson: z.string().nullable(),
|
||||||
|
issuedAt: z.date().nullable(),
|
||||||
|
status: z.enum(['pending', 'in_progress', 'done']),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const save = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
deviceId: z.string().min(1),
|
||||||
|
taskId: z.string().min(1),
|
||||||
|
enterpriseId: z.string().optional(),
|
||||||
|
orgName: z.string().optional(),
|
||||||
|
inspectionId: z.string().optional(),
|
||||||
|
inspectionPerson: z.string().optional(),
|
||||||
|
issuedAt: z.number().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(taskOutput)
|
||||||
|
|
||||||
|
export const list = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
deviceId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.array(taskOutput))
|
||||||
|
|
||||||
|
export const updateStatus = oc
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
status: z.enum(['pending', 'in_progress', 'done']),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(taskOutput)
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { oc } from '@orpc/contract'
|
|
||||||
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { generatedFieldKeys } from '@/server/db/fields'
|
|
||||||
import { todoTable } from '@/server/db/schema'
|
|
||||||
|
|
||||||
const selectSchema = createSelectSchema(todoTable)
|
|
||||||
|
|
||||||
const insertSchema = createInsertSchema(todoTable).omit(generatedFieldKeys)
|
|
||||||
|
|
||||||
const updateSchema = createUpdateSchema(todoTable).omit(generatedFieldKeys)
|
|
||||||
|
|
||||||
export const list = oc.input(z.void()).output(z.array(selectSchema))
|
|
||||||
|
|
||||||
export const create = oc.input(insertSchema).output(selectSchema)
|
|
||||||
|
|
||||||
export const update = oc
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
data: updateSchema,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(selectSchema)
|
|
||||||
|
|
||||||
export const remove = oc
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(z.void())
|
|
||||||
306
apps/server/src/server/api/routers/crypto.router.ts
Normal file
306
apps/server/src/server/api/routers/crypto.router.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import {
|
||||||
|
aesGcmDecrypt,
|
||||||
|
aesGcmEncrypt,
|
||||||
|
hkdfSha256,
|
||||||
|
hmacSha256Base64,
|
||||||
|
pgpSignDetached,
|
||||||
|
rsaOaepEncrypt,
|
||||||
|
sha256,
|
||||||
|
sha256Hex,
|
||||||
|
} from '@furtherverse/crypto'
|
||||||
|
import { ORPCError } from '@orpc/server'
|
||||||
|
import type { JSZipObject } from 'jszip'
|
||||||
|
import JSZip from 'jszip'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db } from '../middlewares'
|
||||||
|
import { os } from '../server'
|
||||||
|
|
||||||
|
interface DeviceRow {
|
||||||
|
id: string
|
||||||
|
licence: string
|
||||||
|
fingerprint: string
|
||||||
|
platformPublicKey: string
|
||||||
|
pgpPrivateKey: string | null
|
||||||
|
pgpPublicKey: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportFiles {
|
||||||
|
assets: Uint8Array
|
||||||
|
vulnerabilities: Uint8Array
|
||||||
|
weakPasswords: Uint8Array
|
||||||
|
reportHtml: Uint8Array
|
||||||
|
reportHtmlName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RAW_ZIP_BYTES = 50 * 1024 * 1024
|
||||||
|
const MAX_SINGLE_FILE_BYTES = 20 * 1024 * 1024
|
||||||
|
const MAX_TOTAL_UNCOMPRESSED_BYTES = 60 * 1024 * 1024
|
||||||
|
const MAX_ZIP_ENTRIES = 32
|
||||||
|
|
||||||
|
const taskPayloadSchema = z.object({
|
||||||
|
taskId: z.string().min(1),
|
||||||
|
enterpriseId: z.string().min(1),
|
||||||
|
orgName: z.string().min(1),
|
||||||
|
inspectionId: z.string().min(1),
|
||||||
|
inspectionPerson: z.string().min(1),
|
||||||
|
issuedAt: z.number(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizePath = (name: string): string => name.replaceAll('\\', '/')
|
||||||
|
|
||||||
|
const isUnsafePath = (name: string): boolean => {
|
||||||
|
const normalized = normalizePath(name)
|
||||||
|
const segments = normalized.split('/')
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalized.startsWith('/') ||
|
||||||
|
normalized.includes('\u0000') ||
|
||||||
|
segments.some((segment) => segment === '..' || segment.trim().length === 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBaseName = (name: string): string => {
|
||||||
|
const normalized = normalizePath(name)
|
||||||
|
const parts = normalized.split('/')
|
||||||
|
return parts.at(-1) ?? normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRequiredReportFiles = async (rawZip: JSZip): Promise<ReportFiles> => {
|
||||||
|
let assets: Uint8Array | null = null
|
||||||
|
let vulnerabilities: Uint8Array | null = null
|
||||||
|
let weakPasswords: Uint8Array | null = null
|
||||||
|
let reportHtml: Uint8Array | null = null
|
||||||
|
let reportHtmlName: string | null = null
|
||||||
|
|
||||||
|
const entries = Object.values(rawZip.files) as JSZipObject[]
|
||||||
|
|
||||||
|
if (entries.length > MAX_ZIP_ENTRIES) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: `Zip contains too many entries: ${entries.length}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalUncompressedBytes = 0
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.dir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnsafePath(entry.name)) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: `Zip contains unsafe entry path: ${entry.name}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await entry.async('uint8array')
|
||||||
|
if (content.byteLength > MAX_SINGLE_FILE_BYTES) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: `Zip entry too large: ${entry.name}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
totalUncompressedBytes += content.byteLength
|
||||||
|
if (totalUncompressedBytes > MAX_TOTAL_UNCOMPRESSED_BYTES) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: 'Zip total uncompressed content exceeds max size limit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = getBaseName(entry.name)
|
||||||
|
const lowerFileName = fileName.toLowerCase()
|
||||||
|
|
||||||
|
if (lowerFileName === 'assets.json') {
|
||||||
|
if (assets) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate assets.json' })
|
||||||
|
}
|
||||||
|
assets = content
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lowerFileName === 'vulnerabilities.json') {
|
||||||
|
if (vulnerabilities) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate vulnerabilities.json' })
|
||||||
|
}
|
||||||
|
vulnerabilities = content
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lowerFileName === 'weakpasswords.json') {
|
||||||
|
if (weakPasswords) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', { message: 'Zip contains duplicate weakPasswords.json' })
|
||||||
|
}
|
||||||
|
weakPasswords = content
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (fileName.includes('漏洞评估报告') && lowerFileName.endsWith('.html')) {
|
||||||
|
if (reportHtml) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: 'Zip contains multiple 漏洞评估报告*.html files',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reportHtml = content
|
||||||
|
reportHtmlName = fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assets || !vulnerabilities || !weakPasswords || !reportHtml || !reportHtmlName) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message:
|
||||||
|
'Zip missing required files. Required: assets.json, vulnerabilities.json, weakPasswords.json, and 漏洞评估报告*.html',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
assets,
|
||||||
|
vulnerabilities,
|
||||||
|
weakPasswords,
|
||||||
|
reportHtml,
|
||||||
|
reportHtmlName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDevice = async (
|
||||||
|
context: {
|
||||||
|
db: { query: { deviceTable: { findFirst: (args: { where: { id: string } }) => Promise<DeviceRow | undefined> } } }
|
||||||
|
},
|
||||||
|
deviceId: string,
|
||||||
|
): Promise<DeviceRow> => {
|
||||||
|
const device = await context.db.query.deviceTable.findFirst({
|
||||||
|
where: { id: deviceId },
|
||||||
|
})
|
||||||
|
if (!device) {
|
||||||
|
throw new ORPCError('NOT_FOUND', { message: 'Device not found' })
|
||||||
|
}
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptDeviceInfo = os.crypto.encryptDeviceInfo.use(db).handler(async ({ context, input }) => {
|
||||||
|
const device = await getDevice(context, input.deviceId)
|
||||||
|
|
||||||
|
const deviceInfoJson = JSON.stringify({
|
||||||
|
licence: device.licence,
|
||||||
|
fingerprint: device.fingerprint,
|
||||||
|
})
|
||||||
|
|
||||||
|
const encrypted = rsaOaepEncrypt(deviceInfoJson, device.platformPublicKey)
|
||||||
|
|
||||||
|
return { encrypted }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const decryptTask = os.crypto.decryptTask.use(db).handler(async ({ context, input }) => {
|
||||||
|
const device = await getDevice(context, input.deviceId)
|
||||||
|
|
||||||
|
const key = sha256(device.licence + device.fingerprint)
|
||||||
|
const decryptedJson = aesGcmDecrypt(input.encryptedData, key)
|
||||||
|
const taskData = taskPayloadSchema.parse(JSON.parse(decryptedJson))
|
||||||
|
|
||||||
|
return taskData
|
||||||
|
})
|
||||||
|
|
||||||
|
export const encryptSummary = os.crypto.encryptSummary.use(db).handler(async ({ context, input }) => {
|
||||||
|
const device = await getDevice(context, input.deviceId)
|
||||||
|
|
||||||
|
const ikm = device.licence + device.fingerprint
|
||||||
|
const aesKey = hkdfSha256(ikm, input.taskId, 'inspection_report_encryption')
|
||||||
|
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const plaintextJson = JSON.stringify({
|
||||||
|
enterpriseId: input.enterpriseId,
|
||||||
|
inspectionId: input.inspectionId,
|
||||||
|
summary: input.summary,
|
||||||
|
timestamp,
|
||||||
|
})
|
||||||
|
|
||||||
|
const encrypted = aesGcmEncrypt(plaintextJson, aesKey)
|
||||||
|
|
||||||
|
const qrContent = JSON.stringify({
|
||||||
|
taskId: input.taskId,
|
||||||
|
encrypted,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { qrContent }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const signAndPackReport = os.crypto.signAndPackReport.use(db).handler(async ({ context, input }) => {
|
||||||
|
const device = await getDevice(context, input.deviceId)
|
||||||
|
const rawZipBytes = Buffer.from(input.rawZipBase64, 'base64')
|
||||||
|
|
||||||
|
if (rawZipBytes.byteLength === 0 || rawZipBytes.byteLength > MAX_RAW_ZIP_BYTES) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: 'rawZipBase64 is empty or exceeds max size limit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawZip = await JSZip.loadAsync(rawZipBytes, {
|
||||||
|
checkCRC32: true,
|
||||||
|
}).catch(() => {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: 'rawZipBase64 is not a valid zip file',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const reportFiles = await getRequiredReportFiles(rawZip)
|
||||||
|
|
||||||
|
const ikm = device.licence + device.fingerprint
|
||||||
|
const signingKey = hkdfSha256(ikm, 'AUTH_V3_SALT', 'device_report_signature')
|
||||||
|
|
||||||
|
const assetsHash = sha256Hex(Buffer.from(reportFiles.assets))
|
||||||
|
const vulnerabilitiesHash = sha256Hex(Buffer.from(reportFiles.vulnerabilities))
|
||||||
|
const weakPasswordsHash = sha256Hex(Buffer.from(reportFiles.weakPasswords))
|
||||||
|
const reportHtmlHash = sha256Hex(Buffer.from(reportFiles.reportHtml))
|
||||||
|
|
||||||
|
const signPayload =
|
||||||
|
input.taskId + input.inspectionId + assetsHash + vulnerabilitiesHash + weakPasswordsHash + reportHtmlHash
|
||||||
|
|
||||||
|
const deviceSignature = hmacSha256Base64(signingKey, signPayload)
|
||||||
|
|
||||||
|
if (!device.pgpPrivateKey) {
|
||||||
|
throw new ORPCError('PRECONDITION_FAILED', {
|
||||||
|
message: 'Device does not have a PGP key pair. Re-register the device.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryObject = {
|
||||||
|
enterpriseId: input.enterpriseId,
|
||||||
|
inspectionId: input.inspectionId,
|
||||||
|
taskId: input.taskId,
|
||||||
|
licence: device.licence,
|
||||||
|
fingerprint: device.fingerprint,
|
||||||
|
deviceSignature,
|
||||||
|
summary: input.summary,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryBytes = Buffer.from(JSON.stringify(summaryObject), 'utf-8')
|
||||||
|
|
||||||
|
const manifestObject = {
|
||||||
|
files: {
|
||||||
|
'summary.json': sha256Hex(summaryBytes),
|
||||||
|
'assets.json': assetsHash,
|
||||||
|
'vulnerabilities.json': vulnerabilitiesHash,
|
||||||
|
'weakPasswords.json': weakPasswordsHash,
|
||||||
|
[reportFiles.reportHtmlName]: reportHtmlHash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestBytes = Buffer.from(JSON.stringify(manifestObject, null, 2), 'utf-8')
|
||||||
|
const signatureAsc = await pgpSignDetached(manifestBytes, device.pgpPrivateKey)
|
||||||
|
|
||||||
|
const signedZip = new JSZip()
|
||||||
|
signedZip.file('summary.json', summaryBytes)
|
||||||
|
signedZip.file('assets.json', reportFiles.assets)
|
||||||
|
signedZip.file('vulnerabilities.json', reportFiles.vulnerabilities)
|
||||||
|
signedZip.file('weakPasswords.json', reportFiles.weakPasswords)
|
||||||
|
signedZip.file(reportFiles.reportHtmlName, reportFiles.reportHtml)
|
||||||
|
signedZip.file('META-INF/manifest.json', manifestBytes)
|
||||||
|
signedZip.file('META-INF/signature.asc', signatureAsc)
|
||||||
|
|
||||||
|
const signedZipBytes = await signedZip.generateAsync({
|
||||||
|
type: 'uint8array',
|
||||||
|
compression: 'DEFLATE',
|
||||||
|
compressionOptions: { level: 9 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const signedZipBase64 = Buffer.from(signedZipBytes).toString('base64')
|
||||||
|
|
||||||
|
return { deviceSignature, signedZipBase64 }
|
||||||
|
})
|
||||||
54
apps/server/src/server/api/routers/device.router.ts
Normal file
54
apps/server/src/server/api/routers/device.router.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { generatePgpKeyPair } from '@furtherverse/crypto'
|
||||||
|
import { ORPCError } from '@orpc/server'
|
||||||
|
import { deviceTable } from '@/server/db/schema'
|
||||||
|
import { computeDeviceFingerprint } from '@/server/device-fingerprint'
|
||||||
|
import { db } from '../middlewares'
|
||||||
|
import { os } from '../server'
|
||||||
|
|
||||||
|
export const register = os.device.register.use(db).handler(async ({ context, input }) => {
|
||||||
|
const existing = await context.db.query.deviceTable.findFirst({
|
||||||
|
where: { licence: input.licence },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ORPCError('CONFLICT', {
|
||||||
|
message: `Device with licence "${input.licence}" already registered`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pgpKeys = await generatePgpKeyPair(input.licence, `${input.licence}@ux.local`)
|
||||||
|
const fingerprint = computeDeviceFingerprint()
|
||||||
|
|
||||||
|
const rows = await context.db
|
||||||
|
.insert(deviceTable)
|
||||||
|
.values({
|
||||||
|
licence: input.licence,
|
||||||
|
fingerprint,
|
||||||
|
platformPublicKey: input.platformPublicKey,
|
||||||
|
pgpPrivateKey: pgpKeys.privateKey,
|
||||||
|
pgpPublicKey: pgpKeys.publicKey,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return rows[0] as (typeof rows)[number]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const get = os.device.get.use(db).handler(async ({ context, input }) => {
|
||||||
|
if (!input.id && !input.licence) {
|
||||||
|
throw new ORPCError('BAD_REQUEST', {
|
||||||
|
message: 'Either id or licence must be provided',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = input.id
|
||||||
|
? await context.db.query.deviceTable.findFirst({ where: { id: input.id } })
|
||||||
|
: await context.db.query.deviceTable.findFirst({ where: { licence: input.licence } })
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new ORPCError('NOT_FOUND', {
|
||||||
|
message: 'Device not found',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return device
|
||||||
|
})
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { os } from '../server'
|
import { os } from '../server'
|
||||||
import * as todo from './todo.router'
|
import * as crypto from './crypto.router'
|
||||||
|
import * as device from './device.router'
|
||||||
|
import * as task from './task.router'
|
||||||
|
|
||||||
export const router = os.router({
|
export const router = os.router({
|
||||||
todo,
|
device,
|
||||||
|
crypto,
|
||||||
|
task,
|
||||||
})
|
})
|
||||||
|
|||||||
44
apps/server/src/server/api/routers/task.router.ts
Normal file
44
apps/server/src/server/api/routers/task.router.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { ORPCError } from '@orpc/server'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { taskTable } from '@/server/db/schema'
|
||||||
|
import { db } from '../middlewares'
|
||||||
|
import { os } from '../server'
|
||||||
|
|
||||||
|
export const save = os.task.save.use(db).handler(async ({ context, input }) => {
|
||||||
|
const rows = await context.db
|
||||||
|
.insert(taskTable)
|
||||||
|
.values({
|
||||||
|
deviceId: input.deviceId,
|
||||||
|
taskId: input.taskId,
|
||||||
|
enterpriseId: input.enterpriseId,
|
||||||
|
orgName: input.orgName,
|
||||||
|
inspectionId: input.inspectionId,
|
||||||
|
inspectionPerson: input.inspectionPerson,
|
||||||
|
issuedAt: input.issuedAt ? new Date(input.issuedAt) : null,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return rows[0] as (typeof rows)[number]
|
||||||
|
})
|
||||||
|
|
||||||
|
export const list = os.task.list.use(db).handler(async ({ context, input }) => {
|
||||||
|
return await context.db.query.taskTable.findMany({
|
||||||
|
where: { deviceId: input.deviceId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateStatus = os.task.updateStatus.use(db).handler(async ({ context, input }) => {
|
||||||
|
const rows = await context.db
|
||||||
|
.update(taskTable)
|
||||||
|
.set({ status: input.status })
|
||||||
|
.where(eq(taskTable.id, input.id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
const updated = rows[0]
|
||||||
|
if (!updated) {
|
||||||
|
throw new ORPCError('NOT_FOUND', { message: 'Task not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { ORPCError } from '@orpc/server'
|
|
||||||
import { eq } from 'drizzle-orm'
|
|
||||||
import { todoTable } from '@/server/db/schema'
|
|
||||||
import { db } from '../middlewares'
|
|
||||||
import { os } from '../server'
|
|
||||||
|
|
||||||
export const list = os.todo.list.use(db).handler(async ({ context }) => {
|
|
||||||
const todos = await context.db.query.todoTable.findMany({
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
})
|
|
||||||
return todos
|
|
||||||
})
|
|
||||||
|
|
||||||
export const create = os.todo.create.use(db).handler(async ({ context, input }) => {
|
|
||||||
const [newTodo] = await context.db.insert(todoTable).values(input).returning()
|
|
||||||
|
|
||||||
if (!newTodo) {
|
|
||||||
throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create todo' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return newTodo
|
|
||||||
})
|
|
||||||
|
|
||||||
export const update = os.todo.update.use(db).handler(async ({ context, input }) => {
|
|
||||||
const [updatedTodo] = await context.db.update(todoTable).set(input.data).where(eq(todoTable.id, input.id)).returning()
|
|
||||||
|
|
||||||
if (!updatedTodo) {
|
|
||||||
throw new ORPCError('NOT_FOUND')
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedTodo
|
|
||||||
})
|
|
||||||
|
|
||||||
export const remove = os.todo.remove.use(db).handler(async ({ context, input }) => {
|
|
||||||
const [deleted] = await context.db.delete(todoTable).where(eq(todoTable.id, input.id)).returning({ id: todoTable.id })
|
|
||||||
|
|
||||||
if (!deleted) {
|
|
||||||
throw new ORPCError('NOT_FOUND')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
39
apps/server/src/server/device-fingerprint.ts
Normal file
39
apps/server/src/server/device-fingerprint.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { arch, cpus, networkInterfaces, platform, release, totalmem } from 'node:os'
|
||||||
|
import { sha256Hex } from '@furtherverse/crypto'
|
||||||
|
|
||||||
|
const readMachineId = (): string => {
|
||||||
|
const candidates = ['/etc/machine-id', '/var/lib/dbus/machine-id']
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
try {
|
||||||
|
const value = readFileSync(path, 'utf-8').trim()
|
||||||
|
if (value.length > 0) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectMacAddresses = (): string[] => {
|
||||||
|
const interfaces = networkInterfaces()
|
||||||
|
|
||||||
|
return Object.values(interfaces)
|
||||||
|
.flatMap((group) => group ?? [])
|
||||||
|
.filter((item) => item.mac && item.mac !== '00:00:00:00:00:00' && !item.internal)
|
||||||
|
.map((item) => item.mac)
|
||||||
|
.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const computeDeviceFingerprint = (): string => {
|
||||||
|
const machineId = readMachineId()
|
||||||
|
const firstCpuModel = cpus()[0]?.model ?? 'unknown'
|
||||||
|
const macs = collectMacAddresses().join(',')
|
||||||
|
|
||||||
|
const source = [machineId, platform(), release(), arch(), String(totalmem()), firstCpuModel, macs].join('|')
|
||||||
|
const hash = sha256Hex(source)
|
||||||
|
|
||||||
|
return `FP-${hash.slice(0, 16)}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user