create-entity-server 0.0.9 → 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 +26 -8
- package/package.json +1 -1
- package/template/.env.example +20 -3
- package/template/configs/database.json +173 -10
- package/template/configs/jwt.json +1 -0
- package/template/configs/oauth.json +37 -0
- package/template/configs/push.json +26 -0
- package/template/entities/Account/account_audit.json +4 -5
- package/template/entities/README.md +4 -4
- package/template/entities/{Auth → System/Auth}/account.json +0 -14
- package/template/entities/System/system_audit_log.json +14 -8
- package/template/samples/README.md +43 -21
- package/template/samples/browser/entity-server-client.js +453 -0
- package/template/samples/browser/example.html +498 -0
- package/template/samples/entities/01_basic_fields.json +39 -0
- package/template/samples/entities/02_types_and_defaults.json +67 -0
- package/template/samples/entities/03_hash_and_unique.json +33 -0
- package/template/samples/entities/04_fk_and_composite_unique.json +29 -0
- package/template/samples/entities/05_cache.json +55 -0
- package/template/samples/entities/06_history_and_hard_delete.json +60 -0
- package/template/samples/entities/07_license_scope.json +52 -0
- package/template/samples/entities/08_hook_sql.json +52 -0
- package/template/samples/entities/09_hook_entity.json +65 -0
- package/template/samples/entities/10_hook_submit_delete.json +78 -0
- package/template/samples/entities/11_hook_webhook.json +84 -0
- package/template/samples/entities/12_hook_push.json +73 -0
- package/template/samples/entities/13_read_only.json +54 -0
- package/template/samples/entities/14_optimistic_lock.json +29 -0
- package/template/samples/entities/15_reset_defaults.json +94 -0
- package/template/samples/entities/16_isolated_license.json +62 -0
- package/template/samples/entities/README.md +91 -0
- package/template/samples/flutter/lib/entity_server_client.dart +261 -48
- package/template/samples/java/EntityServerClient.java +325 -61
- package/template/samples/java/EntityServerExample.java +4 -3
- package/template/samples/kotlin/EntityServerClient.kt +261 -45
- package/template/samples/node/src/EntityServerClient.js +348 -59
- package/template/samples/node/src/example.js +9 -9
- package/template/samples/php/ci4/Config/EntityServer.php +14 -0
- package/template/samples/php/ci4/Controllers/EntityController.php +202 -0
- package/template/samples/php/ci4/Controllers/ProductController.php +16 -76
- package/template/samples/php/ci4/Libraries/EntityServer.php +352 -60
- package/template/samples/php/laravel/Services/EntityServerService.php +245 -40
- package/template/samples/python/entity_server.py +287 -68
- package/template/samples/python/example.py +7 -6
- package/template/samples/react/src/example.tsx +41 -25
- package/template/samples/swift/EntityServerClient.swift +248 -37
- package/template/scripts/normalize-entities.sh +10 -10
- package/template/scripts/run.ps1 +12 -3
- package/template/scripts/run.sh +120 -37
- package/template/scripts/update-server.ps1 +160 -4
- package/template/scripts/update-server.sh +132 -4
- package/template/samples/react/src/api/entityServerClient.ts +0 -290
- package/template/samples/react/src/hooks/useEntity.ts +0 -105
- /package/template/entities/{Auth → System/Auth}/api_keys.json +0 -0
- /package/template/entities/{Auth → System/Auth}/license.json +0 -0
- /package/template/entities/{Auth → System/Auth}/rbac_roles.json +0 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
package com.example.entityserver;
|
|
2
2
|
|
|
3
|
+
import org.bouncycastle.crypto.generators.HKDFBytesGenerator;
|
|
3
4
|
import org.bouncycastle.crypto.modes.ChaCha20Poly1305;
|
|
4
5
|
import org.bouncycastle.crypto.params.AEADParameters;
|
|
6
|
+
import org.bouncycastle.crypto.params.HKDFParameters;
|
|
5
7
|
import org.bouncycastle.crypto.params.KeyParameter;
|
|
8
|
+
import org.bouncycastle.crypto.digests.SHA256Digest;
|
|
6
9
|
|
|
7
10
|
import javax.crypto.Mac;
|
|
8
11
|
import javax.crypto.spec.SecretKeySpec;
|
|
@@ -14,6 +17,7 @@ import java.net.HttpURLConnection;
|
|
|
14
17
|
import java.net.URL;
|
|
15
18
|
import java.nio.charset.StandardCharsets;
|
|
16
19
|
import java.security.MessageDigest;
|
|
20
|
+
import java.security.SecureRandom;
|
|
17
21
|
import java.util.Arrays;
|
|
18
22
|
import java.util.Collections;
|
|
19
23
|
import java.util.HashMap;
|
|
@@ -29,9 +33,9 @@ import java.util.UUID;
|
|
|
29
33
|
*
|
|
30
34
|
* 환경변수 또는 생성자로 설정:
|
|
31
35
|
* ENTITY_SERVER_URL http://localhost:47200
|
|
32
|
-
* ENTITY_SERVER_API_KEY your-api-key
|
|
33
|
-
* ENTITY_SERVER_HMAC_SECRET your-hmac-secret
|
|
34
|
-
*
|
|
36
|
+
* ENTITY_SERVER_API_KEY your-api-key (HMAC 모드)
|
|
37
|
+
* ENTITY_SERVER_HMAC_SECRET your-hmac-secret (HMAC 모드)
|
|
38
|
+
* ENTITY_SERVER_TOKEN your-jwt-token (JWT 모드)
|
|
35
39
|
*
|
|
36
40
|
* 사용 예:
|
|
37
41
|
* EntityServerClient es = new EntityServerClient();
|
|
@@ -55,12 +59,15 @@ import java.util.UUID;
|
|
|
55
59
|
*/
|
|
56
60
|
public class EntityServerClient {
|
|
57
61
|
|
|
58
|
-
private final String
|
|
59
|
-
private final String
|
|
60
|
-
private final String
|
|
61
|
-
private final int
|
|
62
|
-
|
|
63
|
-
private
|
|
62
|
+
private final String baseUrl;
|
|
63
|
+
private final String apiKey;
|
|
64
|
+
private final String hmacSecret;
|
|
65
|
+
private final int timeoutMs;
|
|
66
|
+
/** true 이면 POST 요청 바디를 XChaCha20-Poly1305로 암호화합니다. */
|
|
67
|
+
private boolean encryptRequests;
|
|
68
|
+
private boolean packetEncryption = false;
|
|
69
|
+
private String token = "";
|
|
70
|
+
private String activeTxId = null;
|
|
64
71
|
|
|
65
72
|
public EntityServerClient() {
|
|
66
73
|
this(
|
|
@@ -68,43 +75,100 @@ public class EntityServerClient {
|
|
|
68
75
|
getEnv("ENTITY_SERVER_API_KEY", ""),
|
|
69
76
|
getEnv("ENTITY_SERVER_HMAC_SECRET", ""),
|
|
70
77
|
10_000,
|
|
71
|
-
|
|
78
|
+
false
|
|
72
79
|
);
|
|
80
|
+
this.token = getEnv("ENTITY_SERVER_TOKEN", "");
|
|
73
81
|
}
|
|
74
82
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
this
|
|
80
|
-
this.magicLen = magicLen;
|
|
83
|
+
/** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
|
|
84
|
+
public void setToken(String token) { this.token = token; }
|
|
85
|
+
|
|
86
|
+
public EntityServerClient(String baseUrl, String apiKey, String hmacSecret, int timeoutMs) {
|
|
87
|
+
this(baseUrl, apiKey, hmacSecret, timeoutMs, false);
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
|
|
90
|
+
public EntityServerClient(String baseUrl, String apiKey, String hmacSecret, int timeoutMs, boolean encryptRequests) {
|
|
91
|
+
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
|
92
|
+
this.apiKey = apiKey;
|
|
93
|
+
this.hmacSecret = hmacSecret;
|
|
94
|
+
this.timeoutMs = timeoutMs;
|
|
95
|
+
this.encryptRequests = encryptRequests;
|
|
96
|
+
}
|
|
84
97
|
|
|
98
|
+
// ─── CRUD ────────────────────────────────────────────────────────────────
|
|
99
|
+
/**
|
|
100
|
+
* 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
101
|
+
* 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
102
|
+
* @return JSON 문자열 (예: {"ok":true} 또는 {"ok":true,"packet_encryption":true})
|
|
103
|
+
*/
|
|
104
|
+
public String checkHealth() throws IOException {
|
|
105
|
+
String json = request("GET", "/v1/health", null);
|
|
106
|
+
if (json != null && json.contains("\"packet_encryption\":true")) {
|
|
107
|
+
packetEncryption = true;
|
|
108
|
+
}
|
|
109
|
+
return json;
|
|
110
|
+
}
|
|
85
111
|
/** 단건 조회 */
|
|
86
112
|
public String get(String entity, long seq) throws IOException {
|
|
87
|
-
return
|
|
113
|
+
return get(entity, seq, false);
|
|
88
114
|
}
|
|
89
115
|
|
|
90
|
-
/**
|
|
91
|
-
public String
|
|
116
|
+
/** 단건 조회 (skipHooks 지원) */
|
|
117
|
+
public String get(String entity, long seq, boolean skipHooks) throws IOException {
|
|
118
|
+
String q = skipHooks ? "?skipHooks=true" : "";
|
|
119
|
+
return request("GET", "/v1/entity/" + entity + "/" + seq + q, null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 조건으로 단건 조회 — conditions 에 맞는 행 1건 반환, 없으면 404 */
|
|
123
|
+
public String find(String entity, String conditionsJson) throws IOException {
|
|
124
|
+
return find(entity, conditionsJson, false);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 조건으로 단건 조회 (skipHooks 지원) */
|
|
128
|
+
public String find(String entity, String conditionsJson, boolean skipHooks) throws IOException {
|
|
129
|
+
String q = skipHooks ? "?skipHooks=true" : "";
|
|
130
|
+
return request("POST", "/v1/entity/" + entity + "/find" + q,
|
|
131
|
+
conditionsJson != null ? conditionsJson : "{}");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 목록 조회 (POST + conditions body)
|
|
136
|
+
* @param orderBy 정렬 기준 필드명. null 이면 기본 정렬. - 접두사로 내림차순
|
|
137
|
+
* @param conditionsJson 필터 조건 JSON 객체. index/hash/unique 필드만 사용 가능. null 이면 전체
|
|
138
|
+
*/
|
|
139
|
+
public String list(String entity, int page, int limit, String orderBy, String conditionsJson) throws IOException {
|
|
92
140
|
String query = "?page=" + page + "&limit=" + limit + (orderBy != null ? "&order_by=" + orderBy : "");
|
|
93
|
-
return request("
|
|
141
|
+
return request("POST", "/v1/entity/" + entity + "/list" + query, conditionsJson != null ? conditionsJson : "{}");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 목록 조회 (조건 없음) */
|
|
145
|
+
public String list(String entity, int page, int limit, String orderBy) throws IOException {
|
|
146
|
+
return list(entity, page, limit, orderBy, null);
|
|
94
147
|
}
|
|
95
148
|
|
|
96
149
|
/** 건수 조회 */
|
|
97
150
|
public String count(String entity) throws IOException {
|
|
98
|
-
return
|
|
151
|
+
return count(entity, null);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 건수 조회 (conditions 지원) */
|
|
155
|
+
public String count(String entity, String conditionsJson) throws IOException {
|
|
156
|
+
return request("POST", "/v1/entity/" + entity + "/count", conditionsJson != null ? conditionsJson : "{}");
|
|
99
157
|
}
|
|
100
158
|
|
|
101
159
|
/**
|
|
102
|
-
*
|
|
103
|
-
*
|
|
160
|
+
* 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
|
|
161
|
+
*
|
|
162
|
+
* @param sql SELECT SQL문. 사용자 입력은 반드시 ? 로 바인딩 (SQL Injection 방지)
|
|
163
|
+
* @param paramsJson 바인딩 파라미터 JSON 배열. 예: "[\"pending\"]" (null 이면 빈 배열)
|
|
164
|
+
* @param limit 최대 반환 건수 (최대 1000. 0 이하이면 서버 기본값 적용)
|
|
104
165
|
*/
|
|
105
|
-
public String query(String entity, String
|
|
106
|
-
|
|
107
|
-
|
|
166
|
+
public String query(String entity, String sql, String paramsJson, int limit) throws IOException {
|
|
167
|
+
StringBuilder body = new StringBuilder("{\"sql\":").append(jsonString(sql));
|
|
168
|
+
body.append(",\"params\":").append(paramsJson != null ? paramsJson : "[]");
|
|
169
|
+
if (limit > 0) body.append(",\"limit\":").append(limit);
|
|
170
|
+
body.append("}");
|
|
171
|
+
return request("POST", "/v1/entity/" + entity + "/query", body.toString());
|
|
108
172
|
}
|
|
109
173
|
|
|
110
174
|
/**
|
|
@@ -159,29 +223,42 @@ public class EntityServerClient {
|
|
|
159
223
|
* @param dataJson JSON 객체 문자열. seq 포함 시 수정, 없으면 생성.
|
|
160
224
|
*/
|
|
161
225
|
public String submit(String entity, String dataJson) throws IOException {
|
|
162
|
-
return submit(entity, dataJson, null);
|
|
226
|
+
return submit(entity, dataJson, null, false);
|
|
163
227
|
}
|
|
164
228
|
|
|
165
229
|
/** 생성 또는 수정 (트랜잭션 지원) */
|
|
166
230
|
public String submit(String entity, String dataJson, String transactionId) throws IOException {
|
|
231
|
+
return submit(entity, dataJson, transactionId, false);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 생성 또는 수정 (skipHooks 지원) */
|
|
235
|
+
public String submit(String entity, String dataJson, String transactionId, boolean skipHooks) throws IOException {
|
|
167
236
|
Map<String, String> extra = new HashMap<>();
|
|
168
237
|
String txId = transactionId != null ? transactionId : activeTxId;
|
|
169
238
|
if (txId != null) extra.put("X-Transaction-ID", txId);
|
|
170
|
-
|
|
239
|
+
String q = skipHooks ? "?skipHooks=true" : "";
|
|
240
|
+
return request("POST", "/v1/entity/" + entity + "/submit" + q, dataJson, extra);
|
|
171
241
|
}
|
|
172
242
|
|
|
173
243
|
/** 삭제 */
|
|
174
244
|
public String delete(String entity, long seq) throws IOException {
|
|
175
|
-
return delete(entity, seq, null, false);
|
|
245
|
+
return delete(entity, seq, null, false, false);
|
|
176
246
|
}
|
|
177
247
|
|
|
178
|
-
/** 삭제 (
|
|
248
|
+
/** 삭제 (트랜잭션/하드 삭제 지원) */
|
|
179
249
|
public String delete(String entity, long seq, String transactionId, boolean hard) throws IOException {
|
|
180
|
-
|
|
250
|
+
return delete(entity, seq, transactionId, hard, false);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 삭제 (skipHooks 지원). 서버는 POST /delete/:seq 로만 처리합니다. */
|
|
254
|
+
public String delete(String entity, long seq, String transactionId, boolean hard, boolean skipHooks) throws IOException {
|
|
255
|
+
StringBuilder qb = new StringBuilder();
|
|
256
|
+
if (hard) { qb.append(qb.length() == 0 ? "?" : "&").append("hard=true"); }
|
|
257
|
+
if (skipHooks) { qb.append(qb.length() == 0 ? "?" : "&").append("skipHooks=true"); }
|
|
181
258
|
Map<String, String> extra = new HashMap<>();
|
|
182
259
|
String txId = transactionId != null ? transactionId : activeTxId;
|
|
183
260
|
if (txId != null) extra.put("X-Transaction-ID", txId);
|
|
184
|
-
return request("
|
|
261
|
+
return request("POST", "/v1/entity/" + entity + "/delete/" + seq + qb.toString(), null, extra);
|
|
185
262
|
}
|
|
186
263
|
|
|
187
264
|
/** 변경 이력 조회 */
|
|
@@ -195,6 +272,110 @@ public class EntityServerClient {
|
|
|
195
272
|
return request("POST", "/v1/entity/" + entity + "/rollback/" + historySeq, null);
|
|
196
273
|
}
|
|
197
274
|
|
|
275
|
+
/** 푸시 발송 트리거 엔티티에 submit합니다. */
|
|
276
|
+
public String push(String pushEntity, String payloadJson) throws IOException {
|
|
277
|
+
return push(pushEntity, payloadJson, null);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 푸시 발송 트리거 엔티티에 submit합니다. (트랜잭션 지원) */
|
|
281
|
+
public String push(String pushEntity, String payloadJson, String transactionId) throws IOException {
|
|
282
|
+
return submit(pushEntity, payloadJson, transactionId);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** push_log 목록 조회 헬퍼 */
|
|
286
|
+
public String pushLogList() throws IOException {
|
|
287
|
+
return pushLogList(1, 20, null);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** push_log 목록 조회 헬퍼 */
|
|
291
|
+
public String pushLogList(int page, int limit, String orderBy) throws IOException {
|
|
292
|
+
return list("push_log", page, limit, orderBy);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** account_device 디바이스 등록/갱신 헬퍼 (push_token 단일 필드) */
|
|
296
|
+
public String registerPushDevice(
|
|
297
|
+
long accountSeq,
|
|
298
|
+
String deviceId,
|
|
299
|
+
String pushToken,
|
|
300
|
+
String platform,
|
|
301
|
+
String deviceType,
|
|
302
|
+
boolean pushEnabled,
|
|
303
|
+
String transactionId
|
|
304
|
+
) throws IOException {
|
|
305
|
+
StringBuilder payload = new StringBuilder("{");
|
|
306
|
+
payload.append("\"id\":").append(jsonString(deviceId));
|
|
307
|
+
payload.append(",\"account_seq\":").append(accountSeq);
|
|
308
|
+
payload.append(",\"push_token\":").append(jsonString(pushToken));
|
|
309
|
+
payload.append(",\"push_enabled\":").append(pushEnabled);
|
|
310
|
+
if (platform != null && !platform.isBlank()) {
|
|
311
|
+
payload.append(",\"platform\":").append(jsonString(platform));
|
|
312
|
+
}
|
|
313
|
+
if (deviceType != null && !deviceType.isBlank()) {
|
|
314
|
+
payload.append(",\"device_type\":").append(jsonString(deviceType));
|
|
315
|
+
}
|
|
316
|
+
payload.append("}");
|
|
317
|
+
|
|
318
|
+
return submit("account_device", payload.toString(), transactionId);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** account_device.seq 기준 push_token 갱신 헬퍼 */
|
|
322
|
+
public String updatePushDeviceToken(
|
|
323
|
+
long deviceSeq,
|
|
324
|
+
String pushToken,
|
|
325
|
+
boolean pushEnabled,
|
|
326
|
+
String transactionId
|
|
327
|
+
) throws IOException {
|
|
328
|
+
String payload = "{" +
|
|
329
|
+
"\"seq\":" + deviceSeq +
|
|
330
|
+
",\"push_token\":" + jsonString(pushToken) +
|
|
331
|
+
",\"push_enabled\":" + pushEnabled +
|
|
332
|
+
"}";
|
|
333
|
+
return submit("account_device", payload, transactionId);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** account_device.seq 기준 푸시 수신 비활성화 헬퍼 */
|
|
337
|
+
public String disablePushDevice(long deviceSeq, String transactionId) throws IOException {
|
|
338
|
+
String payload = "{" +
|
|
339
|
+
"\"seq\":" + deviceSeq +
|
|
340
|
+
",\"push_enabled\":false" +
|
|
341
|
+
"}";
|
|
342
|
+
return submit("account_device", payload, transactionId);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 요청 본문을 읽어 JSON 문자열로 반환합니다.
|
|
347
|
+
* - application/octet-stream: 암호 패킷 복호화
|
|
348
|
+
* - 그 외: 평문 JSON 문자열 반환
|
|
349
|
+
*/
|
|
350
|
+
public String readRequestBody(byte[] rawBody, String contentType, boolean requireEncrypted) throws IOException {
|
|
351
|
+
String lowered = contentType == null ? "" : contentType.toLowerCase();
|
|
352
|
+
boolean isEncrypted = lowered.contains("application/octet-stream");
|
|
353
|
+
|
|
354
|
+
if (requireEncrypted && !isEncrypted) {
|
|
355
|
+
throw new IOException("Encrypted request required: Content-Type must be application/octet-stream");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (isEncrypted) {
|
|
359
|
+
if (rawBody == null || rawBody.length == 0) {
|
|
360
|
+
throw new IOException("Encrypted request body is empty");
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
return decryptPacket(rawBody);
|
|
364
|
+
} catch (Exception e) {
|
|
365
|
+
throw new IOException("Packet decryption failed: " + e.getMessage(), e);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (rawBody == null || rawBody.length == 0) {
|
|
370
|
+
return "{}";
|
|
371
|
+
}
|
|
372
|
+
return new String(rawBody, StandardCharsets.UTF_8);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
public String readRequestBody(byte[] rawBody, String contentType) throws IOException {
|
|
376
|
+
return readRequestBody(rawBody, contentType, false);
|
|
377
|
+
}
|
|
378
|
+
|
|
198
379
|
// ─── 내부 ─────────────────────────────────────────────────────────────────
|
|
199
380
|
|
|
200
381
|
private String request(String method, String path, String body) throws IOException {
|
|
@@ -202,40 +383,59 @@ public class EntityServerClient {
|
|
|
202
383
|
}
|
|
203
384
|
|
|
204
385
|
private String request(String method, String path, String body, Map<String, String> extraHeaders) throws IOException {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
String
|
|
208
|
-
|
|
386
|
+
// 요청 바디 결정: encryptRequests 시 POST 바디를 암호화
|
|
387
|
+
byte[] bodyBytes;
|
|
388
|
+
String contentType;
|
|
389
|
+
if ((encryptRequests || packetEncryption) && body != null && !body.isEmpty()) {
|
|
390
|
+
try {
|
|
391
|
+
bodyBytes = encryptPacket(body.getBytes(StandardCharsets.UTF_8));
|
|
392
|
+
} catch (Exception e) {
|
|
393
|
+
throw new IOException("Packet encryption failed: " + e.getMessage(), e);
|
|
394
|
+
}
|
|
395
|
+
contentType = "application/octet-stream";
|
|
396
|
+
} else {
|
|
397
|
+
bodyBytes = body != null ? body.getBytes(StandardCharsets.UTF_8) : new byte[0];
|
|
398
|
+
contentType = "application/json";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
boolean isHmacMode = !apiKey.isEmpty() && !hmacSecret.isEmpty();
|
|
209
402
|
|
|
210
403
|
URL url = new URL(baseUrl + path);
|
|
211
404
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
|
212
405
|
conn.setRequestMethod(method);
|
|
213
406
|
conn.setConnectTimeout(timeoutMs);
|
|
214
407
|
conn.setReadTimeout(timeoutMs);
|
|
215
|
-
conn.setRequestProperty("Content-Type",
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
408
|
+
conn.setRequestProperty("Content-Type", contentType);
|
|
409
|
+
if (isHmacMode) {
|
|
410
|
+
String timestamp = String.valueOf(System.currentTimeMillis() / 1000L);
|
|
411
|
+
String nonce = UUID.randomUUID().toString();
|
|
412
|
+
String signature = sign(method, path, timestamp, nonce, bodyBytes);
|
|
413
|
+
conn.setRequestProperty("X-API-Key", apiKey);
|
|
414
|
+
conn.setRequestProperty("X-Timestamp", timestamp);
|
|
415
|
+
conn.setRequestProperty("X-Nonce", nonce);
|
|
416
|
+
conn.setRequestProperty("X-Signature", signature);
|
|
417
|
+
} else if (token != null && !token.isEmpty()) {
|
|
418
|
+
conn.setRequestProperty("Authorization", "Bearer " + token);
|
|
419
|
+
}
|
|
220
420
|
for (Map.Entry<String, String> h : extraHeaders.entrySet()) {
|
|
221
421
|
conn.setRequestProperty(h.getKey(), h.getValue());
|
|
222
422
|
}
|
|
223
423
|
|
|
224
|
-
if (
|
|
424
|
+
if (bodyBytes.length > 0) {
|
|
225
425
|
conn.setDoOutput(true);
|
|
226
426
|
try (OutputStream os = conn.getOutputStream()) {
|
|
227
|
-
os.write(
|
|
427
|
+
os.write(bodyBytes);
|
|
228
428
|
}
|
|
229
429
|
}
|
|
230
430
|
|
|
231
431
|
int status = conn.getResponseCode();
|
|
232
|
-
String
|
|
432
|
+
String respCT = conn.getContentType();
|
|
233
433
|
InputStream stream = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
|
|
234
434
|
byte[] rawBytes = readAllBytes(stream);
|
|
235
435
|
|
|
236
436
|
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
237
437
|
String response;
|
|
238
|
-
if (
|
|
438
|
+
if (respCT != null && respCT.contains("application/octet-stream")) {
|
|
239
439
|
try {
|
|
240
440
|
response = decryptPacket(rawBytes);
|
|
241
441
|
} catch (Exception e) {
|
|
@@ -251,17 +451,68 @@ public class EntityServerClient {
|
|
|
251
451
|
return response;
|
|
252
452
|
}
|
|
253
453
|
|
|
454
|
+
/**
|
|
455
|
+
* 패킷 암호화 키를 유도합니다.
|
|
456
|
+
* - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
457
|
+
* - JWT 모드: SHA256(token)
|
|
458
|
+
*/
|
|
459
|
+
private byte[] derivePacketKey() {
|
|
460
|
+
if (token != null && !token.isEmpty() && hmacSecret.isEmpty()) {
|
|
461
|
+
try {
|
|
462
|
+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
463
|
+
return digest.digest(token.getBytes(StandardCharsets.UTF_8));
|
|
464
|
+
} catch (Exception e) {
|
|
465
|
+
throw new RuntimeException("SHA-256 failed", e);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
HKDFBytesGenerator gen = new HKDFBytesGenerator(new SHA256Digest());
|
|
469
|
+
gen.init(new HKDFParameters(
|
|
470
|
+
hmacSecret.getBytes(StandardCharsets.UTF_8),
|
|
471
|
+
"entity-server:hkdf:v1".getBytes(StandardCharsets.UTF_8),
|
|
472
|
+
"entity-server:packet-encryption".getBytes(StandardCharsets.UTF_8)
|
|
473
|
+
));
|
|
474
|
+
byte[] key = new byte[32];
|
|
475
|
+
gen.generateBytes(key, 0, 32);
|
|
476
|
+
return key;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* XChaCha20-Poly1305 패킷 암호화
|
|
481
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
482
|
+
* magicLen: 2 + (key[31] & 0xFF) % 14
|
|
483
|
+
*/
|
|
484
|
+
private byte[] encryptPacket(byte[] plaintext) throws Exception {
|
|
485
|
+
byte[] key = derivePacketKey();
|
|
486
|
+
int magicLen = 2 + (key[31] & 0xFF) % 14;
|
|
487
|
+
byte[] magic = new byte[magicLen];
|
|
488
|
+
byte[] nonce = new byte[24];
|
|
489
|
+
SecureRandom rng = new SecureRandom();
|
|
490
|
+
rng.nextBytes(magic);
|
|
491
|
+
rng.nextBytes(nonce);
|
|
492
|
+
|
|
493
|
+
ChaCha20Poly1305 aead = new ChaCha20Poly1305();
|
|
494
|
+
aead.init(true, new AEADParameters(new KeyParameter(key), 128, nonce));
|
|
495
|
+
byte[] ciphertext = new byte[aead.getOutputSize(plaintext.length)];
|
|
496
|
+
int len = aead.processBytes(plaintext, 0, plaintext.length, ciphertext, 0);
|
|
497
|
+
aead.doFinal(ciphertext, len);
|
|
498
|
+
|
|
499
|
+
byte[] result = new byte[magic.length + nonce.length + ciphertext.length];
|
|
500
|
+
System.arraycopy(magic, 0, result, 0, magic.length);
|
|
501
|
+
System.arraycopy(nonce, 0, result, magic.length, nonce.length);
|
|
502
|
+
System.arraycopy(ciphertext, 0, result, magic.length + nonce.length, ciphertext.length);
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
|
|
254
506
|
/**
|
|
255
507
|
* XChaCha20-Poly1305 패킷 복호화
|
|
256
508
|
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
257
|
-
* 키:
|
|
258
|
-
*
|
|
259
|
-
* Bouncy Castle ChaCha20Poly1305 사용 (nonce 24바이트 → 자동으로 XChaCha20 선택)
|
|
509
|
+
* 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
260
510
|
*/
|
|
261
511
|
private String decryptPacket(byte[] data) throws Exception {
|
|
262
|
-
byte[] key
|
|
263
|
-
|
|
264
|
-
byte[]
|
|
512
|
+
byte[] key = derivePacketKey();
|
|
513
|
+
int magicLen = 2 + (key[31] & 0xFF) % 14;
|
|
514
|
+
byte[] nonce = Arrays.copyOfRange(data, magicLen, magicLen + 24);
|
|
515
|
+
byte[] ctext = Arrays.copyOfRange(data, magicLen + 24, data.length);
|
|
265
516
|
|
|
266
517
|
ChaCha20Poly1305 aead = new ChaCha20Poly1305();
|
|
267
518
|
aead.init(false, new AEADParameters(new KeyParameter(key), 128, nonce));
|
|
@@ -272,23 +523,23 @@ public class EntityServerClient {
|
|
|
272
523
|
return new String(plaintext, StandardCharsets.UTF_8);
|
|
273
524
|
}
|
|
274
525
|
|
|
275
|
-
/**
|
|
276
|
-
|
|
526
|
+
/**
|
|
527
|
+
* HMAC-SHA256 서명. bodyBytes 는 JSON 또는 암호화된 바이너리 모두 지원합니다.
|
|
528
|
+
*/
|
|
529
|
+
private String sign(String method, String path, String timestamp, String nonce, byte[] bodyBytes) {
|
|
277
530
|
try {
|
|
278
|
-
String payload = String.join("|", method, path, timestamp, nonce, body);
|
|
279
531
|
Mac mac = Mac.getInstance("HmacSHA256");
|
|
280
532
|
mac.init(new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
281
|
-
|
|
282
|
-
|
|
533
|
+
String prefix = method + "|" + path + "|" + timestamp + "|" + nonce + "|";
|
|
534
|
+
mac.update(prefix.getBytes(StandardCharsets.UTF_8));
|
|
535
|
+
if (bodyBytes != null && bodyBytes.length > 0) mac.update(bodyBytes);
|
|
536
|
+
byte[] hash = mac.doFinal();
|
|
537
|
+
return HexFormat.of().formatHex(hash);
|
|
283
538
|
} catch (Exception e) {
|
|
284
539
|
throw new RuntimeException("HMAC signing failed", e);
|
|
285
540
|
}
|
|
286
541
|
}
|
|
287
542
|
|
|
288
|
-
private static byte[] sha256(byte[] input) throws Exception {
|
|
289
|
-
return MessageDigest.getInstance("SHA-256").digest(input);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
543
|
private static byte[] readAllBytes(InputStream in) throws IOException {
|
|
293
544
|
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
|
294
545
|
byte[] chunk = new byte[8192];
|
|
@@ -301,4 +552,17 @@ public class EntityServerClient {
|
|
|
301
552
|
String v = System.getenv(key);
|
|
302
553
|
return (v != null && !v.isBlank()) ? v : defaultValue;
|
|
303
554
|
}
|
|
555
|
+
|
|
556
|
+
private static String jsonString(String value) {
|
|
557
|
+
if (value == null) {
|
|
558
|
+
return "null";
|
|
559
|
+
}
|
|
560
|
+
String escaped = value
|
|
561
|
+
.replace("\\", "\\\\")
|
|
562
|
+
.replace("\"", "\\\"")
|
|
563
|
+
.replace("\n", "\\n")
|
|
564
|
+
.replace("\r", "\\r")
|
|
565
|
+
.replace("\t", "\\t");
|
|
566
|
+
return "\"" + escaped + "\"";
|
|
567
|
+
}
|
|
304
568
|
}
|
|
@@ -31,10 +31,11 @@ public class EntityServerExample {
|
|
|
31
31
|
String updated = es.submit("product", "{\"seq\":1,\"name\":\"게이밍 노트북\",\"price\":2000000}");
|
|
32
32
|
System.out.println("Updated: " + updated);
|
|
33
33
|
|
|
34
|
-
//
|
|
34
|
+
// 커스텀 SQL 검색
|
|
35
35
|
String results = es.query("product",
|
|
36
|
-
"
|
|
37
|
-
|
|
36
|
+
"SELECT seq, name, category FROM product WHERE category = ?",
|
|
37
|
+
"[\"electronics\"]",
|
|
38
|
+
10
|
|
38
39
|
);
|
|
39
40
|
System.out.println("Query: " + results);
|
|
40
41
|
|