create-entity-server 0.0.15 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create.js +15 -7
- package/package.json +1 -1
- package/template/.env.example +8 -7
- package/template/configs/database.json +173 -10
- package/template/entities/Account/account_audit.json +4 -5
- package/template/entities/System/system_audit_log.json +14 -8
- package/template/samples/README.md +28 -22
- package/template/samples/browser/entity-server-client.js +453 -0
- package/template/samples/browser/example.html +498 -0
- package/template/samples/entities/02_types_and_defaults.json +15 -16
- package/template/samples/entities/04_fk_and_composite_unique.json +0 -2
- package/template/samples/entities/05_cache.json +9 -8
- package/template/samples/entities/06_history_and_hard_delete.json +27 -9
- package/template/samples/entities/07_license_scope.json +40 -31
- package/template/samples/entities/09_hook_entity.json +0 -6
- package/template/samples/entities/10_hook_submit_delete.json +5 -2
- package/template/samples/entities/11_hook_webhook.json +9 -7
- package/template/samples/entities/12_hook_push.json +3 -3
- package/template/samples/entities/13_read_only.json +13 -10
- package/template/samples/entities/15_reset_defaults.json +0 -1
- package/template/samples/entities/16_isolated_license.json +62 -0
- package/template/samples/entities/README.md +36 -39
- package/template/samples/flutter/lib/entity_server_client.dart +170 -48
- package/template/samples/java/EntityServerClient.java +208 -61
- package/template/samples/java/EntityServerExample.java +4 -3
- package/template/samples/kotlin/EntityServerClient.kt +175 -45
- package/template/samples/node/src/EntityServerClient.js +232 -59
- package/template/samples/node/src/example.js +9 -9
- package/template/samples/php/ci4/Config/EntityServer.php +0 -1
- package/template/samples/php/ci4/Libraries/EntityServer.php +206 -53
- package/template/samples/php/laravel/Services/EntityServerService.php +190 -41
- package/template/samples/python/entity_server.py +181 -68
- package/template/samples/python/example.py +7 -6
- package/template/samples/react/src/example.tsx +41 -25
- package/template/samples/swift/EntityServerClient.swift +143 -37
- package/template/scripts/run.ps1 +12 -3
- package/template/scripts/run.sh +12 -8
- package/template/scripts/update-server.ps1 +68 -2
- package/template/scripts/update-server.sh +59 -2
- package/template/samples/entities/order_notification.json +0 -51
- package/template/samples/react/src/api/entityServerClient.ts +0 -413
- package/template/samples/react/src/hooks/useEntity.ts +0 -173
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
* val client = EntityServerClient(
|
|
9
9
|
* baseUrl = "http://your-server:47200",
|
|
10
10
|
* apiKey = BuildConfig.ENTITY_API_KEY,
|
|
11
|
-
* hmacSecret = BuildConfig.ENTITY_HMAC_SECRET
|
|
12
|
-
* magicLen = 4 // 서버 packet_magic_len 과 동일
|
|
11
|
+
* hmacSecret = BuildConfig.ENTITY_HMAC_SECRET
|
|
13
12
|
* )
|
|
14
13
|
*
|
|
15
14
|
* 트랜잭션 사용 예:
|
|
@@ -34,9 +33,13 @@ import org.bouncycastle.crypto.engines.ChaCha7539Engine
|
|
|
34
33
|
import org.bouncycastle.crypto.modes.ChaChaEngine
|
|
35
34
|
import org.bouncycastle.crypto.params.KeyParameter
|
|
36
35
|
import org.bouncycastle.crypto.params.ParametersWithIV
|
|
36
|
+
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
|
|
37
|
+
import org.bouncycastle.crypto.params.HKDFParameters
|
|
38
|
+
import org.bouncycastle.crypto.digests.SHA256Digest
|
|
37
39
|
import org.json.JSONArray
|
|
38
40
|
import org.json.JSONObject
|
|
39
41
|
import java.security.MessageDigest
|
|
42
|
+
import java.security.SecureRandom
|
|
40
43
|
import java.util.UUID
|
|
41
44
|
import javax.crypto.Mac
|
|
42
45
|
import javax.crypto.spec.SecretKeySpec
|
|
@@ -45,24 +48,87 @@ class EntityServerClient(
|
|
|
45
48
|
private val baseUrl: String = "http://localhost:47200",
|
|
46
49
|
private val apiKey: String = "",
|
|
47
50
|
private val hmacSecret: String = "",
|
|
48
|
-
private
|
|
51
|
+
private var token: String = "",
|
|
52
|
+
/** true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화합니다. */
|
|
53
|
+
private var encryptRequests: Boolean = false,
|
|
49
54
|
) {
|
|
50
55
|
private val http = OkHttpClient()
|
|
51
|
-
private var activeTxId: String? = null
|
|
56
|
+
private var activeTxId: String? = null private var packetEncryption: Boolean = false
|
|
52
57
|
|
|
58
|
+
/** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
|
|
59
|
+
fun setToken(newToken: String) { token = newToken }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
63
|
+
* 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
64
|
+
*/
|
|
65
|
+
fun checkHealth(): JSONObject {
|
|
66
|
+
val req = Request.Builder()
|
|
67
|
+
.url(baseUrl.trimEnd('/') + "/v1/health")
|
|
68
|
+
.get()
|
|
69
|
+
.build()
|
|
70
|
+
val res = http.newCall(req).execute()
|
|
71
|
+
val body = JSONObject(res.body?.string() ?: "{}")
|
|
72
|
+
if (body.optBoolean("packet_encryption", false)) {
|
|
73
|
+
packetEncryption = true
|
|
74
|
+
}
|
|
75
|
+
return body
|
|
76
|
+
}
|
|
53
77
|
// ─── CRUD ─────────────────────────────────────────────────────────
|
|
54
78
|
|
|
55
|
-
fun get(entity: String, seq: Long): JSONObject
|
|
56
|
-
|
|
79
|
+
fun get(entity: String, seq: Long, skipHooks: Boolean = false): JSONObject {
|
|
80
|
+
val q = if (skipHooks) "?skipHooks=true" else ""
|
|
81
|
+
return request("GET", "/v1/entity/$entity/$seq$q")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 조건으로 단건 조회 (POST + conditions body)
|
|
86
|
+
*
|
|
87
|
+
* @param conditions 필터 조건. index/hash/unique 필드만 사용 가능
|
|
88
|
+
* @param skipHooks after_find 훅 미실행 여부
|
|
89
|
+
*/
|
|
90
|
+
fun find(entity: String, conditions: JSONObject, skipHooks: Boolean = false): JSONObject {
|
|
91
|
+
val q = if (skipHooks) "?skipHooks=true" else ""
|
|
92
|
+
return request("POST", "/v1/entity/$entity/find$q", conditions.toString())
|
|
93
|
+
}
|
|
57
94
|
|
|
58
|
-
|
|
59
|
-
|
|
95
|
+
/**
|
|
96
|
+
* 목록 조회 (POST + conditions body)
|
|
97
|
+
* @param orderBy 정렬 기준 필드명. null 이면 기본 정렬. - 접두사로 내림차순
|
|
98
|
+
* @param fields 반환 필드 목록. 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름). listOf("*") 지정 시 전체 필드 반환
|
|
99
|
+
* @param conditions 필터 조건. index/hash/unique 필드만 사용 가능
|
|
100
|
+
*/
|
|
101
|
+
fun list(
|
|
102
|
+
entity: String,
|
|
103
|
+
page: Int = 1,
|
|
104
|
+
limit: Int = 20,
|
|
105
|
+
orderBy: String? = null,
|
|
106
|
+
fields: List<String>? = null,
|
|
107
|
+
conditions: JSONObject? = null,
|
|
108
|
+
): JSONObject {
|
|
109
|
+
val qParts = mutableListOf("page=$page", "limit=$limit")
|
|
110
|
+
if (orderBy != null) qParts += "order_by=$orderBy"
|
|
111
|
+
if (!fields.isNullOrEmpty()) qParts += "fields=${fields.joinToString(",")}"
|
|
112
|
+
return request("POST", "/v1/entity/$entity/list?${qParts.joinToString("&")}", conditions?.toString() ?: "{}")
|
|
113
|
+
}
|
|
60
114
|
|
|
61
|
-
|
|
62
|
-
|
|
115
|
+
/** 건수 조회 */
|
|
116
|
+
fun count(entity: String, conditions: JSONObject? = null): JSONObject =
|
|
117
|
+
request("POST", "/v1/entity/$entity/count", conditions?.toString() ?: "{}")
|
|
63
118
|
|
|
64
|
-
|
|
65
|
-
|
|
119
|
+
/**
|
|
120
|
+
* 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
|
|
121
|
+
* @param sql SELECT SQL문. 사용자 입력은 반드시 ? 로 바인딩 (SQL Injection 방지)
|
|
122
|
+
* @param params 바인딩 파라미터 JSON 배열
|
|
123
|
+
* @param limit 최대 반환 건수 (최대 1000. 0 이하이면 서버 기본값)
|
|
124
|
+
*/
|
|
125
|
+
fun query(entity: String, sql: String, params: JSONArray? = null, limit: Int = 0): JSONObject {
|
|
126
|
+
val body = JSONObject()
|
|
127
|
+
body.put("sql", sql)
|
|
128
|
+
body.put("params", params ?: JSONArray())
|
|
129
|
+
if (limit > 0) body.put("limit", limit)
|
|
130
|
+
return request("POST", "/v1/entity/$entity/query", body.toString())
|
|
131
|
+
}
|
|
66
132
|
|
|
67
133
|
/**
|
|
68
134
|
* 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
|
|
@@ -98,18 +164,23 @@ class EntityServerClient(
|
|
|
98
164
|
}
|
|
99
165
|
|
|
100
166
|
/** 생성 또는 수정 (seq 포함시 수정, 없으면 생성) */
|
|
101
|
-
fun submit(entity: String, data: JSONObject, transactionId: String? = null): JSONObject {
|
|
167
|
+
fun submit(entity: String, data: JSONObject, transactionId: String? = null, skipHooks: Boolean = false): JSONObject {
|
|
102
168
|
val txId = transactionId ?: activeTxId
|
|
103
169
|
val extra = if (txId != null) mapOf("X-Transaction-ID" to txId) else emptyMap()
|
|
104
|
-
|
|
170
|
+
val q = if (skipHooks) "?skipHooks=true" else ""
|
|
171
|
+
return request("POST", "/v1/entity/$entity/submit$q", data.toString(), extra)
|
|
105
172
|
}
|
|
106
173
|
|
|
107
174
|
/** 삭제 */
|
|
108
|
-
|
|
109
|
-
|
|
175
|
+
/** 삭제. 서버는 POST /delete/:seq 로만 처리합니다. */
|
|
176
|
+
fun delete(entity: String, seq: Long, transactionId: String? = null, hard: Boolean = false, skipHooks: Boolean = false): JSONObject {
|
|
177
|
+
val qParts = mutableListOf<String>()
|
|
178
|
+
if (hard) qParts += "hard=true"
|
|
179
|
+
if (skipHooks) qParts += "skipHooks=true"
|
|
180
|
+
val q = if (qParts.isNotEmpty()) "?" + qParts.joinToString("&") else ""
|
|
110
181
|
val txId = transactionId ?: activeTxId
|
|
111
182
|
val extra = if (txId != null) mapOf("X-Transaction-ID" to txId) else emptyMap()
|
|
112
|
-
return request("
|
|
183
|
+
return request("POST", "/v1/entity/$entity/delete/$seq$q", extraHeaders = extra)
|
|
113
184
|
}
|
|
114
185
|
|
|
115
186
|
fun history(entity: String, seq: Long, page: Int = 1, limit: Int = 50): JSONObject =
|
|
@@ -207,55 +278,113 @@ class EntityServerClient(
|
|
|
207
278
|
// ─── 내부 ─────────────────────────────────────────────────────────
|
|
208
279
|
|
|
209
280
|
private fun request(method: String, path: String, bodyStr: String = "", extraHeaders: Map<String, String> = emptyMap()): JSONObject {
|
|
281
|
+
// 요청 바디 결정: encryptRequests 시 POST 바디를 암호화
|
|
282
|
+
val bodyBytes: ByteArray
|
|
283
|
+
val contentType: String
|
|
284
|
+
if ((encryptRequests || packetEncryption) && bodyStr.isNotEmpty()) {
|
|
285
|
+
bodyBytes = encryptPacket(bodyStr.toByteArray(Charsets.UTF_8))
|
|
286
|
+
contentType = "application/octet-stream"
|
|
287
|
+
} else {
|
|
288
|
+
bodyBytes = bodyStr.toByteArray(Charsets.UTF_8)
|
|
289
|
+
contentType = "application/json"
|
|
290
|
+
}
|
|
291
|
+
|
|
210
292
|
val timestamp = (System.currentTimeMillis() / 1000).toString()
|
|
211
293
|
val nonce = UUID.randomUUID().toString()
|
|
212
|
-
val signature = sign(method, path, timestamp, nonce,
|
|
294
|
+
val signature = sign(method, path, timestamp, nonce, bodyBytes)
|
|
295
|
+
|
|
296
|
+
val isHmacMode = apiKey.isNotEmpty() && hmacSecret.isNotEmpty()
|
|
213
297
|
|
|
214
298
|
val requestBuilder = Request.Builder()
|
|
215
299
|
.url(baseUrl.trimEnd('/') + path)
|
|
216
|
-
.addHeader("Content-Type",
|
|
217
|
-
.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
300
|
+
.addHeader("Content-Type", contentType)
|
|
301
|
+
.apply {
|
|
302
|
+
if (isHmacMode) {
|
|
303
|
+
addHeader("X-API-Key", apiKey)
|
|
304
|
+
addHeader("X-Timestamp", timestamp)
|
|
305
|
+
addHeader("X-Nonce", nonce)
|
|
306
|
+
addHeader("X-Signature", signature)
|
|
307
|
+
} else if (token.isNotEmpty()) {
|
|
308
|
+
addHeader("Authorization", "Bearer $token")
|
|
309
|
+
}
|
|
310
|
+
extraHeaders.forEach { (k, v) -> addHeader(k, v) }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
val reqBody = if (bodyBytes.isNotEmpty())
|
|
314
|
+
bodyBytes.toRequestBody(contentType.toMediaType())
|
|
225
315
|
else null
|
|
226
316
|
|
|
227
317
|
val req = when (method.uppercase()) {
|
|
228
318
|
"GET" -> requestBuilder.get().build()
|
|
229
|
-
"DELETE" -> requestBuilder.delete(
|
|
230
|
-
else -> requestBuilder.method(method.uppercase(),
|
|
319
|
+
"DELETE" -> requestBuilder.delete(reqBody).build()
|
|
320
|
+
else -> requestBuilder.method(method.uppercase(), reqBody).build()
|
|
231
321
|
}
|
|
232
322
|
|
|
233
323
|
val res = http.newCall(req).execute()
|
|
234
|
-
val
|
|
324
|
+
val resContentType = res.header("Content-Type") ?: ""
|
|
235
325
|
val rawBytes = res.body?.bytes() ?: byteArrayOf()
|
|
236
326
|
|
|
237
327
|
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
238
|
-
return if (
|
|
328
|
+
return if (resContentType.contains("application/octet-stream")) {
|
|
239
329
|
JSONObject(decryptPacket(rawBytes))
|
|
240
330
|
} else {
|
|
241
331
|
JSONObject(String(rawBytes, Charsets.UTF_8))
|
|
242
332
|
}
|
|
243
333
|
}
|
|
244
334
|
|
|
335
|
+
/**
|
|
336
|
+
* 패킷 암호화 키를 유도합니다.
|
|
337
|
+
* - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
338
|
+
* - JWT 모드: SHA256(token)
|
|
339
|
+
*/
|
|
340
|
+
private fun derivePacketKey(): ByteArray {
|
|
341
|
+
if (token.isNotEmpty() && hmacSecret.isEmpty()) {
|
|
342
|
+
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
|
343
|
+
return digest.digest(token.toByteArray(Charsets.UTF_8))
|
|
344
|
+
}
|
|
345
|
+
val gen = HKDFBytesGenerator(SHA256Digest())
|
|
346
|
+
gen.init(HKDFParameters(
|
|
347
|
+
hmacSecret.toByteArray(Charsets.UTF_8),
|
|
348
|
+
"entity-server:hkdf:v1".toByteArray(Charsets.UTF_8),
|
|
349
|
+
"entity-server:packet-encryption".toByteArray(Charsets.UTF_8),
|
|
350
|
+
))
|
|
351
|
+
val key = ByteArray(32)
|
|
352
|
+
gen.generateBytes(key, 0, 32)
|
|
353
|
+
return key
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* XChaCha20-Poly1305 패킷 암호화
|
|
358
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
359
|
+
* magicLen: 2 + key[31] % 14
|
|
360
|
+
*/
|
|
361
|
+
private fun encryptPacket(plaintext: ByteArray): ByteArray {
|
|
362
|
+
val key = derivePacketKey()
|
|
363
|
+
val magicLen = 2 + (key[31].toInt() and 0xFF) % 14
|
|
364
|
+
val magic = ByteArray(magicLen).also { SecureRandom().nextBytes(it) }
|
|
365
|
+
val nonce = ByteArray(24).also { SecureRandom().nextBytes(it) }
|
|
366
|
+
|
|
367
|
+
val aead = org.bouncycastle.crypto.modes.ChaCha20Poly1305()
|
|
368
|
+
aead.init(true, org.bouncycastle.crypto.params.AEADParameters(
|
|
369
|
+
KeyParameter(key), 128, nonce
|
|
370
|
+
))
|
|
371
|
+
val ciphertext = ByteArray(aead.getOutputSize(plaintext.size))
|
|
372
|
+
val len = aead.processBytes(plaintext, 0, plaintext.size, ciphertext, 0)
|
|
373
|
+
aead.doFinal(ciphertext, len)
|
|
374
|
+
return magic + nonce + ciphertext
|
|
375
|
+
}
|
|
376
|
+
|
|
245
377
|
/**
|
|
246
378
|
* XChaCha20-Poly1305 패킷 복호화
|
|
247
379
|
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
248
|
-
* 키:
|
|
249
|
-
*
|
|
250
|
-
* Bouncy Castle XChaCha20-Poly1305 사용
|
|
380
|
+
* 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
251
381
|
*/
|
|
252
382
|
private fun decryptPacket(data: ByteArray): String {
|
|
253
|
-
val key =
|
|
254
|
-
|
|
383
|
+
val key = derivePacketKey()
|
|
384
|
+
val magicLen = 2 + (key[31].toInt() and 0xFF) % 14
|
|
255
385
|
val nonce = data.copyOfRange(magicLen, magicLen + 24)
|
|
256
386
|
val ciphertext = data.copyOfRange(magicLen + 24, data.size)
|
|
257
387
|
|
|
258
|
-
// Bouncy Castle: XChaCha20-Poly1305 (AEAD)
|
|
259
388
|
val aead = org.bouncycastle.crypto.modes.ChaCha20Poly1305()
|
|
260
389
|
aead.init(false, org.bouncycastle.crypto.params.AEADParameters(
|
|
261
390
|
KeyParameter(key), 128, nonce
|
|
@@ -267,14 +396,15 @@ class EntityServerClient(
|
|
|
267
396
|
return plaintext.toString(Charsets.UTF_8)
|
|
268
397
|
}
|
|
269
398
|
|
|
270
|
-
/**
|
|
271
|
-
|
|
272
|
-
|
|
399
|
+
/**
|
|
400
|
+
* HMAC-SHA256 서명
|
|
401
|
+
* bodyBytes 는 JSON 바이트 또는 암호화된 바리두 모두 지원합니다.
|
|
402
|
+
*/
|
|
403
|
+
private fun sign(method: String, path: String, timestamp: String, nonce: String, bodyBytes: ByteArray): String {
|
|
273
404
|
val mac = Mac.getInstance("HmacSHA256")
|
|
274
405
|
mac.init(SecretKeySpec(hmacSecret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
|
275
|
-
|
|
406
|
+
val prefix = "$method|$path|$timestamp|$nonce|".toByteArray(Charsets.UTF_8)
|
|
407
|
+
mac.update(prefix)
|
|
408
|
+
if (bodyBytes.isNotEmpty()) mac.update(bodyBytes)
|
|
409
|
+
return mac.doFinal().joinToString("") { "%02x".format(it) }
|
|
276
410
|
}
|
|
277
|
-
|
|
278
|
-
private fun sha256(input: ByteArray): ByteArray =
|
|
279
|
-
MessageDigest.getInstance("SHA-256").digest(input)
|
|
280
|
-
}
|