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
|
@@ -8,7 +8,6 @@ Entity Server 클라이언트 (Python)
|
|
|
8
8
|
ENTITY_SERVER_URL http://localhost:47200
|
|
9
9
|
ENTITY_SERVER_API_KEY your-api-key
|
|
10
10
|
ENTITY_SERVER_HMAC_SECRET your-hmac-secret
|
|
11
|
-
ENTITY_PACKET_MAGIC_LEN 4 (서버 packet_magic_len 과 동일)
|
|
12
11
|
|
|
13
12
|
사용 예:
|
|
14
13
|
es = EntityServerClient()
|
|
@@ -40,25 +39,36 @@ from typing import Any
|
|
|
40
39
|
|
|
41
40
|
import requests
|
|
42
41
|
from cryptography.hazmat.primitives.ciphers.aead import XChaCha20Poly1305
|
|
42
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
43
|
+
from cryptography.hazmat.primitives import hashes
|
|
44
|
+
|
|
45
|
+
import secrets as _secrets
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
class EntityServerClient:
|
|
46
49
|
def __init__(
|
|
47
50
|
self,
|
|
48
|
-
base_url:
|
|
49
|
-
api_key:
|
|
50
|
-
hmac_secret:
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
base_url: str = "",
|
|
52
|
+
api_key: str = "",
|
|
53
|
+
hmac_secret: str = "",
|
|
54
|
+
token: str = "",
|
|
55
|
+
timeout: int = 10,
|
|
56
|
+
encrypt_requests: bool = False,
|
|
53
57
|
) -> None:
|
|
54
|
-
self.base_url
|
|
55
|
-
self.api_key
|
|
56
|
-
self.hmac_secret
|
|
57
|
-
self.
|
|
58
|
-
self.
|
|
59
|
-
self.
|
|
58
|
+
self.base_url = (base_url or os.getenv("ENTITY_SERVER_URL", "http://localhost:47200")).rstrip("/")
|
|
59
|
+
self.api_key = api_key or os.getenv("ENTITY_SERVER_API_KEY", "")
|
|
60
|
+
self.hmac_secret = hmac_secret or os.getenv("ENTITY_SERVER_HMAC_SECRET", "")
|
|
61
|
+
self.token = token or os.getenv("ENTITY_SERVER_TOKEN", "")
|
|
62
|
+
self.timeout = timeout
|
|
63
|
+
self.encrypt_requests = encrypt_requests
|
|
64
|
+
self._packet_encryption: bool = False
|
|
65
|
+
self._session = requests.Session()
|
|
60
66
|
self._active_tx_id: str | None = None
|
|
61
67
|
|
|
68
|
+
def set_token(self, token: str) -> None:
|
|
69
|
+
"""JWT Bearer 토큰을 설정합니다. HMAC 모드와 배타적으로 사용해야 합니다."""
|
|
70
|
+
self.token = token
|
|
71
|
+
|
|
62
72
|
# ─── 트랜잭션 ──────────────────────────────────────────────────────────────
|
|
63
73
|
|
|
64
74
|
def trans_start(self) -> str:
|
|
@@ -89,58 +99,108 @@ class EntityServerClient:
|
|
|
89
99
|
return self._request("POST", f"/v1/transaction/commit/{tx_id}")
|
|
90
100
|
|
|
91
101
|
# ─── CRUD ─────────────────────────────────────────────────────────────────
|
|
102
|
+
def check_health(self) -> dict:
|
|
103
|
+
"""서버 헬스 체크를 수행하고 패킷 암호화 활성 여부를 자동으로 감지합니다.
|
|
104
|
+
서버가 packet_encryption: true 를 응답하면 이후 모든 요청에 암호화가 자동 적용됩니다."""
|
|
105
|
+
resp = self._session.get(self.base_url + "/v1/health", timeout=self.timeout)
|
|
106
|
+
data = resp.json()
|
|
107
|
+
if data.get("packet_encryption"):
|
|
108
|
+
self._packet_encryption = True
|
|
109
|
+
return data
|
|
110
|
+
def get(self, entity: str, seq: int, *, skip_hooks: bool = False) -> dict:
|
|
111
|
+
"""단건 조회. skip_hooks=True 이면 after_get 훅 미실행."""
|
|
112
|
+
q = "?skipHooks=true" if skip_hooks else ""
|
|
113
|
+
return self._request("GET", f"/v1/entity/{entity}/{seq}{q}")
|
|
114
|
+
|
|
115
|
+
def find(self, entity: str, conditions: dict, *, skip_hooks: bool = False) -> dict:
|
|
116
|
+
"""
|
|
117
|
+
조건으로 단건 조회 (POST + conditions body).
|
|
118
|
+
|
|
119
|
+
- conditions: index/hash/unique 필드에만 필터 조건 사용 가능
|
|
120
|
+
- skip_hooks=True 이면 after_find 훅 미실행
|
|
121
|
+
"""
|
|
122
|
+
q = "?skipHooks=true" if skip_hooks else ""
|
|
123
|
+
return self._request("POST", f"/v1/entity/{entity}/find{q}", body=conditions)
|
|
92
124
|
|
|
93
|
-
def
|
|
94
|
-
|
|
95
|
-
|
|
125
|
+
def list(
|
|
126
|
+
self,
|
|
127
|
+
entity: str,
|
|
128
|
+
page: int = 1,
|
|
129
|
+
limit: int = 20,
|
|
130
|
+
order_by: str | None = None,
|
|
131
|
+
order_dir: str | None = None,
|
|
132
|
+
fields: list[str] | None = None,
|
|
133
|
+
conditions: dict | None = None,
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""
|
|
136
|
+
목록 조회 (POST + conditions body)
|
|
96
137
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
138
|
+
- fields: 미지정 시 인덱스 필드만 반환 (기본, 가장 빠름). ['*'] 지정 시 전체 필드 반환
|
|
139
|
+
- conditions: index/hash/unique 필드에만 필터 조건 사용 가능
|
|
140
|
+
"""
|
|
141
|
+
query_params: dict = {"page": page, "limit": limit}
|
|
100
142
|
if order_by:
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
query_params["order_by"] = f"-{order_by}" if order_dir == "DESC" else order_by
|
|
144
|
+
if fields:
|
|
145
|
+
query_params["fields"] = ",".join(fields)
|
|
146
|
+
return self._request("POST", f"/v1/entity/{entity}/list", body=conditions or {}, params=query_params)
|
|
103
147
|
|
|
104
|
-
def count(self, entity: str) -> dict:
|
|
105
|
-
"""건수
|
|
106
|
-
return self._request("
|
|
148
|
+
def count(self, entity: str, conditions: dict | None = None) -> dict:
|
|
149
|
+
"""건수 조회. conditions 는 list() 와 동일한 필터 규칙."""
|
|
150
|
+
return self._request("POST", f"/v1/entity/{entity}/count", body=conditions or {})
|
|
107
151
|
|
|
108
152
|
def query(
|
|
109
153
|
self,
|
|
110
|
-
entity:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
limit:
|
|
114
|
-
order_by: str | None = None,
|
|
154
|
+
entity: str,
|
|
155
|
+
sql: str,
|
|
156
|
+
params: list | None = None,
|
|
157
|
+
limit: int | None = None,
|
|
115
158
|
) -> dict:
|
|
116
159
|
"""
|
|
117
|
-
|
|
118
|
-
|
|
160
|
+
커스텀 SQL 조회 (SELECT 전용, 인덱스 테이블만, JOIN 지원)
|
|
161
|
+
|
|
162
|
+
- SELECT 쿼리만 허용. SELECT * 불가. 최대 1000건.
|
|
163
|
+
- 사용자 입력은 반드시 params 로 바인딩 (SQL Injection 방지)
|
|
164
|
+
|
|
165
|
+
예::
|
|
166
|
+
es.query(
|
|
167
|
+
'order',
|
|
168
|
+
'SELECT o.seq, u.name FROM order o JOIN account u ON u.data_seq = o.account_seq WHERE o.status = ?',
|
|
169
|
+
params=['pending'],
|
|
170
|
+
limit=100,
|
|
171
|
+
)
|
|
119
172
|
"""
|
|
120
|
-
|
|
121
|
-
if
|
|
122
|
-
|
|
123
|
-
return self._request("POST", f"/v1/entity/{entity}/query", body=
|
|
173
|
+
body: dict[str, Any] = {"sql": sql, "params": params or []}
|
|
174
|
+
if limit is not None:
|
|
175
|
+
body["limit"] = limit
|
|
176
|
+
return self._request("POST", f"/v1/entity/{entity}/query", body=body)
|
|
124
177
|
|
|
125
|
-
def submit(self, entity: str, data: dict, *, transaction_id: str | None = None) -> dict:
|
|
178
|
+
def submit(self, entity: str, data: dict, *, transaction_id: str | None = None, skip_hooks: bool = False) -> dict:
|
|
126
179
|
"""
|
|
127
180
|
생성 또는 수정
|
|
128
181
|
data에 'seq' 포함 시 수정, 없으면 생성
|
|
129
182
|
:param transaction_id: trans_start() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
183
|
+
:param skip_hooks: True 이면 before/after_insert, before/after_update 훅 미실행
|
|
130
184
|
"""
|
|
131
185
|
tx_id = transaction_id or self._active_tx_id
|
|
132
186
|
extra = {"X-Transaction-ID": tx_id} if tx_id else {}
|
|
133
|
-
|
|
187
|
+
q = "?skipHooks=true" if skip_hooks else ""
|
|
188
|
+
return self._request("POST", f"/v1/entity/{entity}/submit{q}", body=data, extra_headers=extra)
|
|
134
189
|
|
|
135
|
-
def delete(self, entity: str, seq: int, *, transaction_id: str | None = None, hard: bool = False) -> dict:
|
|
136
|
-
"""
|
|
190
|
+
def delete(self, entity: str, seq: int, *, transaction_id: str | None = None, hard: bool = False, skip_hooks: bool = False) -> dict:
|
|
191
|
+
"""
|
|
192
|
+
삭제
|
|
137
193
|
:param transaction_id: trans_start() 가 반환한 ID (생략 시 활성 트랜잭션 자동 사용)
|
|
138
|
-
:param hard: True 시 하드 삭제
|
|
194
|
+
:param hard: True 시 하드(물리) 삭제. False(기본) 이면 소프트 삭제 (rollback 으로 복원 가능)
|
|
195
|
+
:param skip_hooks: True 이면 before/after_delete 훅 미실행
|
|
139
196
|
"""
|
|
140
|
-
|
|
197
|
+
query_parts: list[str] = []
|
|
198
|
+
if hard: query_parts.append("hard=true")
|
|
199
|
+
if skip_hooks: query_parts.append("skipHooks=true")
|
|
200
|
+
q = "?" + "&".join(query_parts) if query_parts else ""
|
|
141
201
|
tx_id = transaction_id or self._active_tx_id
|
|
142
202
|
extra = {"X-Transaction-ID": tx_id} if tx_id else {}
|
|
143
|
-
return self._request("
|
|
203
|
+
return self._request("POST", f"/v1/entity/{entity}/delete/{seq}{q}", extra_headers=extra)
|
|
144
204
|
|
|
145
205
|
def history(self, entity: str, seq: int, page: int = 1, limit: int = 50) -> dict:
|
|
146
206
|
"""변경 이력 조회"""
|
|
@@ -150,6 +210,112 @@ class EntityServerClient:
|
|
|
150
210
|
"""history seq 단위 롤백 (단건)"""
|
|
151
211
|
return self._request("POST", f"/v1/entity/{entity}/rollback/{history_seq}")
|
|
152
212
|
|
|
213
|
+
def push(self, push_entity: str, payload: dict, *, transaction_id: str | None = None) -> dict:
|
|
214
|
+
"""푸시 발송 트리거 엔티티에 submit합니다."""
|
|
215
|
+
return self.submit(push_entity, payload, transaction_id=transaction_id)
|
|
216
|
+
|
|
217
|
+
def push_log_list(self, page: int = 1, limit: int = 20, order_by: str | None = None) -> dict:
|
|
218
|
+
"""push_log 목록 조회 헬퍼"""
|
|
219
|
+
return self.list("push_log", page=page, limit=limit, order_by=order_by)
|
|
220
|
+
|
|
221
|
+
def register_push_device(
|
|
222
|
+
self,
|
|
223
|
+
account_seq: int,
|
|
224
|
+
device_id: str,
|
|
225
|
+
push_token: str,
|
|
226
|
+
*,
|
|
227
|
+
platform: str | None = None,
|
|
228
|
+
device_type: str | None = None,
|
|
229
|
+
browser: str | None = None,
|
|
230
|
+
browser_version: str | None = None,
|
|
231
|
+
push_enabled: bool = True,
|
|
232
|
+
transaction_id: str | None = None,
|
|
233
|
+
) -> dict:
|
|
234
|
+
"""account_device 디바이스 등록/갱신 헬퍼 (push_token 단일 필드)"""
|
|
235
|
+
payload: dict[str, Any] = {
|
|
236
|
+
"id": device_id,
|
|
237
|
+
"account_seq": account_seq,
|
|
238
|
+
"push_token": push_token,
|
|
239
|
+
"push_enabled": push_enabled,
|
|
240
|
+
}
|
|
241
|
+
if platform:
|
|
242
|
+
payload["platform"] = platform
|
|
243
|
+
if device_type:
|
|
244
|
+
payload["device_type"] = device_type
|
|
245
|
+
if browser:
|
|
246
|
+
payload["browser"] = browser
|
|
247
|
+
if browser_version:
|
|
248
|
+
payload["browser_version"] = browser_version
|
|
249
|
+
return self.submit("account_device", payload, transaction_id=transaction_id)
|
|
250
|
+
|
|
251
|
+
def update_push_device_token(
|
|
252
|
+
self,
|
|
253
|
+
device_seq: int,
|
|
254
|
+
push_token: str,
|
|
255
|
+
*,
|
|
256
|
+
push_enabled: bool = True,
|
|
257
|
+
transaction_id: str | None = None,
|
|
258
|
+
) -> dict:
|
|
259
|
+
"""account_device.seq 기준 push_token 갱신 헬퍼"""
|
|
260
|
+
return self.submit(
|
|
261
|
+
"account_device",
|
|
262
|
+
{
|
|
263
|
+
"seq": device_seq,
|
|
264
|
+
"push_token": push_token,
|
|
265
|
+
"push_enabled": push_enabled,
|
|
266
|
+
},
|
|
267
|
+
transaction_id=transaction_id,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def disable_push_device(
|
|
271
|
+
self,
|
|
272
|
+
device_seq: int,
|
|
273
|
+
*,
|
|
274
|
+
transaction_id: str | None = None,
|
|
275
|
+
) -> dict:
|
|
276
|
+
"""account_device.seq 기준 푸시 수신 비활성화 헬퍼"""
|
|
277
|
+
return self.submit(
|
|
278
|
+
"account_device",
|
|
279
|
+
{
|
|
280
|
+
"seq": device_seq,
|
|
281
|
+
"push_enabled": False,
|
|
282
|
+
},
|
|
283
|
+
transaction_id=transaction_id,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def read_request_body(
|
|
287
|
+
self,
|
|
288
|
+
raw_body: bytes | str | None,
|
|
289
|
+
content_type: str = "application/json",
|
|
290
|
+
*,
|
|
291
|
+
require_encrypted: bool = False,
|
|
292
|
+
) -> dict:
|
|
293
|
+
"""요청 본문을 읽어 JSON으로 반환합니다.
|
|
294
|
+
- application/octet-stream: 암호 패킷 복호화
|
|
295
|
+
- 그 외: 평문 JSON 파싱
|
|
296
|
+
"""
|
|
297
|
+
lowered = (content_type or "").lower()
|
|
298
|
+
is_encrypted = "application/octet-stream" in lowered
|
|
299
|
+
|
|
300
|
+
if require_encrypted and not is_encrypted:
|
|
301
|
+
raise RuntimeError(
|
|
302
|
+
"Encrypted request required: Content-Type must be application/octet-stream"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if is_encrypted:
|
|
306
|
+
if raw_body in (None, b"", ""):
|
|
307
|
+
raise RuntimeError("Encrypted request body is empty")
|
|
308
|
+
|
|
309
|
+
packet = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
|
|
310
|
+
return json.loads(self._decrypt_packet(packet))
|
|
311
|
+
|
|
312
|
+
if raw_body in (None, b"", ""):
|
|
313
|
+
return {}
|
|
314
|
+
|
|
315
|
+
if isinstance(raw_body, bytes):
|
|
316
|
+
return json.loads(raw_body.decode("utf-8"))
|
|
317
|
+
return json.loads(raw_body)
|
|
318
|
+
|
|
153
319
|
# ─── 내부 ─────────────────────────────────────────────────────────────────
|
|
154
320
|
|
|
155
321
|
def _request(
|
|
@@ -167,18 +333,35 @@ class EntityServerClient:
|
|
|
167
333
|
else:
|
|
168
334
|
signed_path = path
|
|
169
335
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
"
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
336
|
+
# 요청 바디 결정: encrypt_requests 시 POST 바디를 암호화
|
|
337
|
+
body_data: bytes | None = None # 네트워크로 보낼 바이트
|
|
338
|
+
body_for_sign: bytes = b"" # HMAC 서명 대상
|
|
339
|
+
content_type_header = "application/json"
|
|
340
|
+
|
|
341
|
+
if body is not None:
|
|
342
|
+
json_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
343
|
+
if self.encrypt_requests or self._packet_encryption:
|
|
344
|
+
encrypted = self._encrypt_packet(json_bytes)
|
|
345
|
+
body_data = encrypted
|
|
346
|
+
body_for_sign = encrypted
|
|
347
|
+
content_type_header = "application/octet-stream"
|
|
348
|
+
else:
|
|
349
|
+
body_data = json_bytes
|
|
350
|
+
body_for_sign = json_bytes
|
|
351
|
+
|
|
352
|
+
is_hmac_mode = bool(self.api_key and self.hmac_secret)
|
|
353
|
+
|
|
354
|
+
headers: dict = {"Content-Type": content_type_header}
|
|
355
|
+
if is_hmac_mode:
|
|
356
|
+
timestamp = str(int(time.time()))
|
|
357
|
+
nonce = str(uuid.uuid4())
|
|
358
|
+
signature = self._sign(method, signed_path, timestamp, nonce, body_for_sign)
|
|
359
|
+
headers["X-API-Key"] = self.api_key
|
|
360
|
+
headers["X-Timestamp"] = timestamp
|
|
361
|
+
headers["X-Nonce"] = nonce
|
|
362
|
+
headers["X-Signature"] = signature
|
|
363
|
+
elif self.token:
|
|
364
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
182
365
|
if extra_headers:
|
|
183
366
|
headers.update(extra_headers)
|
|
184
367
|
|
|
@@ -187,14 +370,14 @@ class EntityServerClient:
|
|
|
187
370
|
method=method,
|
|
188
371
|
url=url,
|
|
189
372
|
headers=headers,
|
|
190
|
-
data=
|
|
373
|
+
data=body_data,
|
|
191
374
|
params=params,
|
|
192
375
|
timeout=self.timeout,
|
|
193
376
|
)
|
|
194
377
|
|
|
195
378
|
# 패킷 암호화 응답: application/octet-stream → 복호화
|
|
196
|
-
|
|
197
|
-
if "application/octet-stream" in
|
|
379
|
+
ct = resp.headers.get("Content-Type", "")
|
|
380
|
+
if "application/octet-stream" in ct:
|
|
198
381
|
data = json.loads(self._decrypt_packet(resp.content))
|
|
199
382
|
else:
|
|
200
383
|
data = resp.json()
|
|
@@ -204,22 +387,58 @@ class EntityServerClient:
|
|
|
204
387
|
|
|
205
388
|
return data
|
|
206
389
|
|
|
390
|
+
def _derive_packet_key(self) -> bytes:
|
|
391
|
+
"""
|
|
392
|
+
패킷 암호화 키를 유도합니다.
|
|
393
|
+
- HMAC 모드: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
394
|
+
- JWT 모드: SHA256(token)
|
|
395
|
+
"""
|
|
396
|
+
if self.token and not self.hmac_secret:
|
|
397
|
+
return hashlib.sha256(self.token.encode("utf-8")).digest()
|
|
398
|
+
h = HKDF(
|
|
399
|
+
algorithm=hashes.SHA256(),
|
|
400
|
+
length=32,
|
|
401
|
+
salt=b"entity-server:hkdf:v1",
|
|
402
|
+
info=b"entity-server:packet-encryption",
|
|
403
|
+
)
|
|
404
|
+
return h.derive(self.hmac_secret.encode("utf-8"))
|
|
405
|
+
|
|
406
|
+
def _encrypt_packet(self, plaintext: bytes) -> bytes:
|
|
407
|
+
"""
|
|
408
|
+
XChaCha20-Poly1305 패킷 암호화
|
|
409
|
+
포맷: [magic:magic_len][nonce:24][ciphertext+tag]
|
|
410
|
+
magic_len: 2 + key[31] % 14 (패킷 키에서 자동 파생)
|
|
411
|
+
"""
|
|
412
|
+
key = self._derive_packet_key()
|
|
413
|
+
magic_len = 2 + key[31] % 14
|
|
414
|
+
magic = _secrets.token_bytes(magic_len)
|
|
415
|
+
nonce = _secrets.token_bytes(24)
|
|
416
|
+
ct = XChaCha20Poly1305(key).encrypt(nonce, plaintext, b"")
|
|
417
|
+
return magic + nonce + ct
|
|
418
|
+
|
|
207
419
|
def _decrypt_packet(self, data: bytes) -> bytes:
|
|
208
420
|
"""
|
|
209
421
|
XChaCha20-Poly1305 패킷 복호화
|
|
210
422
|
포맷: [magic:magic_len][nonce:24][ciphertext+tag]
|
|
211
|
-
키:
|
|
423
|
+
키: HKDF-SHA256(hmac_secret, "entity-server:packet-encryption")
|
|
212
424
|
"""
|
|
213
|
-
key =
|
|
214
|
-
|
|
215
|
-
|
|
425
|
+
key = self._derive_packet_key()
|
|
426
|
+
magic_len = 2 + key[31] % 14
|
|
427
|
+
nonce = data[magic_len : magic_len + 24]
|
|
428
|
+
ciphertext = data[magic_len + 24 :]
|
|
216
429
|
return XChaCha20Poly1305(key).decrypt(nonce, ciphertext, b"")
|
|
217
430
|
|
|
218
|
-
def _sign(self, method: str, path: str, timestamp: str, nonce: str, body: str) -> str:
|
|
219
|
-
"""
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
431
|
+
def _sign(self, method: str, path: str, timestamp: str, nonce: str, body: bytes | str) -> str:
|
|
432
|
+
"""
|
|
433
|
+
HMAC-SHA256 서명.
|
|
434
|
+
body 는 bytes(암호화된 바디 포함) 또는 str 모두 지원합니다.
|
|
435
|
+
"""
|
|
436
|
+
prefix = f"{method}|{path}|{timestamp}|{nonce}|".encode("utf-8")
|
|
437
|
+
h = hmac.new(key=self.hmac_secret.encode("utf-8"), digestmod=hashlib.sha256)
|
|
438
|
+
h.update(prefix)
|
|
439
|
+
if isinstance(body, bytes):
|
|
440
|
+
if body:
|
|
441
|
+
h.update(body)
|
|
442
|
+
elif body:
|
|
443
|
+
h.update(body.encode("utf-8"))
|
|
444
|
+
return h.hexdigest()
|
|
@@ -15,7 +15,7 @@ es = EntityServerClient()
|
|
|
15
15
|
|
|
16
16
|
# 목록 조회
|
|
17
17
|
result = es.list("product", page=1, limit=10)
|
|
18
|
-
print(f"List: {
|
|
18
|
+
print(f"List: {result.get('data', {}).get('total', 0)} items")
|
|
19
19
|
|
|
20
20
|
# 생성
|
|
21
21
|
created = es.submit("product", {
|
|
@@ -34,16 +34,17 @@ print(f"Get: {item['data']['name']}")
|
|
|
34
34
|
es.submit("product", {"seq": seq, "price": 39000})
|
|
35
35
|
print("Updated")
|
|
36
36
|
|
|
37
|
-
#
|
|
37
|
+
# 커스텀 SQL 검색
|
|
38
38
|
results = es.query("product",
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
sql="SELECT seq, name, category FROM product WHERE category = ?",
|
|
40
|
+
params=["peripherals"],
|
|
41
|
+
limit=5,
|
|
41
42
|
)
|
|
42
|
-
print(f"Query: {len(results.get('data', []))} results")
|
|
43
|
+
print(f"Query: {len(results.get('data', {}).get('items', []))} results")
|
|
43
44
|
|
|
44
45
|
# 이력 조회
|
|
45
46
|
hist = es.history("product", seq)
|
|
46
|
-
print(f"History: {
|
|
47
|
+
print(f"History: {hist.get('data', {}).get('total', 0)} entries")
|
|
47
48
|
|
|
48
49
|
# 삭제
|
|
49
50
|
es.delete("product", seq)
|
|
@@ -4,16 +4,15 @@
|
|
|
4
4
|
* 설정:
|
|
5
5
|
* 1. .env 에 VITE_ENTITY_SERVER_URL=http://localhost:47200 추가
|
|
6
6
|
* 2. 로그인 시 entityServer.login(email, password) 호출
|
|
7
|
-
* 3. 이후
|
|
7
|
+
* 3. 이후 client 메서드로 데이터 조회/수정
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "./hooks/useEntity";
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
import { useEffect, useState } from "react";
|
|
12
|
+
// @ts-ignore
|
|
13
|
+
import { useEntityServer } from "entity-server-client/react";
|
|
14
|
+
// @ts-ignore
|
|
15
|
+
import type { EntityListResult } from "entity-server-client";
|
|
17
16
|
|
|
18
17
|
interface Product {
|
|
19
18
|
seq: number;
|
|
@@ -25,27 +24,35 @@ interface Product {
|
|
|
25
24
|
// ─── 목록 컴포넌트 ─────────────────────────────────────────────────────────
|
|
26
25
|
|
|
27
26
|
export function ProductList() {
|
|
27
|
+
const { client, isPending, error, del } = useEntityServer();
|
|
28
28
|
const [page, setPage] = useState(1);
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
const [result, setResult] = useState<EntityListResult<Product> | null>(
|
|
30
|
+
null,
|
|
31
|
+
);
|
|
32
|
+
const [loading, setLoading] = useState(false);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
setLoading(true);
|
|
36
|
+
client
|
|
37
|
+
.list<Product>("product", { page, limit: 20 })
|
|
38
|
+
.then((r) => setResult(r.data))
|
|
39
|
+
.finally(() => setLoading(false));
|
|
40
|
+
}, [client, page]);
|
|
34
41
|
|
|
35
|
-
if (
|
|
36
|
-
if (error) return <p>에러: {
|
|
42
|
+
if (loading) return <p>로딩 중...</p>;
|
|
43
|
+
if (error) return <p>에러: {error.message}</p>;
|
|
37
44
|
|
|
38
45
|
return (
|
|
39
46
|
<div>
|
|
40
|
-
<h2>상품 목록 ({
|
|
47
|
+
<h2>상품 목록 ({result?.total ?? 0}건)</h2>
|
|
41
48
|
<ul>
|
|
42
|
-
{
|
|
49
|
+
{result?.items.map((item) => (
|
|
43
50
|
<li key={item.seq}>
|
|
44
51
|
[{item.seq}] {item.name} — {item.price.toLocaleString()}
|
|
45
52
|
원
|
|
46
53
|
<button
|
|
47
|
-
onClick={() =>
|
|
48
|
-
disabled={
|
|
54
|
+
onClick={() => del("product", item.seq)}
|
|
55
|
+
disabled={isPending}
|
|
49
56
|
>
|
|
50
57
|
삭제
|
|
51
58
|
</button>
|
|
@@ -67,10 +74,19 @@ export function ProductList() {
|
|
|
67
74
|
// ─── 단건 조회 컴포넌트 ─────────────────────────────────────────────────────
|
|
68
75
|
|
|
69
76
|
export function ProductDetail({ seq }: { seq: number }) {
|
|
70
|
-
const {
|
|
77
|
+
const { client } = useEntityServer();
|
|
78
|
+
const [item, setItem] = useState<Product | null>(null);
|
|
79
|
+
const [loading, setLoading] = useState(true);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
client
|
|
83
|
+
.get<Product>("product", seq)
|
|
84
|
+
.then((r) => setItem(r.data))
|
|
85
|
+
.catch(() => setItem(null))
|
|
86
|
+
.finally(() => setLoading(false));
|
|
87
|
+
}, [client, seq]);
|
|
71
88
|
|
|
72
|
-
if (
|
|
73
|
-
const item = data?.data;
|
|
89
|
+
if (loading) return <p>로딩 중...</p>;
|
|
74
90
|
if (!item) return <p>상품을 찾을 수 없습니다.</p>;
|
|
75
91
|
|
|
76
92
|
return (
|
|
@@ -85,13 +101,13 @@ export function ProductDetail({ seq }: { seq: number }) {
|
|
|
85
101
|
// ─── 생성/수정 폼 컴포넌트 ──────────────────────────────────────────────────
|
|
86
102
|
|
|
87
103
|
export function ProductForm({ seq }: { seq?: number }) {
|
|
88
|
-
const
|
|
104
|
+
const { submit, isPending } = useEntityServer();
|
|
89
105
|
const [form, setForm] = useState({ name: "", price: 0, category: "" });
|
|
90
106
|
|
|
91
107
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
92
108
|
e.preventDefault();
|
|
93
109
|
// seq 있으면 수정, 없으면 생성
|
|
94
|
-
await
|
|
110
|
+
await submit("product", seq ? { ...form, seq } : form);
|
|
95
111
|
alert(seq ? "수정 완료" : "등록 완료");
|
|
96
112
|
};
|
|
97
113
|
|
|
@@ -119,7 +135,7 @@ export function ProductForm({ seq }: { seq?: number }) {
|
|
|
119
135
|
setForm((f) => ({ ...f, category: e.target.value }))
|
|
120
136
|
}
|
|
121
137
|
/>
|
|
122
|
-
<button type="submit" disabled={
|
|
138
|
+
<button type="submit" disabled={isPending}>
|
|
123
139
|
{seq ? "수정" : "등록"}
|
|
124
140
|
</button>
|
|
125
141
|
</form>
|