mockup API device
This commit is contained in:
@@ -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
|
|
||||||
@@ -40,5 +40,4 @@ yarn-error.log*
|
|||||||
**/*.log
|
**/*.log
|
||||||
package-lock.json
|
package-lock.json
|
||||||
**/*.bun
|
**/*.bun
|
||||||
|
|
||||||
*.txt
|
*.txt
|
||||||
@@ -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<string> {
|
|
||||||
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()
|
|
||||||
+6
-4
@@ -1,12 +1,14 @@
|
|||||||
import { Elysia } from 'elysia'
|
import { Elysia } from 'elysia'
|
||||||
import authRouter from '~/modules/auth/router'
|
// import authRouter from '~/modules/auth/router'
|
||||||
import userRouter from '~/modules/user/router'
|
// import userRouter from '~/modules/user/router'
|
||||||
|
import deviceRouter from '~/modules/device/router'
|
||||||
|
|
||||||
export const router = new Elysia({
|
export const router = new Elysia({
|
||||||
name: 'codebase.router',
|
name: 'codebase.router',
|
||||||
})
|
})
|
||||||
.use(authRouter)
|
// .use(authRouter)
|
||||||
.use(userRouter)
|
// .use(userRouter)
|
||||||
|
.use(deviceRouter)
|
||||||
.get('/', () => ({
|
.get('/', () => ({
|
||||||
message: 'This service is running as expected.',
|
message: 'This service is running as expected.',
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const deviceErrorResponseMap: Record<string, { status: number, response: { status: 'invalid_signature' | 'invalid_api_key' | 'replay_attack_detected' | 'invalid_request' | 'internal_error', message: string } }> = {
|
||||||
|
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' },
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { DataAlreadyExistsError } from './DataAlreadyExistsError'
|
|||||||
export * from './FieldValidationError'
|
export * from './FieldValidationError'
|
||||||
export * from './UnauthenticatedError'
|
export * from './UnauthenticatedError'
|
||||||
export * from './DataAlreadyExistsError'
|
export * from './DataAlreadyExistsError'
|
||||||
|
export * from './DeviceErrorResponseMap'
|
||||||
export { InternalServerError } from 'elysia'
|
export { InternalServerError } from 'elysia'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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<string, string | undefined>): 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<typeof deviceCommandBody>
|
||||||
|
|
||||||
|
// 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'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
+18
-4
@@ -1,6 +1,7 @@
|
|||||||
import { openapi } from '@elysiajs/openapi'
|
import { openapi } from '@elysiajs/openapi'
|
||||||
import { OpenAPIV3 } from 'openapi-types'
|
import { OpenAPIV3 } from 'openapi-types'
|
||||||
import { PORT } from '~/config'
|
import { PORT } from '~/config'
|
||||||
|
import * as z from 'zod'
|
||||||
|
|
||||||
interface OpenApiDocWithTagGroups { 'x-tagGroups': { name: string, tags: string[] } }
|
interface OpenApiDocWithTagGroups { 'x-tagGroups': { name: string, tags: string[] } }
|
||||||
type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGroups>>, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'>
|
type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGroups>>, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'>
|
||||||
@@ -8,14 +9,16 @@ type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGro
|
|||||||
export const swaggerPlugin = openapi({
|
export const swaggerPlugin = openapi({
|
||||||
path: '/swagger',
|
path: '/swagger',
|
||||||
provider: 'scalar',
|
provider: 'scalar',
|
||||||
|
mapJsonSchema: {
|
||||||
|
zod: z.toJSONSchema,
|
||||||
|
},
|
||||||
documentation: {
|
documentation: {
|
||||||
'info': {
|
'info': {
|
||||||
title: 'Backend node Skyrain API Documentation',
|
title: 'Backend node Skyrain API Documentation',
|
||||||
version: 'v1.0.0',
|
version: 'v1.0.0',
|
||||||
},
|
},
|
||||||
'tags': [
|
'tags': [
|
||||||
{ name: 'Authentication', description: 'Authentication API' },
|
{ name: 'Device', description: 'Device API' },
|
||||||
{ name: 'Users', description: 'User API' },
|
|
||||||
],
|
],
|
||||||
'servers': [
|
'servers': [
|
||||||
{
|
{
|
||||||
@@ -33,7 +36,7 @@ export const swaggerPlugin = openapi({
|
|||||||
],
|
],
|
||||||
// --- Grouping di atas tag ---
|
// --- Grouping di atas tag ---
|
||||||
'x-tagGroups': [
|
'x-tagGroups': [
|
||||||
{ name: 'Auth & IAM', tags: ['Authentication', 'Users'] },
|
{ name: 'Device', tags: ['Device'] },
|
||||||
],
|
],
|
||||||
'components': {
|
'components': {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
@@ -46,8 +49,19 @@ export const swaggerPlugin = openapi({
|
|||||||
scheme: 'bearer',
|
scheme: 'bearer',
|
||||||
bearerFormat: 'JWT',
|
bearerFormat: 'JWT',
|
||||||
},
|
},
|
||||||
|
apiKey: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'X-API-Key',
|
||||||
|
description: 'API key for authentication',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'X-Timestamp',
|
||||||
|
description: 'UTC timestamp in ISO8601 format',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'security': [{ bearerAuth: [] }],
|
|
||||||
} as OpenApiDocumentation,
|
} as OpenApiDocumentation,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user