nai-aclab 1.0.1 → 1.0.4

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/README.md CHANGED
@@ -121,6 +121,12 @@ override가 입력되어 있으면 모든 프리셋 조합에서 override 값을
121
121
 
122
122
  토큰을 넣기 전에는 목업 모드로 UI 흐름을 먼저 테스트할 수 있습니다.
123
123
 
124
+ API 토큰은 `app_state.json`에 저장하거나 `/api/state` 응답으로 다시 내려보내지 않습니다. 한 번 입력한 토큰은 별도 secret store에 저장되어 다음 실행 때 자동으로 사용됩니다.
125
+
126
+ 생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, UC 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다. 이전 버전에서 생성된 `*_request.json` 파일은 앱 시작 시 자동으로 제거됩니다.
127
+
128
+ 로컬 API는 앱이 시작될 때 생성되는 임시 토큰으로 보호됩니다. 또한 기본 NovelAI endpoint가 아닌 주소로 이미지를 생성하려는 경우, API 토큰이 해당 endpoint로 전송된다는 확인창을 먼저 표시합니다.
129
+
124
130
  ## 저장 위치
125
131
 
126
132
  앱 상태, 생성 이미지, 요청 JSON은 아래 위치에 저장됩니다.
package/app.py CHANGED
@@ -32,6 +32,7 @@ USER_DIR = Path(
32
32
  DATA_DIR = USER_DIR / "data"
33
33
  OUTPUT_DIR = USER_DIR / "outputs"
34
34
  STATE_PATH = DATA_DIR / "app_state.json"
35
+ TOKEN_SECRET_PATH = DATA_DIR / ("api_token.dpapi" if os.name == "nt" else "api_token.secret")
35
36
  DEFAULT_USER_AGENT = (
36
37
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
37
38
  "AppleWebKit/537.36 (KHTML, like Gecko) "
@@ -44,6 +45,127 @@ def ensure_dirs() -> None:
44
45
  OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
45
46
 
46
47
 
48
+ def _windows_protect(data: bytes) -> bytes:
49
+ import ctypes
50
+ from ctypes import wintypes
51
+
52
+ class DataBlob(ctypes.Structure):
53
+ _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
54
+
55
+ crypt32 = ctypes.windll.crypt32
56
+ kernel32 = ctypes.windll.kernel32
57
+ buffer = ctypes.create_string_buffer(data)
58
+ in_blob = DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
59
+ out_blob = DataBlob()
60
+ if not crypt32.CryptProtectData(ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob)):
61
+ raise OSError("Failed to protect API token with Windows DPAPI.")
62
+ try:
63
+ return ctypes.string_at(out_blob.pbData, out_blob.cbData)
64
+ finally:
65
+ kernel32.LocalFree(out_blob.pbData)
66
+
67
+
68
+ def _windows_unprotect(data: bytes) -> bytes:
69
+ import ctypes
70
+ from ctypes import wintypes
71
+
72
+ class DataBlob(ctypes.Structure):
73
+ _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
74
+
75
+ crypt32 = ctypes.windll.crypt32
76
+ kernel32 = ctypes.windll.kernel32
77
+ buffer = ctypes.create_string_buffer(data)
78
+ in_blob = DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)))
79
+ out_blob = DataBlob()
80
+ if not crypt32.CryptUnprotectData(ctypes.byref(in_blob), None, None, None, None, 0, ctypes.byref(out_blob)):
81
+ raise OSError("Failed to unprotect API token with Windows DPAPI.")
82
+ try:
83
+ return ctypes.string_at(out_blob.pbData, out_blob.cbData)
84
+ finally:
85
+ kernel32.LocalFree(out_blob.pbData)
86
+
87
+
88
+ def save_api_token(token: str) -> None:
89
+ token = token.strip()
90
+ if not token:
91
+ return
92
+ ensure_dirs()
93
+ raw = token.encode("utf-8")
94
+ payload = _windows_protect(raw) if os.name == "nt" else raw
95
+ TOKEN_SECRET_PATH.write_bytes(payload)
96
+ if os.name != "nt":
97
+ try:
98
+ TOKEN_SECRET_PATH.chmod(0o600)
99
+ except OSError:
100
+ pass
101
+
102
+
103
+ def load_api_token() -> str:
104
+ try:
105
+ payload = TOKEN_SECRET_PATH.read_bytes()
106
+ except OSError:
107
+ return ""
108
+ try:
109
+ raw = _windows_unprotect(payload) if os.name == "nt" else payload
110
+ return raw.decode("utf-8")
111
+ except Exception:
112
+ return ""
113
+
114
+
115
+ def has_saved_api_token() -> bool:
116
+ return bool(load_api_token().strip())
117
+
118
+
119
+ def cleanup_saved_request_jsons() -> None:
120
+ try:
121
+ for path in OUTPUT_DIR.rglob("*_request.json"):
122
+ if path.is_file():
123
+ path.unlink()
124
+ except OSError:
125
+ pass
126
+
127
+
128
+ def state_dict_without_secrets(state: AppState | dict) -> dict:
129
+ data = asdict(state) if not isinstance(state, dict) else dict(state)
130
+ api = dict(data.get("api", {}) or {})
131
+ api["token"] = ""
132
+ data["api"] = api
133
+ data["history"] = [sanitize_history_entry(item) for item in data.get("history", [])]
134
+ return data
135
+
136
+
137
+ def output_ref(path_value: str) -> str:
138
+ raw = str(path_value or "").strip()
139
+ if not raw:
140
+ return ""
141
+ path = Path(raw)
142
+ try:
143
+ if path.is_absolute():
144
+ return str(path.resolve().relative_to(OUTPUT_DIR.resolve())).replace("\\", "/")
145
+ except (OSError, ValueError):
146
+ return path.name
147
+ return raw.replace("\\", "/")
148
+
149
+
150
+ def sanitize_history_item(item: dict) -> dict:
151
+ clean = {
152
+ key: value
153
+ for key, value in dict(item or {}).items()
154
+ if key not in {"prompt", "negative_prompt", "uc_prompt", "request_path", "request_url"}
155
+ }
156
+ if "path" in clean:
157
+ clean["path"] = output_ref(clean.get("path", ""))
158
+ return clean
159
+
160
+
161
+ def sanitize_history_entry(history: dict) -> dict:
162
+ clean = dict(history or {})
163
+ if "output_dir" in clean:
164
+ clean["output_dir"] = output_ref(clean.get("output_dir", ""))
165
+ clean["items"] = [sanitize_history_item(item) for item in clean.get("items", [])]
166
+ return clean
167
+
168
+
47
169
  def now_id() -> str:
48
170
  return time.strftime("%Y%m%d_%H%M%S")
49
171
 
@@ -223,8 +345,11 @@ def default_state() -> AppState:
223
345
 
224
346
  def load_state() -> AppState:
225
347
  ensure_dirs()
348
+ cleanup_saved_request_jsons()
226
349
  if not STATE_PATH.exists():
227
- return default_state()
350
+ state = default_state()
351
+ state.api.token = load_api_token()
352
+ return state
228
353
  data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
229
354
  base_data = data.get("base_presets", [])
230
355
  quality_override = data.get("quality_override_prompt", "")
@@ -237,9 +362,24 @@ def load_state() -> AppState:
237
362
  else next((item for item in base_data if item.get("quality_override_prompt")), None)
238
363
  )
239
364
  quality_override = (fallback_base or {}).get("quality_override_prompt", "")
240
- api_data = data.get("api", {})
365
+ api_data = dict(data.get("api", {}) or {})
366
+ legacy_token = str(api_data.pop("token", "") or "").strip()
367
+ if legacy_token and not has_saved_api_token():
368
+ save_api_token(legacy_token)
369
+ data["api"] = api_data
370
+ STATE_PATH.write_text(
371
+ json.dumps(state_dict_without_secrets(data), ensure_ascii=False, indent=2),
372
+ encoding="utf-8",
373
+ )
241
374
  if api_data.get("user_agent") in (None, "", "NAIArtistCombination/0.1"):
242
375
  api_data["user_agent"] = DEFAULT_USER_AGENT
376
+ sanitized_data = state_dict_without_secrets(data)
377
+ if sanitized_data != data:
378
+ STATE_PATH.write_text(
379
+ json.dumps(sanitized_data, ensure_ascii=False, indent=2),
380
+ encoding="utf-8",
381
+ )
382
+ api_data["token"] = load_api_token()
243
383
  return AppState(
244
384
  categories=[Category(**item) for item in data.get("categories", [])],
245
385
  base_presets=[PromptPreset(**item) for item in base_data],
@@ -250,14 +390,14 @@ def load_state() -> AppState:
250
390
  api=ApiSettings(**api_data),
251
391
  generation=GenerationSettings(**data.get("generation", {})),
252
392
  batting_scenes=[BattingScene(**item) for item in data.get("batting_scenes", [])],
253
- history=data.get("history", []),
393
+ history=[sanitize_history_entry(item) for item in data.get("history", [])],
254
394
  )
255
395
 
256
396
 
257
397
  def save_state(state: AppState) -> None:
258
398
  ensure_dirs()
259
399
  STATE_PATH.write_text(
260
- json.dumps(asdict(state), ensure_ascii=False, indent=2),
400
+ json.dumps(state_dict_without_secrets(state), ensure_ascii=False, indent=2),
261
401
  encoding="utf-8",
262
402
  )
263
403
 
@@ -334,8 +474,6 @@ class NovelAIClient:
334
474
  return
335
475
 
336
476
  payload = self.build_payload(prompt, negative_prompt, uc_prompt)
337
- request_path = output_path.with_name(output_path.stem + "_request.json")
338
- request_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
339
477
  req = urllib.request.Request(
340
478
  self.settings.endpoint,
341
479
  data=json.dumps(payload).encode("utf-8"),
@@ -1133,11 +1271,7 @@ class App(tk.Tk if tk else object):
1133
1271
  prompt, negative, uc_prompt, artists = self.build_prompt()
1134
1272
  path = out_dir / f"image_{idx + 1:03}.png"
1135
1273
  metadata = {
1136
- "path": str(path),
1137
- "request_path": str(path.with_name(path.stem + "_request.json")),
1138
- "prompt": prompt,
1139
- "negative_prompt": negative,
1140
- "uc_prompt": uc_prompt,
1274
+ "path": output_ref(str(path)),
1141
1275
  "artists": artists,
1142
1276
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
1143
1277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nai-aclab",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "Local web UI for experimenting with NovelAI artist tag weight combinations.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/web/index.html CHANGED
@@ -388,10 +388,6 @@
388
388
  <strong>작가 가중치</strong>
389
389
  <div id="modalArtists" class="artist-weight-list"></div>
390
390
  </div>
391
- <div class="modal-section">
392
- <strong>Prompt</strong>
393
- <pre id="modalPrompt"></pre>
394
- </div>
395
391
  </aside>
396
392
  </section>
397
393
  </div>
package/web/main.js CHANGED
@@ -56,6 +56,7 @@ const imageSizeOptions = {
56
56
  landscape: { label: "Landscape", width: 1216, height: 832 },
57
57
  square: { label: "Square", width: 1024, height: 1024 },
58
58
  };
59
+ const defaultNovelAiEndpoint = "https://image.novelai.net/ai/generate-image";
59
60
 
60
61
  function escapeHtml(value) {
61
62
  return String(value ?? "")
@@ -109,9 +110,14 @@ function setSaveState(text) {
109
110
  }
110
111
 
111
112
  async function request(path, options = {}) {
113
+ const headers = {
114
+ "Content-Type": "application/json",
115
+ "X-NAI-ACLAB-Token": window.__NAI_ACLAB_TOKEN__ || "",
116
+ ...(options.headers || {}),
117
+ };
112
118
  const response = await fetch(path, {
113
- headers: { "Content-Type": "application/json" },
114
119
  ...options,
120
+ headers,
115
121
  });
116
122
  if (!response.ok) throw new Error(await response.text());
117
123
  return response.json();
@@ -534,8 +540,13 @@ function renderSettings() {
534
540
  const wrap = document.createElement("label");
535
541
  wrap.innerHTML = `<span>${label}</span><input id="api_${key}" type="${type}" />`;
536
542
  form.appendChild(wrap);
537
- $(`#api_${key}`).value = state.api[key] ?? "";
538
- if (type === "number") $(`#api_${key}`).step = "any";
543
+ const input = $(`#api_${key}`);
544
+ input.value = key === "token" ? "" : state.api[key] ?? "";
545
+ if (key === "token") {
546
+ input.placeholder = state.api.token_saved ? "저장된 토큰을 사용합니다" : "API 토큰을 입력하세요";
547
+ input.autocomplete = "off";
548
+ }
549
+ if (type === "number") input.step = "any";
539
550
  }
540
551
  $("#mockMode").checked = !!state.api.mock_mode;
541
552
  $("#negativePrompt").value = state.negative_prompt || "";
@@ -566,32 +577,30 @@ function renderImageCard(item, context = {}) {
566
577
  card.className = `image-card ${item.error ? "error" : ""}`;
567
578
  const enriched = enrichedImageItem(item, context);
568
579
  const modalList = context.modalItems || [enriched];
569
- card.innerHTML = `
570
- ${item.image_url ? `<img src="${item.image_url}" alt="generated image" />` : ""}
571
- <div>${item.error ? item.error : item.created_at || "생성 완료"}</div>
572
- `;
573
- const img = card.querySelector("img");
574
- if (img) img.onclick = () => openImageModal(enriched, modalList);
575
- const meta = card.querySelector("div");
576
- if (meta) {
577
- const label = meta.textContent;
578
- meta.className = "image-card-meta";
579
- meta.textContent = "";
580
- const labelNode = document.createElement("span");
581
- labelNode.textContent = label;
582
- meta.appendChild(labelNode);
583
- if (canReuseArtists(item)) {
584
- const button = document.createElement("button");
585
- button.className = "mini-button artist-reuse-button";
586
- button.type = "button";
587
- button.textContent = "가중치 불러오기";
588
- button.onclick = (event) => {
589
- event.stopPropagation();
590
- generateFromImage(enriched);
591
- };
592
- meta.appendChild(button);
593
- }
580
+ if (item.image_url) {
581
+ const img = document.createElement("img");
582
+ img.src = item.image_url;
583
+ img.alt = "generated image";
584
+ img.onclick = () => openImageModal(enriched, modalList);
585
+ card.appendChild(img);
594
586
  }
587
+ const meta = document.createElement("div");
588
+ meta.className = "image-card-meta";
589
+ const labelNode = document.createElement("span");
590
+ labelNode.textContent = item.error ? item.error : item.created_at || "생성 완료";
591
+ meta.appendChild(labelNode);
592
+ if (canReuseArtists(item)) {
593
+ const button = document.createElement("button");
594
+ button.className = "mini-button artist-reuse-button";
595
+ button.type = "button";
596
+ button.textContent = "가중치 불러오기";
597
+ button.onclick = (event) => {
598
+ event.stopPropagation();
599
+ generateFromImage(enriched);
600
+ };
601
+ meta.appendChild(button);
602
+ }
603
+ card.appendChild(meta);
595
604
  return card;
596
605
  }
597
606
 
@@ -626,7 +635,12 @@ function renderHistory() {
626
635
  };
627
636
  const button = document.createElement("button");
628
637
  button.className = `list-item ${index === historyIndex ? "active" : ""}`;
629
- button.innerHTML = `<strong>${history.base_preset} + ${history.character_preset}</strong><br><span>${history.created_at} · ${(history.items || []).length}장</span>`;
638
+ const title = document.createElement("strong");
639
+ title.textContent = `${history.base_preset || ""} + ${history.character_preset || ""}`;
640
+ const br = document.createElement("br");
641
+ const meta = document.createElement("span");
642
+ meta.textContent = `${history.created_at || ""} · ${(history.items || []).length}장`;
643
+ button.append(title, br, meta);
630
644
  button.onclick = () => {
631
645
  historyIndex = index;
632
646
  renderHistory();
@@ -834,7 +848,6 @@ function renderImageModalItem(item) {
834
848
  const position = modalItems.length > 1 ? ` (${modalIndex + 1} / ${modalItems.length})` : "";
835
849
  $("#modalTitle").textContent = `${item.source_label || item.created_at || "이미지"}${position}`;
836
850
  $("#modalSubtitle").textContent = [item.source_base_preset, item.source_character_preset].filter(Boolean).join(" + ");
837
- $("#modalPrompt").textContent = item.prompt || "";
838
851
  const artistList = $("#modalArtists");
839
852
  artistList.innerHTML = "";
840
853
  for (const row of artistWeightRows(item.artists || [])) artistList.appendChild(row);
@@ -1041,6 +1054,7 @@ function setBattingRunning(running, cancelling = false) {
1041
1054
 
1042
1055
  async function startBattingTest() {
1043
1056
  syncEditorsToState();
1057
+ if (!confirmCustomEndpointTokenUse()) return;
1044
1058
  if (!currentFixedArtists().length) {
1045
1059
  window.alert("타율 테스트는 작가태그 가중치를 고정한 상태에서 실행하는 기능입니다. 먼저 히스토리나 가중치 비교에서 가중치를 불러와 주세요.");
1046
1060
  return;
@@ -1149,8 +1163,23 @@ function generationStateForRequest() {
1149
1163
  return requestState;
1150
1164
  }
1151
1165
 
1166
+ function shouldConfirmCustomEndpoint() {
1167
+ const endpoint = String(state?.api?.endpoint || "").trim();
1168
+ if (!endpoint || endpoint === defaultNovelAiEndpoint) return false;
1169
+ return Boolean(state?.api?.token_saved || String($("#api_token")?.value || "").trim());
1170
+ }
1171
+
1172
+ function confirmCustomEndpointTokenUse() {
1173
+ if (!shouldConfirmCustomEndpoint()) return true;
1174
+ return window.confirm(
1175
+ "기본 NovelAI endpoint가 아닌 주소가 설정되어 있습니다.\n\n" +
1176
+ "이미지 생성 시 API 토큰이 이 endpoint로 전송됩니다. 계속할까요?"
1177
+ );
1178
+ }
1179
+
1152
1180
  async function startGeneration() {
1153
1181
  syncEditorsToState();
1182
+ if (!confirmCustomEndpointTokenUse()) return;
1154
1183
  setProgressActive(true);
1155
1184
  $("#jobLog").textContent = "생성 작업을 시작합니다...\n";
1156
1185
  if (currentFixedArtists().length) $("#jobLog").textContent = "고정 작가가중치로 생성 작업을 시작합니다...\n";
@@ -1431,6 +1460,14 @@ function deleteChar() {
1431
1460
  function bindAutosaveInputs(root = document) {
1432
1461
  root.querySelectorAll("input, textarea, select").forEach((node) => {
1433
1462
  if (node.id === "compareHistorySelect") return;
1463
+ if (node.id === "api_token") {
1464
+ node.oninput = null;
1465
+ node.onchange = () => {
1466
+ syncEditorsToState();
1467
+ scheduleSave();
1468
+ };
1469
+ return;
1470
+ }
1434
1471
  node.oninput = () => {
1435
1472
  syncEditorsToState();
1436
1473
  if (node.id === "catTags") renderRecognizedTags();
package/web_app.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import mimetypes
5
5
  import random
6
+ import secrets
6
7
  import shutil
7
8
  import sys
8
9
  import threading
@@ -25,11 +26,17 @@ from app import (
25
26
  NovelAIClient,
26
27
  PromptPreset,
27
28
  float_range,
29
+ has_saved_api_token,
30
+ load_api_token,
28
31
  load_state,
29
32
  now_id,
33
+ output_ref,
30
34
  parse_artist_tags,
31
35
  safe_path_name,
36
+ save_api_token,
32
37
  save_state,
38
+ sanitize_history_entry,
39
+ sanitize_history_item,
33
40
  weight_tag,
34
41
  )
35
42
 
@@ -37,6 +44,7 @@ from app import (
37
44
  WEB_DIR = APP_DIR / "web"
38
45
  JOBS: dict[str, dict] = {}
39
46
  STATE_LOCK = threading.Lock()
47
+ LOCAL_API_TOKEN = secrets.token_urlsafe(32)
40
48
 
41
49
 
42
50
  def dataclass_from_dict(cls, data: dict):
@@ -72,27 +80,42 @@ def state_from_dict(data: dict) -> AppState:
72
80
 
73
81
  def media_url(path: str) -> str:
74
82
  try:
75
- rel = Path(path).resolve().relative_to(OUTPUT_DIR.resolve())
83
+ raw = str(path or "")
84
+ candidate = Path(raw)
85
+ root = OUTPUT_DIR.resolve()
86
+ resolved = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
87
+ rel = resolved.relative_to(root)
76
88
  except (ValueError, OSError):
77
89
  return ""
78
90
  return "/media/" + urllib.parse.quote(str(rel).replace("\\", "/"))
79
91
 
80
92
 
93
+ def safe_child_path(root: Path, rel: str) -> Path | None:
94
+ try:
95
+ root_resolved = root.resolve()
96
+ child = (root_resolved / urllib.parse.unquote(rel)).resolve()
97
+ child.relative_to(root_resolved)
98
+ return child
99
+ except (ValueError, OSError):
100
+ return None
101
+
102
+
81
103
  def state_payload(state: AppState) -> dict:
82
104
  data = asdict(state)
105
+ data.setdefault("api", {})["token"] = ""
106
+ data["api"]["token_saved"] = has_saved_api_token()
107
+ data["history"] = [sanitize_history_entry(item) for item in data.get("history", [])]
83
108
  for category in data.get("categories", []):
84
109
  category["recognized_tags"] = parse_artist_tags(category.get("tags", []))
85
110
  for history in data.get("history", []):
86
111
  for item in history.get("items", []):
87
112
  item["image_url"] = media_url(item.get("path", ""))
88
- item["request_url"] = media_url(item.get("request_path", ""))
89
113
  return data
90
114
 
91
115
 
92
116
  def item_payload(item: dict) -> dict:
93
- data = dict(item)
117
+ data = sanitize_history_item(item)
94
118
  data["image_url"] = media_url(data.get("path", ""))
95
- data["request_url"] = media_url(data.get("request_path", ""))
96
119
  return data
97
120
 
98
121
 
@@ -186,6 +209,12 @@ def build_prompt(state: AppState) -> tuple[str, str, str, list[dict]]:
186
209
 
187
210
  def save_incoming_state(data: dict) -> AppState:
188
211
  state = state_from_dict(data)
212
+ incoming_token = str((data.get("api", {}) or {}).get("token", "") or "").strip()
213
+ if incoming_token:
214
+ save_api_token(incoming_token)
215
+ state.api.token = incoming_token
216
+ else:
217
+ state.api.token = load_api_token()
189
218
  with STATE_LOCK:
190
219
  current = load_state()
191
220
  state.history = current.history
@@ -207,7 +236,8 @@ def delete_history_entries(ids: list[str], delete_files: bool) -> AppState:
207
236
  if not raw_dir:
208
237
  continue
209
238
  try:
210
- target = Path(raw_dir).resolve()
239
+ raw_path = Path(raw_dir)
240
+ target = raw_path.resolve() if raw_path.is_absolute() else (OUTPUT_DIR / raw_path).resolve()
211
241
  except OSError:
212
242
  continue
213
243
  if target == output_root or output_root not in target.parents:
@@ -244,11 +274,7 @@ def run_generation(job_id: str, state: AppState) -> None:
244
274
  prompt, negative, uc_prompt, artists = build_prompt(state)
245
275
  path = out_dir / f"image_{idx + 1:03}.png"
246
276
  metadata = {
247
- "path": str(path),
248
- "request_path": str(path.with_name(path.stem + "_request.json")),
249
- "prompt": prompt,
250
- "negative_prompt": negative,
251
- "uc_prompt": uc_prompt,
277
+ "path": output_ref(str(path)),
252
278
  "artists": artists,
253
279
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
254
280
  }
@@ -266,7 +292,7 @@ def run_generation(job_id: str, state: AppState) -> None:
266
292
  "base_preset": base.name if base else "",
267
293
  "character_preset": character.name if character else "",
268
294
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
269
- "output_dir": str(out_dir),
295
+ "output_dir": output_ref(str(out_dir)),
270
296
  "items": items,
271
297
  }
272
298
  update = {
@@ -346,11 +372,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
346
372
  prompt, negative, uc_prompt, artists = build_prompt(current)
347
373
  path = scene_dir / f"image_{image_index + 1:03}.png"
348
374
  metadata = {
349
- "path": str(path),
350
- "request_path": str(path.with_name(path.stem + "_request.json")),
351
- "prompt": prompt,
352
- "negative_prompt": negative,
353
- "uc_prompt": uc_prompt,
375
+ "path": output_ref(str(path)),
354
376
  "artists": artists,
355
377
  "scene_name": scene_name,
356
378
  "scene_index": scene_index,
@@ -376,7 +398,7 @@ def run_batting_test(job_id: str, state: AppState) -> None:
376
398
  "base_preset": "타율 테스트",
377
399
  "character_preset": f"{len(scenes)}개 씬",
378
400
  "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
379
- "output_dir": str(out_dir),
401
+ "output_dir": output_ref(str(out_dir)),
380
402
  "scenes": [asdict(scene) for scene in scenes],
381
403
  "items": items,
382
404
  }
@@ -424,27 +446,57 @@ class Handler(BaseHTTPRequestHandler):
424
446
  self.end_headers()
425
447
  self.wfile.write(body)
426
448
 
449
+ def send_index(self) -> None:
450
+ path = WEB_DIR / "index.html"
451
+ if not path.exists() or not path.is_file():
452
+ self.send_error(404)
453
+ return
454
+ token_script = f"<script>window.__NAI_ACLAB_TOKEN__ = {json.dumps(LOCAL_API_TOKEN)};</script>"
455
+ html = path.read_text(encoding="utf-8").replace("</head>", f" {token_script}\n </head>")
456
+ body = html.encode("utf-8")
457
+ self.send_response(200)
458
+ self.send_header("Content-Type", "text/html; charset=utf-8")
459
+ self.send_header("Cache-Control", "no-store")
460
+ self.send_header("Content-Length", str(len(body)))
461
+ self.end_headers()
462
+ self.wfile.write(body)
463
+
464
+ def authorized_api_request(self) -> bool:
465
+ return self.headers.get("X-NAI-ACLAB-Token", "") == LOCAL_API_TOKEN
466
+
427
467
  def do_GET(self) -> None:
428
468
  parsed = urllib.parse.urlparse(self.path)
429
469
  route = parsed.path
430
470
  if route == "/":
431
- self.send_file(WEB_DIR / "index.html")
471
+ self.send_index()
432
472
  elif route.startswith("/web/"):
433
- self.send_file((WEB_DIR / route.removeprefix("/web/")).resolve())
473
+ path = safe_child_path(WEB_DIR, route.removeprefix("/web/"))
474
+ if not path:
475
+ self.send_error(403)
476
+ return
477
+ self.send_file(path)
434
478
  elif route == "/api/state":
479
+ if not self.authorized_api_request():
480
+ self.send_error(403)
481
+ return
435
482
  self.send_json({"state": state_payload(load_state())})
436
483
  elif route == "/api/preview":
484
+ if not self.authorized_api_request():
485
+ self.send_error(403)
486
+ return
437
487
  state = load_state()
438
488
  prompt, negative, uc_prompt, artists = build_prompt(state)
439
489
  self.send_json({"prompt": prompt, "negative": negative, "uc": uc_prompt, "artists": artists})
440
490
  elif route == "/api/job":
491
+ if not self.authorized_api_request():
492
+ self.send_error(403)
493
+ return
441
494
  query = urllib.parse.parse_qs(parsed.query)
442
495
  job_id = query.get("id", [""])[0]
443
496
  self.send_json({"job": JOBS.get(job_id, {"status": "missing"})})
444
497
  elif route.startswith("/media/"):
445
- rel = urllib.parse.unquote(route.removeprefix("/media/"))
446
- path = (OUTPUT_DIR / rel).resolve()
447
- if not str(path).startswith(str(OUTPUT_DIR.resolve())):
498
+ path = safe_child_path(OUTPUT_DIR, route.removeprefix("/media/"))
499
+ if not path:
448
500
  self.send_error(403)
449
501
  return
450
502
  self.send_file(path)
@@ -454,6 +506,9 @@ class Handler(BaseHTTPRequestHandler):
454
506
  def do_POST(self) -> None:
455
507
  parsed = urllib.parse.urlparse(self.path)
456
508
  route = parsed.path
509
+ if route.startswith("/api/") and not self.authorized_api_request():
510
+ self.send_error(403)
511
+ return
457
512
  data = self.read_json()
458
513
  if route == "/api/state":
459
514
  state = save_incoming_state(data.get("state", data))