From 4e7c4e1aa5c353c1677eaa96ffd76385b541a217 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Thu, 5 Mar 2026 16:24:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=AE=9E=E7=8E=B0=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=8E=88=E6=9D=83=E4=B8=8E=E6=8A=A5=E5=91=8A=20ZIP=20?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E6=89=93=E5=8C=85=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/client/orpc.ts | 28 +- apps/server/src/routes/index.tsx | 194 +---------- .../server/api/contracts/crypto.contract.ts | 66 ++++ .../server/api/contracts/device.contract.ts | 30 ++ apps/server/src/server/api/contracts/index.ts | 8 +- .../src/server/api/contracts/task.contract.ts | 47 +++ .../src/server/api/contracts/todo.contract.ts | 32 -- .../src/server/api/routers/crypto.router.ts | 306 ++++++++++++++++++ .../src/server/api/routers/device.router.ts | 54 ++++ apps/server/src/server/api/routers/index.ts | 8 +- .../src/server/api/routers/task.router.ts | 44 +++ .../src/server/api/routers/todo.router.ts | 40 --- apps/server/src/server/device-fingerprint.ts | 39 +++ 13 files changed, 610 insertions(+), 286 deletions(-) create mode 100644 apps/server/src/server/api/contracts/crypto.contract.ts create mode 100644 apps/server/src/server/api/contracts/device.contract.ts create mode 100644 apps/server/src/server/api/contracts/task.contract.ts delete mode 100644 apps/server/src/server/api/contracts/todo.contract.ts create mode 100644 apps/server/src/server/api/routers/crypto.router.ts create mode 100644 apps/server/src/server/api/routers/device.router.ts create mode 100644 apps/server/src/server/api/routers/task.router.ts delete mode 100644 apps/server/src/server/api/routers/todo.router.ts create mode 100644 apps/server/src/server/device-fingerprint.ts diff --git a/apps/server/src/client/orpc.ts b/apps/server/src/client/orpc.ts index 6b9abab..7bea292 100644 --- a/apps/server/src/client/orpc.ts +++ b/apps/server/src/client/orpc.ts @@ -24,30 +24,4 @@ const getORPCClient = createIsomorphicFn() const client: RouterClient = getORPCClient() -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() }) - }, - }, - }, - }, - }, -}) +export const orpc = createTanstackQueryUtils(client) diff --git a/apps/server/src/routes/index.tsx b/apps/server/src/routes/index.tsx index fa57a89..43d11ac 100644 --- a/apps/server/src/routes/index.tsx +++ b/apps/server/src/routes/index.tsx @@ -1,192 +1,20 @@ -import { useMutation, useSuspenseQuery } from '@tanstack/react-query' 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('/')({ - component: Todos, - loader: async ({ context }) => { - await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions()) - }, + component: Home, }) -function Todos() { - 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 = (e) => { - e.preventDefault() - if (newTodoTitle.trim()) { - createMutation.mutate({ title: newTodoTitle.trim() }) - setNewTodoTitle('') - } - } - - const handleInputChange: ChangeEventHandler = (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 - +function Home() { return ( -
-
- {/* Header */} -
-
-

我的待办

-

保持专注,逐个击破

-
-
-
- {completedCount} - /{totalCount} -
-
已完成
-
-
- - {/* Add Todo Form */} -
-
- - -
-
- - {/* Progress Bar (Only visible when there are tasks) */} - {totalCount > 0 && ( -
-
-
- )} - - {/* Todo List */} -
- {todos.length === 0 ? ( -
-
- -
-

没有待办事项

-

输入上方内容添加您的第一个任务

-
- ) : ( - todos.map((todo) => ( -
- - -
-

- {todo.title} -

-
- -
- - {new Date(todo.createdAt).toLocaleDateString('zh-CN')} - - -
-
- )) - )} -
+
+
+

UX Server

+

+ API:  + + /api + +

) diff --git a/apps/server/src/server/api/contracts/crypto.contract.ts b/apps/server/src/server/api/contracts/crypto.contract.ts new file mode 100644 index 0000000..785f926 --- /dev/null +++ b/apps/server/src/server/api/contracts/crypto.contract.ts @@ -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(), + }), + ) diff --git a/apps/server/src/server/api/contracts/device.contract.ts b/apps/server/src/server/api/contracts/device.contract.ts new file mode 100644 index 0000000..def7a63 --- /dev/null +++ b/apps/server/src/server/api/contracts/device.contract.ts @@ -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) diff --git a/apps/server/src/server/api/contracts/index.ts b/apps/server/src/server/api/contracts/index.ts index 669cfd5..c46ca45 100644 --- a/apps/server/src/server/api/contracts/index.ts +++ b/apps/server/src/server/api/contracts/index.ts @@ -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 = { - todo, + device, + crypto, + task, } export type Contract = typeof contract diff --git a/apps/server/src/server/api/contracts/task.contract.ts b/apps/server/src/server/api/contracts/task.contract.ts new file mode 100644 index 0000000..edf5cf4 --- /dev/null +++ b/apps/server/src/server/api/contracts/task.contract.ts @@ -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) diff --git a/apps/server/src/server/api/contracts/todo.contract.ts b/apps/server/src/server/api/contracts/todo.contract.ts deleted file mode 100644 index 8aed5cc..0000000 --- a/apps/server/src/server/api/contracts/todo.contract.ts +++ /dev/null @@ -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()) diff --git a/apps/server/src/server/api/routers/crypto.router.ts b/apps/server/src/server/api/routers/crypto.router.ts new file mode 100644 index 0000000..071b6c4 --- /dev/null +++ b/apps/server/src/server/api/routers/crypto.router.ts @@ -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 => { + 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 } } } + }, + deviceId: string, +): Promise => { + 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 } +}) diff --git a/apps/server/src/server/api/routers/device.router.ts b/apps/server/src/server/api/routers/device.router.ts new file mode 100644 index 0000000..6f9633b --- /dev/null +++ b/apps/server/src/server/api/routers/device.router.ts @@ -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 +}) diff --git a/apps/server/src/server/api/routers/index.ts b/apps/server/src/server/api/routers/index.ts index 02a11fe..a93b77f 100644 --- a/apps/server/src/server/api/routers/index.ts +++ b/apps/server/src/server/api/routers/index.ts @@ -1,6 +1,10 @@ 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({ - todo, + device, + crypto, + task, }) diff --git a/apps/server/src/server/api/routers/task.router.ts b/apps/server/src/server/api/routers/task.router.ts new file mode 100644 index 0000000..eb858a8 --- /dev/null +++ b/apps/server/src/server/api/routers/task.router.ts @@ -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 +}) diff --git a/apps/server/src/server/api/routers/todo.router.ts b/apps/server/src/server/api/routers/todo.router.ts deleted file mode 100644 index 40c988f..0000000 --- a/apps/server/src/server/api/routers/todo.router.ts +++ /dev/null @@ -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') - } -}) diff --git a/apps/server/src/server/device-fingerprint.ts b/apps/server/src/server/device-fingerprint.ts new file mode 100644 index 0000000..d039450 --- /dev/null +++ b/apps/server/src/server/device-fingerprint.ts @@ -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)}` +}