From af4f6a3eea948de860385e677d8ab002cab4acdc Mon Sep 17 00:00:00 2001 From: NurAlan Date: Wed, 8 Oct 2025 22:13:24 +0700 Subject: [PATCH] mockup API device --- .gitea/workflows/develop.yaml | 70 ------------------- .gitignore | 1 - setup-admin.ts | 73 -------------------- src/app/router.ts | 10 +-- src/helpers/errors/DeviceErrorResponseMap.ts | 18 +++++ src/helpers/errors/index.ts | 1 + src/middlewares/deviceGuard.ts | 39 +++++++++++ src/modules/device/commands/service.ts | 34 +++++++++ src/modules/device/router.ts | 39 +++++++++++ src/modules/device/schema.ts | 52 ++++++++++++++ src/plugins/swagger.ts | 22 ++++-- 11 files changed, 207 insertions(+), 152 deletions(-) delete mode 100644 .gitea/workflows/develop.yaml delete mode 100644 setup-admin.ts create mode 100644 src/helpers/errors/DeviceErrorResponseMap.ts create mode 100644 src/middlewares/deviceGuard.ts create mode 100644 src/modules/device/commands/service.ts create mode 100644 src/modules/device/router.ts create mode 100644 src/modules/device/schema.ts diff --git a/.gitea/workflows/develop.yaml b/.gitea/workflows/develop.yaml deleted file mode 100644 index 1ce394b..0000000 --- a/.gitea/workflows/develop.yaml +++ /dev/null @@ -1,70 +0,0 @@ -name: CI - -on: - push: - branches: - - 'develop' - -jobs: - develop: - runs-on: ubuntu-latest - env: - GITEA_ACTOR: ${{ gitea.actor }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - registry: ${{ vars.REGISTRY_URL }} - username: ${{ secrets.AGENT_USER }} - password: ${{ secrets.AGENT_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile - push: true - tags: ${{ vars.REPOSITORY_URL }}/${{ gitea.repository }}:develop - - name: Cleanup - run: | - docker rmi ${{ vars.REPOSITORY_URL }}/${{ gitea.repository }}:develop - - name: Deploy portainer - uses: newarifrh/portainer-service-webhook@v1 - with: - webhook_url: ${{ secrets.WEBHOOK_URL }} - - name: Set Discord Content - run: | - DISCORD_ID="${{ vars[format('DISCORD_ID_{0}', env.GITEA_ACTOR)] }}" - echo "DISCORD_ID: $DISCORD_ID" - - # - name: Notify Discord Success - # uses: sarisia/actions-status-discord@v1 - # if: success() - # with: - # webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} - # status: ${{ job.status }} - # title: Build and Deploy ${{ job.status }} - # content: "test the app" - # description: Build and deploy ${{ job.status }} - # color: 0x00FF00 - # username: MBG-AGENT - # nodetail: true - # avatar_url: https://gitea.tepibojonegoro.com/avatars/ab1b485ae0fcd8b618bbc36158528b64f23cd9c472442c2e5ca54a63771c1411?size=200 - - # - name: Notify Discord Failure - # uses: sarisia/actions-status-discord@v1 - # if: failure() - # with: - # webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} - # status: ${{ job.status }} - # title: Build and Deploy ${{ job.status }} - # content: "Hey <@${{ env.DISCORD_ID }}> check service failed" - # description: Build and deploy ${{ job.status }} - # color: 0xFF0000 - # nodetail: true - # username: MBG-AGENT - # avatar_url: https://gitea.tepibojonegoro.com/avatars/ab1b485ae0fcd8b618bbc36158528b64f23cd9c472442c2e5ca54a63771c1411?size=200 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1917da3..a136bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,4 @@ yarn-error.log* **/*.log package-lock.json **/*.bun - *.txt \ No newline at end of file diff --git a/setup-admin.ts b/setup-admin.ts deleted file mode 100644 index 03ee98b..0000000 --- a/setup-admin.ts +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bun -import { setTimeout } from 'node:timers/promises' -import { createInterface } from 'readline' -import UserCommandService from './src/modules/user/commands/service' -import { passwordHash } from './src/helpers/utils' - -const rl = createInterface({ - input: process.stdin, - output: process.stdout, -}) - -function question(prompt: string): Promise { - return new Promise((resolve) => { - rl.question(prompt, resolve) - }) -} - -async function main() { - try { - console.log('Setting up admin user...') - - const name = await question('Enter admin name (min 3 characters): ') - if (name.length < 3) { - console.error('Name must be at least 3 characters long.') - process.exit(1) - } - - const emailOrPhone = await question('Enter email or phone number: ') - if (!emailOrPhone) { - console.error('Either email or phone number must be provided.') - process.exit(1) - } - - const password = await question('Enter password (min 8 characters): ') - if (password.length < 8) { - console.error('Password must be at least 8 characters long.') - process.exit(1) - } - - const confirmPassword = await question('Confirm password: ') - if (password !== confirmPassword) { - console.error('Passwords do not match.') - process.exit(1) - } - - // Determine if it's email or phone - const isEmail = emailOrPhone.includes('@') - const userData = { - name, - password: passwordHash(password), - role: 'admin' as const, - status: true, - ...(isEmail ? { email: emailOrPhone } : { phone: emailOrPhone }), - } - - const user = await UserCommandService.createUser(userData) - - console.log('Admin user created successfully!') - console.log(`ID: ${user.id}`) - console.log(`Name: ${user.name}`) - console.log(`Email/Phone: ${user.email || user.phone}`) - console.log(`Role: ${user.role}`) - await setTimeout(3000) - process.exit() - } catch (error) { - console.error('Error creating admin user:', error instanceof Error ? error.message : String(error)) - process.exit(1) - } finally { - rl.close() - } -} - -main() diff --git a/src/app/router.ts b/src/app/router.ts index f41d11d..f529a96 100644 --- a/src/app/router.ts +++ b/src/app/router.ts @@ -1,12 +1,14 @@ import { Elysia } from 'elysia' -import authRouter from '~/modules/auth/router' -import userRouter from '~/modules/user/router' +// import authRouter from '~/modules/auth/router' +// import userRouter from '~/modules/user/router' +import deviceRouter from '~/modules/device/router' export const router = new Elysia({ name: 'codebase.router', }) - .use(authRouter) - .use(userRouter) + // .use(authRouter) + // .use(userRouter) + .use(deviceRouter) .get('/', () => ({ message: 'This service is running as expected.', })) diff --git a/src/helpers/errors/DeviceErrorResponseMap.ts b/src/helpers/errors/DeviceErrorResponseMap.ts new file mode 100644 index 0000000..59291c5 --- /dev/null +++ b/src/helpers/errors/DeviceErrorResponseMap.ts @@ -0,0 +1,18 @@ +export const deviceErrorResponseMap: Record = { + invalid_signature: { + status: 401, + response: { status: 'invalid_signature', message: 'Signature mismatch' }, + }, + invalid_api_key: { + status: 401, + response: { status: 'invalid_api_key', message: 'API Key is not valid' }, + }, + replay_attack_detected: { + status: 403, + response: { status: 'replay_attack_detected', message: 'Timestamp or nonce is invalid/has been reused' }, + }, + invalid_request: { + status: 400, + response: { status: 'invalid_request', message: 'Invalid request format' }, + }, +} diff --git a/src/helpers/errors/index.ts b/src/helpers/errors/index.ts index fe6816a..58b21ce 100644 --- a/src/helpers/errors/index.ts +++ b/src/helpers/errors/index.ts @@ -6,6 +6,7 @@ import { DataAlreadyExistsError } from './DataAlreadyExistsError' export * from './FieldValidationError' export * from './UnauthenticatedError' export * from './DataAlreadyExistsError' +export * from './DeviceErrorResponseMap' export { InternalServerError } from 'elysia' export default { diff --git a/src/middlewares/deviceGuard.ts b/src/middlewares/deviceGuard.ts new file mode 100644 index 0000000..01ad251 --- /dev/null +++ b/src/middlewares/deviceGuard.ts @@ -0,0 +1,39 @@ +// Clock skew window: ±5 minutes +const CLOCK_SKEW_MS = 5 * 60 * 1000 + +export const deviceGuard = () => ({ + beforeHandle(context: unknown) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { headers, set } = context as any + // Validate Content-Type + if (headers['content-type'] !== 'application/json') { + set.status = 400 + throw new Error('invalid_request') + } + + // Validate X-API-Key + const apiKey = headers['x-api-key'] + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') { + set.status = 401 + throw new Error('invalid_api_key') + } + + // Validate X-Timestamp + const timestampStr = headers['x-timestamp'] + if (!timestampStr) { + set.status = 403 + throw new Error('replay_attack_detected') + } + const timestamp = new Date(timestampStr) + if (isNaN(timestamp.getTime()) || timestampStr !== timestamp.toISOString()) { + set.status = 403 + throw new Error('replay_attack_detected') + } + const now = new Date() + const diff = Math.abs(now.getTime() - timestamp.getTime()) + if (diff > CLOCK_SKEW_MS) { + set.status = 403 + throw new Error('replay_attack_detected') + } + }, +}) diff --git a/src/modules/device/commands/service.ts b/src/modules/device/commands/service.ts new file mode 100644 index 0000000..2dae9e8 --- /dev/null +++ b/src/modules/device/commands/service.ts @@ -0,0 +1,34 @@ +import { verify } from '~/helpers/signature' +import { DeviceCommandBody } from '../schema' + +export default abstract class CommandService { + static async sendCommand(body: DeviceCommandBody, headers: Record): Promise<{ + status: 'success' + message: 'Command accepted' + receivedAt: string + }> { + const apiKey = headers['x-api-key'] + if (!apiKey) { + throw new Error('invalid_api_key') + } + + // For now, use apiKey as secretKey since DB validation is skipped + const secretKey = apiKey + + // Remove signature from body for verification + const { signature, ...bodyWithoutSignature } = body + + const isValid = verify(bodyWithoutSignature, signature, secretKey) + if (!isValid) { + throw new Error('invalid_signature') + } + + // TODO: Check nonce for replay attack when DB is implemented + + return { + status: 'success', + message: 'Command accepted', + receivedAt: new Date().toISOString(), + } + } +} diff --git a/src/modules/device/router.ts b/src/modules/device/router.ts new file mode 100644 index 0000000..b8c5b86 --- /dev/null +++ b/src/modules/device/router.ts @@ -0,0 +1,39 @@ +import { Elysia } from 'elysia' +import { deviceGuard } from '~/middlewares/deviceGuard' +import { deviceCommandBody, deviceCommandResponseSchema } from './schema' +import CommandService from './commands/service' +import { deviceErrorResponseMap } from '~/helpers/errors' + +export const router = new Elysia({ + name: 'modules.device', + detail: { tags: ['Device'] }, + prefix: '/device/v1', +}) + .guard(deviceGuard()) + .post('/command', async ({ body, headers, set }) => { + try { + const result = await CommandService.sendCommand(body, headers) + return result + } catch (error) { + if (error instanceof Error && error.message in deviceErrorResponseMap) { + const { status, response } = deviceErrorResponseMap[error.message] + set.status = status + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return response as any + } + set.status = 500 + return { status: 'internal_error', message: 'Server error' } + } + }, { + body: deviceCommandBody, + response: deviceCommandResponseSchema, + detail: { + security: [ + { apiKey: [] }, + { timestamp: [] }, + { signature: [] }, + ], + }, + }) + +export default router diff --git a/src/modules/device/schema.ts b/src/modules/device/schema.ts new file mode 100644 index 0000000..87d87c9 --- /dev/null +++ b/src/modules/device/schema.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' + +// Request body schema +export const deviceCommandBody = z.object({ + merchantName: z.string(), + floorName: z.string(), + unitNumber: z.string(), + deviceName: z.string(), + roomName: z.string(), + deviceType: z.string(), + commandType: z.string(), + towerNumber: z.string(), + timestamp: z.string().datetime(), // ISO8601 + nonce: z.string(), + payload: z.object({ + AC: z.string(), + Temp: z.number(), + }), + signature: z.string(), +}) +export type DeviceCommandBody = z.infer + +// Response schemas +export const deviceCommandResponseSchema = { + 200: z.object({ + status: z.literal('success'), + message: z.literal('Command accepted'), + receivedAt: z.string().datetime(), + }), + 400: z.object({ + status: z.literal('invalid_request'), + message: z.literal('Invalid request format'), + }), + 401: z.union([ + z.object({ + status: z.literal('invalid_signature'), + message: z.literal('Signature mismatch'), + }), + z.object({ + status: z.literal('invalid_api_key'), + message: z.literal('API Key is not valid'), + }), + ]), + 403: z.object({ + status: z.literal('replay_attack_detected'), + message: z.literal('Timestamp or nonce is invalid/has been reused'), + }), + 500: z.object({ + status: z.literal('internal_error'), + message: z.literal('Server error'), + }), +} diff --git a/src/plugins/swagger.ts b/src/plugins/swagger.ts index c68a24b..7bc6e51 100644 --- a/src/plugins/swagger.ts +++ b/src/plugins/swagger.ts @@ -1,6 +1,7 @@ import { openapi } from '@elysiajs/openapi' import { OpenAPIV3 } from 'openapi-types' import { PORT } from '~/config' +import * as z from 'zod' interface OpenApiDocWithTagGroups { 'x-tagGroups': { name: string, tags: string[] } } type OpenApiDocumentation = Omit>, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'> @@ -8,14 +9,16 @@ type OpenApiDocumentation = Omit