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
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
namespace App\Libraries;
|
|
4
4
|
|
|
5
|
+
use Config\EntityServer as EntityServerConfig;
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Entity Server 클라이언트 라이브러리 (CodeIgniter 4)
|
|
7
9
|
*
|
|
@@ -9,11 +11,11 @@ namespace App\Libraries;
|
|
|
9
11
|
*
|
|
10
12
|
* 설치: app/Libraries/EntityServer.php 에 배치
|
|
11
13
|
*
|
|
12
|
-
* 설정: app/Config/EntityServer.php
|
|
14
|
+
* 설정: app/Config/EntityServer.php 우선 (필요시 .env fallback)
|
|
13
15
|
* ENTITY_SERVER_URL=http://localhost:47200
|
|
14
16
|
* ENTITY_SERVER_API_KEY=your-api-key
|
|
15
17
|
* ENTITY_SERVER_HMAC_SECRET=your-hmac-secret
|
|
16
|
-
|
|
18
|
+
|
|
17
19
|
*
|
|
18
20
|
* 컨트롤러 사용법:
|
|
19
21
|
* $es = new \App\Libraries\EntityServer();
|
|
@@ -26,55 +28,158 @@ class EntityServer
|
|
|
26
28
|
private string $baseUrl;
|
|
27
29
|
private string $apiKey;
|
|
28
30
|
private string $hmacSecret;
|
|
31
|
+
private string $token = '';
|
|
29
32
|
private int $timeout;
|
|
30
|
-
private
|
|
33
|
+
private bool $requireEncryptedRequest;
|
|
34
|
+
private bool $encryptRequests;
|
|
35
|
+
private bool $packetEncryption = false;
|
|
31
36
|
private ?string $activeTxId = null;
|
|
32
37
|
|
|
33
38
|
public function __construct(
|
|
34
|
-
string $baseUrl =
|
|
35
|
-
string $apiKey =
|
|
36
|
-
string $hmacSecret =
|
|
37
|
-
|
|
38
|
-
int $
|
|
39
|
+
?string $baseUrl = null,
|
|
40
|
+
?string $apiKey = null,
|
|
41
|
+
?string $hmacSecret = null,
|
|
42
|
+
?string $token = null,
|
|
43
|
+
?int $timeout = null,
|
|
44
|
+
?bool $requireEncryptedRequest = null,
|
|
45
|
+
?bool $encryptRequests = null
|
|
39
46
|
) {
|
|
40
|
-
$
|
|
41
|
-
|
|
42
|
-
$
|
|
43
|
-
$
|
|
44
|
-
$
|
|
47
|
+
$config = class_exists(EntityServerConfig::class) ? new EntityServerConfig() : null;
|
|
48
|
+
|
|
49
|
+
$configBaseUrl = $config?->baseUrl ?? env('ENTITY_SERVER_URL', 'http://localhost:47200');
|
|
50
|
+
$configApiKey = $config?->apiKey ?? env('ENTITY_SERVER_API_KEY', '');
|
|
51
|
+
$configHmacSecret = $config?->hmacSecret ?? env('ENTITY_SERVER_HMAC_SECRET', '');
|
|
52
|
+
$configToken = $config?->token ?? env('ENTITY_SERVER_TOKEN', '');
|
|
53
|
+
$configTimeout = $config?->timeout ?? (int) env('ENTITY_SERVER_TIMEOUT', 10);
|
|
54
|
+
$configRequireEncrypted = $config?->requireEncryptedRequest ?? true;
|
|
55
|
+
$configEncryptRequests = $config?->encryptRequests ?? false;
|
|
56
|
+
|
|
57
|
+
$this->baseUrl = rtrim($baseUrl ?? (string) $configBaseUrl, '/');
|
|
58
|
+
$this->apiKey = (string) ($apiKey ?? $configApiKey);
|
|
59
|
+
$this->hmacSecret = (string) ($hmacSecret ?? $configHmacSecret);
|
|
60
|
+
$this->token = (string) ($token ?? $configToken);
|
|
61
|
+
$this->timeout = (int) ($timeout ?? $configTimeout);
|
|
62
|
+
$this->requireEncryptedRequest = (bool) ($requireEncryptedRequest ?? $configRequireEncrypted);
|
|
63
|
+
$this->encryptRequests = (bool) ($encryptRequests ?? $configEncryptRequests);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용합니다. */
|
|
67
|
+
public function setToken(string $token): void
|
|
68
|
+
{
|
|
69
|
+
$this->token = $token;
|
|
45
70
|
}
|
|
46
71
|
|
|
47
72
|
// ─── CRUD ────────────────────────────────────────────────────────────────
|
|
48
73
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
74
|
+
/** * 서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
75
|
+
* 서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다.
|
|
76
|
+
*/
|
|
77
|
+
public function checkHealth(): array
|
|
51
78
|
{
|
|
52
|
-
|
|
79
|
+
$ch = curl_init($this->baseUrl . '/v1/health');
|
|
80
|
+
curl_setopt_array($ch, [
|
|
81
|
+
CURLOPT_RETURNTRANSFER => true,
|
|
82
|
+
CURLOPT_TIMEOUT => $this->timeout,
|
|
83
|
+
]);
|
|
84
|
+
$response = curl_exec($ch);
|
|
85
|
+
curl_close($ch);
|
|
86
|
+
$decoded = json_decode($response, true) ?? [];
|
|
87
|
+
if (!empty($decoded['packet_encryption'])) {
|
|
88
|
+
$this->packetEncryption = true;
|
|
89
|
+
}
|
|
90
|
+
return $decoded;
|
|
53
91
|
}
|
|
54
92
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
93
|
+
/** * 단건 조회
|
|
94
|
+
*
|
|
95
|
+
* @param bool $skipHooks true 이면 after_get 훅 미실행
|
|
96
|
+
*/
|
|
97
|
+
public function get(string $entity, int $seq, bool $skipHooks = false): array
|
|
57
98
|
{
|
|
58
|
-
$
|
|
59
|
-
return $this->request('GET', "/v1/entity/{$entity}/
|
|
99
|
+
$q = $skipHooks ? '?skipHooks=true' : '';
|
|
100
|
+
return $this->request('GET', "/v1/entity/{$entity}/{$seq}{$q}");
|
|
60
101
|
}
|
|
61
102
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
103
|
+
/**
|
|
104
|
+
* 조건으로 단건 조회 (POST + conditions body)
|
|
105
|
+
*
|
|
106
|
+
* @param array $conditions 필터 조건. index/hash/unique 필드만 사용 가능.
|
|
107
|
+
* 예: ['email' => 'user@example.com']
|
|
108
|
+
* @param bool $skipHooks true 이면 after_find 훅 미실행
|
|
109
|
+
*/
|
|
110
|
+
public function find(string $entity, array $conditions, bool $skipHooks = false): array
|
|
64
111
|
{
|
|
65
|
-
|
|
112
|
+
$q = $skipHooks ? '?skipHooks=true' : '';
|
|
113
|
+
return $this->request('POST', "/v1/entity/{$entity}/find{$q}", $conditions);
|
|
66
114
|
}
|
|
67
115
|
|
|
68
116
|
/**
|
|
69
|
-
*
|
|
117
|
+
* 목록 조회
|
|
70
118
|
*
|
|
71
|
-
* @param array $
|
|
72
|
-
* @param array $
|
|
119
|
+
* @param array $params 페이지/정렬 파라미터 (page, limit, orderBy, orderDir, fields)
|
|
120
|
+
* @param array $conditions 필터 조건 POST body. index/hash/unique 필드만 사용 가능.
|
|
121
|
+
* 예: ['status' => 'active']
|
|
122
|
+
* fields 예: ['*'] 시 전체 필드 반환, 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름)
|
|
123
|
+
* fields 예: ['name','email'] 또는 미지정
|
|
73
124
|
*/
|
|
74
|
-
public function
|
|
125
|
+
public function list(string $entity, array $params = [], array $conditions = []): array
|
|
75
126
|
{
|
|
76
|
-
$
|
|
77
|
-
|
|
127
|
+
$queryParams = array_merge(['page' => 1, 'limit' => 20], $params);
|
|
128
|
+
|
|
129
|
+
// orderBy + orderDir → orderBy 앞에 - 접두사 방식으로 변환
|
|
130
|
+
if (isset($queryParams['orderDir'])) {
|
|
131
|
+
$dir = strtoupper((string) $queryParams['orderDir']);
|
|
132
|
+
$orderBy = (string) ($queryParams['orderBy'] ?? '');
|
|
133
|
+
if ($dir === 'DESC' && $orderBy !== '') {
|
|
134
|
+
$queryParams['orderBy'] = '-' . ltrim($orderBy, '-');
|
|
135
|
+
}
|
|
136
|
+
unset($queryParams['orderDir']);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// fields 배열 → 쉼표 구분 문자열
|
|
140
|
+
if (isset($queryParams['fields']) && is_array($queryParams['fields'])) {
|
|
141
|
+
$queryParams['fields'] = implode(',', $queryParams['fields']);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
$query = http_build_query($queryParams);
|
|
145
|
+
return $this->request('POST', "/v1/entity/{$entity}/list?{$query}", $conditions);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 건수 조회
|
|
150
|
+
*
|
|
151
|
+
* @param array $conditions 필터 조건 (list()와 동일 규칙)
|
|
152
|
+
*/
|
|
153
|
+
public function count(string $entity, array $conditions = []): array
|
|
154
|
+
{
|
|
155
|
+
return $this->request('POST', "/v1/entity/{$entity}/count", $conditions);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만)
|
|
160
|
+
*
|
|
161
|
+
* - SELECT 쿼리만 허용 (INSERT/UPDATE/DELETE 불가)
|
|
162
|
+
* - 인덱스 테이블(`entity_idx_*`)만 접근 가능. SELECT * 불가
|
|
163
|
+
* - JOIN 지원. 최대 반환 건수 1000
|
|
164
|
+
* - 사용자 입력은 반드시 params 로 바인딩 (SQL Injection 방지)
|
|
165
|
+
*
|
|
166
|
+
* @param string $entity URL 라우트용 기본 엔티티명
|
|
167
|
+
* @param string $sql SELECT SQL
|
|
168
|
+
* @param array $params ? 플레이스홀더 바인딩 값
|
|
169
|
+
* @param int|null $limit 최대 반환 건수 (최대 1000)
|
|
170
|
+
*
|
|
171
|
+
* 예:
|
|
172
|
+
* $es->query('order',
|
|
173
|
+
* 'SELECT o.seq, o.status, u.name FROM order o JOIN account u ON u.data_seq = o.account_seq WHERE o.status = ?',
|
|
174
|
+
* ['pending'], 100);
|
|
175
|
+
*/
|
|
176
|
+
public function query(string $entity, string $sql, array $params = [], ?int $limit = null): array
|
|
177
|
+
{
|
|
178
|
+
$body = ['sql' => $sql, 'params' => $params];
|
|
179
|
+
if ($limit !== null) {
|
|
180
|
+
$body['limit'] = $limit;
|
|
181
|
+
}
|
|
182
|
+
return $this->request('POST', "/v1/entity/{$entity}/query", $body);
|
|
78
183
|
}
|
|
79
184
|
|
|
80
185
|
/**
|
|
@@ -121,26 +226,39 @@ class EntityServer
|
|
|
121
226
|
* 생성 또는 수정
|
|
122
227
|
* - body에 'seq' 포함 → 수정
|
|
123
228
|
* - body에 'seq' 없음 → 생성 (seq 반환)
|
|
229
|
+
* - unique 필드 기준 중복 시 자동 UPDATE (upsert)
|
|
230
|
+
*
|
|
124
231
|
* @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
232
|
+
* @param bool $skipHooks true 이면 before/after_insert, before/after_update 훅 미실행
|
|
125
233
|
*/
|
|
126
|
-
public function submit(string $entity, array $data, ?string $transactionId = null): array
|
|
234
|
+
public function submit(string $entity, array $data, ?string $transactionId = null, bool $skipHooks = false): array
|
|
127
235
|
{
|
|
128
236
|
$txId = $transactionId ?? $this->activeTxId;
|
|
129
237
|
$extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
|
|
130
|
-
|
|
238
|
+
$q = $skipHooks ? '?skipHooks=true' : '';
|
|
239
|
+
return $this->request('POST', "/v1/entity/{$entity}/submit{$q}", $data, $extra);
|
|
131
240
|
}
|
|
132
241
|
|
|
133
242
|
/**
|
|
134
|
-
* 삭제
|
|
135
|
-
*
|
|
243
|
+
* 삭제 (서버는 POST /delete/:seq 로만 처리)
|
|
244
|
+
*
|
|
245
|
+
* @param bool $hard true 이면 하드(물리) 삭제. false(기본) 이면 소프트 삭제 (rollback 으로 복원 가능)
|
|
136
246
|
* @param string|null $transactionId transStart() 로 얻은 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
247
|
+
* @param bool $skipHooks true 이면 before/after_delete 훅 미실행
|
|
137
248
|
*/
|
|
138
|
-
public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false): array
|
|
249
|
+
public function delete(string $entity, int $seq, ?string $transactionId = null, bool $hard = false, bool $skipHooks = false): array
|
|
139
250
|
{
|
|
140
|
-
$
|
|
251
|
+
$queryParams = [];
|
|
252
|
+
if ($hard) {
|
|
253
|
+
$queryParams[] = 'hard=true';
|
|
254
|
+
}
|
|
255
|
+
if ($skipHooks) {
|
|
256
|
+
$queryParams[] = 'skipHooks=true';
|
|
257
|
+
}
|
|
258
|
+
$q = $queryParams ? '?' . implode('&', $queryParams) : '';
|
|
141
259
|
$txId = $transactionId ?? $this->activeTxId;
|
|
142
260
|
$extra = $txId ? ['X-Transaction-ID: ' . $txId] : [];
|
|
143
|
-
return $this->request('
|
|
261
|
+
return $this->request('POST', "/v1/entity/{$entity}/delete/{$seq}{$q}", [], $extra);
|
|
144
262
|
}
|
|
145
263
|
|
|
146
264
|
/** 변경 이력 조회 */
|
|
@@ -155,22 +273,161 @@ class EntityServer
|
|
|
155
273
|
return $this->request('POST', "/v1/entity/{$entity}/rollback/{$historySeq}");
|
|
156
274
|
}
|
|
157
275
|
|
|
276
|
+
/**
|
|
277
|
+
* 푸시 발송 트리거 엔티티에 submit합니다.
|
|
278
|
+
*/
|
|
279
|
+
public function push(string $pushEntity, array $payload, ?string $transactionId = null): array
|
|
280
|
+
{
|
|
281
|
+
return $this->submit($pushEntity, $payload, $transactionId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* push_log 목록 조회 헬퍼
|
|
286
|
+
*/
|
|
287
|
+
public function pushLogList(array $params = []): array
|
|
288
|
+
{
|
|
289
|
+
return $this->list('push_log', $params);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 디바이스 등록/갱신 헬퍼 (push_token 단일 필드)
|
|
294
|
+
*
|
|
295
|
+
* - 기본 대상 엔티티: account_device
|
|
296
|
+
* - 신규 등록: seq 미전달
|
|
297
|
+
* - 기존 레코드 갱신: options['seq'] 전달
|
|
298
|
+
*
|
|
299
|
+
* @param int $accountSeq 계정 seq
|
|
300
|
+
* @param string $deviceId 디바이스 고유 ID (account_device.id)
|
|
301
|
+
* @param string $pushToken 푸시 디바이스 토큰
|
|
302
|
+
* @param array $options 추가 필드 (예: platform, device_type, browser, push_enabled, seq)
|
|
303
|
+
* @param string|null $transactionId transStart()로 얻은 트랜잭션 ID
|
|
304
|
+
*/
|
|
305
|
+
public function registerPushDevice(
|
|
306
|
+
int $accountSeq,
|
|
307
|
+
string $deviceId,
|
|
308
|
+
string $pushToken,
|
|
309
|
+
array $options = [],
|
|
310
|
+
?string $transactionId = null
|
|
311
|
+
): array {
|
|
312
|
+
$payload = array_merge([
|
|
313
|
+
'id' => $deviceId,
|
|
314
|
+
'account_seq' => $accountSeq,
|
|
315
|
+
'push_token' => $pushToken,
|
|
316
|
+
'push_enabled' => true,
|
|
317
|
+
], $options);
|
|
318
|
+
|
|
319
|
+
return $this->submit('account_device', $payload, $transactionId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* account_device.seq 기준으로 push_token 갱신
|
|
324
|
+
*/
|
|
325
|
+
public function updatePushDeviceToken(
|
|
326
|
+
int $deviceSeq,
|
|
327
|
+
string $pushToken,
|
|
328
|
+
bool $pushEnabled = true,
|
|
329
|
+
?string $transactionId = null
|
|
330
|
+
): array {
|
|
331
|
+
return $this->submit('account_device', [
|
|
332
|
+
'seq' => $deviceSeq,
|
|
333
|
+
'push_token' => $pushToken,
|
|
334
|
+
'push_enabled' => $pushEnabled,
|
|
335
|
+
], $transactionId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* account_device.seq 기준으로 푸시 수신 비활성화
|
|
340
|
+
*/
|
|
341
|
+
public function disablePushDevice(
|
|
342
|
+
int $deviceSeq,
|
|
343
|
+
?string $transactionId = null
|
|
344
|
+
): array {
|
|
345
|
+
return $this->submit('account_device', [
|
|
346
|
+
'seq' => $deviceSeq,
|
|
347
|
+
'push_enabled' => false,
|
|
348
|
+
], $transactionId);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* CI4 IncomingRequest에서 암호화 패킷을 읽어 JSON 배열로 복호화합니다.
|
|
353
|
+
*
|
|
354
|
+
* @param object $request CodeIgniter\HTTP\IncomingRequest
|
|
355
|
+
* @param bool $requireEncrypted true면 평문 JSON 요청을 거부합니다.
|
|
356
|
+
*/
|
|
357
|
+
public function readRequestBody(object $request, ?bool $requireEncrypted = null): array
|
|
358
|
+
{
|
|
359
|
+
$requireEncrypted = $requireEncrypted ?? $this->requireEncryptedRequest;
|
|
360
|
+
|
|
361
|
+
$contentType = '';
|
|
362
|
+
if (method_exists($request, 'getHeaderLine')) {
|
|
363
|
+
$contentType = strtolower((string) $request->getHeaderLine('Content-Type'));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
$rawBody = '';
|
|
367
|
+
if (method_exists($request, 'getBody')) {
|
|
368
|
+
$rawBody = (string) $request->getBody();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
$isEncryptedPacket = str_contains($contentType, 'application/octet-stream');
|
|
372
|
+
|
|
373
|
+
if ($requireEncrypted && !$isEncryptedPacket) {
|
|
374
|
+
throw new \RuntimeException('Encrypted request required: Content-Type must be application/octet-stream');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if ($isEncryptedPacket) {
|
|
378
|
+
if ($rawBody === '') {
|
|
379
|
+
throw new \RuntimeException('Encrypted request body is empty');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
$jsonStr = $this->decryptPacket($rawBody);
|
|
383
|
+
$decoded = json_decode($jsonStr, true);
|
|
384
|
+
if (!is_array($decoded)) {
|
|
385
|
+
throw new \RuntimeException('Invalid encrypted JSON payload');
|
|
386
|
+
}
|
|
387
|
+
return $decoded;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 선택적으로 평문 허용할 때만 fallback
|
|
391
|
+
if ($rawBody === '') {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
$decoded = json_decode($rawBody, true);
|
|
395
|
+
if (!is_array($decoded)) {
|
|
396
|
+
throw new \RuntimeException('Invalid JSON payload');
|
|
397
|
+
}
|
|
398
|
+
return $decoded;
|
|
399
|
+
}
|
|
400
|
+
|
|
158
401
|
// ─── 내부 ─────────────────────────────────────────────────────────────────
|
|
159
402
|
|
|
160
403
|
private function request(string $method, string $path, array $body = [], array $extraHeaders = []): array
|
|
161
404
|
{
|
|
162
|
-
|
|
163
|
-
$
|
|
164
|
-
$
|
|
165
|
-
$
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
'
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
405
|
+
// 요청 바디 결정: encryptRequests 시 POST 바디를 암호화
|
|
406
|
+
$bodyJson = empty($body) ? '' : json_encode($body, JSON_UNESCAPED_UNICODE);
|
|
407
|
+
$bodyData = $bodyJson;
|
|
408
|
+
$contentType = 'application/json';
|
|
409
|
+
if (($this->encryptRequests || $this->packetEncryption) && $bodyJson !== '') {
|
|
410
|
+
$bodyData = $this->encryptPacket($bodyJson);
|
|
411
|
+
$contentType = 'application/octet-stream';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
$isHmacMode = $this->apiKey !== '' && $this->hmacSecret !== '';
|
|
415
|
+
|
|
416
|
+
$headers = ["Content-Type: {$contentType}"];
|
|
417
|
+
if ($isHmacMode) {
|
|
418
|
+
$timestamp = (string) time();
|
|
419
|
+
$nonce = $this->generateNonce();
|
|
420
|
+
$signature = $this->sign($method, $path, $timestamp, $nonce, $bodyData);
|
|
421
|
+
$headers = array_merge($headers, [
|
|
422
|
+
'X-API-Key: ' . $this->apiKey,
|
|
423
|
+
'X-Timestamp: ' . $timestamp,
|
|
424
|
+
'X-Nonce: ' . $nonce,
|
|
425
|
+
'X-Signature: ' . $signature,
|
|
426
|
+
]);
|
|
427
|
+
} elseif ($this->token !== '') {
|
|
428
|
+
$headers[] = 'Authorization: Bearer ' . $this->token;
|
|
429
|
+
}
|
|
430
|
+
$headers = array_merge($headers, $extraHeaders);
|
|
174
431
|
|
|
175
432
|
$url = $this->baseUrl . $path;
|
|
176
433
|
$ch = curl_init($url);
|
|
@@ -182,13 +439,13 @@ class EntityServer
|
|
|
182
439
|
CURLOPT_TIMEOUT => $this->timeout,
|
|
183
440
|
]);
|
|
184
441
|
|
|
185
|
-
if ($
|
|
186
|
-
curl_setopt($ch, CURLOPT_POSTFIELDS, $
|
|
442
|
+
if ($bodyData !== '') {
|
|
443
|
+
curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyData);
|
|
187
444
|
}
|
|
188
445
|
|
|
189
446
|
$response = curl_exec($ch);
|
|
190
447
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
191
|
-
$
|
|
448
|
+
$respContentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
|
|
192
449
|
$error = curl_error($ch);
|
|
193
450
|
curl_close($ch);
|
|
194
451
|
|
|
@@ -197,7 +454,7 @@ class EntityServer
|
|
|
197
454
|
}
|
|
198
455
|
|
|
199
456
|
// 패킷 암호화 응답: application/octet-stream → 복호화
|
|
200
|
-
if (str_contains($
|
|
457
|
+
if (str_contains($respContentType, 'application/octet-stream')) {
|
|
201
458
|
$jsonStr = $this->decryptPacket($response);
|
|
202
459
|
$decoded = json_decode($jsonStr, true);
|
|
203
460
|
} else {
|
|
@@ -215,18 +472,51 @@ class EntityServer
|
|
|
215
472
|
return $decoded;
|
|
216
473
|
}
|
|
217
474
|
|
|
475
|
+
/**
|
|
476
|
+
* 패킷 암호화 키를 유도합니다.
|
|
477
|
+
* - HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
478
|
+
* - JWT 모드: SHA256(token)
|
|
479
|
+
*/
|
|
480
|
+
private function derivePacketKey(): string
|
|
481
|
+
{
|
|
482
|
+
if ($this->token !== '' && $this->hmacSecret === '') {
|
|
483
|
+
return hash('sha256', $this->token, true);
|
|
484
|
+
}
|
|
485
|
+
$salt = 'entity-server:hkdf:v1';
|
|
486
|
+
$info = 'entity-server:packet-encryption';
|
|
487
|
+
// HKDF-Extract: PRK = HMAC-SHA256(salt, IKM)
|
|
488
|
+
$prk = hash_hmac('sha256', $this->hmacSecret, $salt, true);
|
|
489
|
+
// HKDF-Expand(PRK, info, 32): T(1) = HMAC-SHA256(PRK, info || 0x01)
|
|
490
|
+
return substr(hash_hmac('sha256', $info . chr(1), $prk, true), 0, 32);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* XChaCha20-Poly1305 패킷 암호화
|
|
495
|
+
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
496
|
+
*/
|
|
497
|
+
private function encryptPacket(string $plaintext): string
|
|
498
|
+
{
|
|
499
|
+
$key = $this->derivePacketKey();
|
|
500
|
+
$magicLen = 2 + (ord($key[31]) % 14);
|
|
501
|
+
$magic = random_bytes($magicLen);
|
|
502
|
+
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
|
|
503
|
+
$ct = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $key);
|
|
504
|
+
return $magic . $nonce . $ct;
|
|
505
|
+
}
|
|
506
|
+
|
|
218
507
|
/**
|
|
219
508
|
* XChaCha20-Poly1305 패킷 복호화
|
|
220
509
|
* 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
|
|
221
|
-
* 키:
|
|
510
|
+
* 키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
222
511
|
*
|
|
223
512
|
* ext-sodium 사용 (PHP 7.2+ 내장)
|
|
224
513
|
*/
|
|
225
514
|
private function decryptPacket(string $data): string
|
|
226
515
|
{
|
|
227
|
-
$key =
|
|
228
|
-
$
|
|
229
|
-
$
|
|
516
|
+
$key = $this->derivePacketKey();
|
|
517
|
+
$magicLen = 2 + (ord($key[31]) % 14);
|
|
518
|
+
$nonce = substr($data, $magicLen, 24);
|
|
519
|
+
$ciphertext = substr($data, $magicLen + 24);
|
|
230
520
|
|
|
231
521
|
$plaintext = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($ciphertext, '', $nonce, $key);
|
|
232
522
|
if ($plaintext === false) {
|
|
@@ -235,12 +525,14 @@ class EntityServer
|
|
|
235
525
|
return $plaintext;
|
|
236
526
|
}
|
|
237
527
|
|
|
238
|
-
/**
|
|
528
|
+
/**
|
|
529
|
+
* HMAC-SHA256 서명. $body 는 JSON 스트링 또는 바이너리 암호화 페이로드 모두 지원합니다.
|
|
530
|
+
* prefix = "METHOD|path|timestamp|nonce|" 뒤에 $body 를 바로 이어 붙여 서명합니다.
|
|
531
|
+
*/
|
|
239
532
|
private function sign(string $method, string $path, string $timestamp, string $nonce, string $body): string
|
|
240
533
|
{
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return hash_hmac('sha256', $payload, $this->hmacSecret);
|
|
534
|
+
$prefix = implode('|', [$method, $path, $timestamp, $nonce]) . '|';
|
|
535
|
+
return hash_hmac('sha256', $prefix . $body, $this->hmacSecret);
|
|
244
536
|
}
|
|
245
537
|
|
|
246
538
|
private function generateNonce(): string
|