nai-aclab 1.0.3 → 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
@@ -125,6 +125,8 @@ API 토큰은 `app_state.json`에 저장하거나 `/api/state` 응답으로 다
125
125
 
126
126
  생성 히스토리에는 이미지 표시와 작가 가중치 비교에 필요한 최소 정보만 저장합니다. 최종 프롬프트, 네거티브 프롬프트, UC 프롬프트, 생성 요청 JSON, 로컬 절대 경로는 별도로 저장하지 않습니다. 이전 버전에서 생성된 `*_request.json` 파일은 앱 시작 시 자동으로 제거됩니다.
127
127
 
128
+ 로컬 API는 앱이 시작될 때 생성되는 임시 토큰으로 보호됩니다. 또한 기본 NovelAI endpoint가 아닌 주소로 이미지를 생성하려는 경우, API 토큰이 해당 endpoint로 전송된다는 확인창을 먼저 표시합니다.
129
+
128
130
  ## 저장 위치
129
131
 
130
132
  앱 상태, 생성 이미지, 요청 JSON은 아래 위치에 저장됩니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nai-aclab",
3
- "version": "1.0.3",
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/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();
@@ -571,32 +577,30 @@ function renderImageCard(item, context = {}) {
571
577
  card.className = `image-card ${item.error ? "error" : ""}`;
572
578
  const enriched = enrichedImageItem(item, context);
573
579
  const modalList = context.modalItems || [enriched];
574
- card.innerHTML = `
575
- ${item.image_url ? `<img src="${item.image_url}" alt="generated image" />` : ""}
576
- <div>${item.error ? item.error : item.created_at || "생성 완료"}</div>
577
- `;
578
- const img = card.querySelector("img");
579
- if (img) img.onclick = () => openImageModal(enriched, modalList);
580
- const meta = card.querySelector("div");
581
- if (meta) {
582
- const label = meta.textContent;
583
- meta.className = "image-card-meta";
584
- meta.textContent = "";
585
- const labelNode = document.createElement("span");
586
- labelNode.textContent = label;
587
- meta.appendChild(labelNode);
588
- if (canReuseArtists(item)) {
589
- const button = document.createElement("button");
590
- button.className = "mini-button artist-reuse-button";
591
- button.type = "button";
592
- button.textContent = "가중치 불러오기";
593
- button.onclick = (event) => {
594
- event.stopPropagation();
595
- generateFromImage(enriched);
596
- };
597
- meta.appendChild(button);
598
- }
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);
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);
599
602
  }
603
+ card.appendChild(meta);
600
604
  return card;
601
605
  }
602
606
 
@@ -631,7 +635,12 @@ function renderHistory() {
631
635
  };
632
636
  const button = document.createElement("button");
633
637
  button.className = `list-item ${index === historyIndex ? "active" : ""}`;
634
- 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);
635
644
  button.onclick = () => {
636
645
  historyIndex = index;
637
646
  renderHistory();
@@ -1045,6 +1054,7 @@ function setBattingRunning(running, cancelling = false) {
1045
1054
 
1046
1055
  async function startBattingTest() {
1047
1056
  syncEditorsToState();
1057
+ if (!confirmCustomEndpointTokenUse()) return;
1048
1058
  if (!currentFixedArtists().length) {
1049
1059
  window.alert("타율 테스트는 작가태그 가중치를 고정한 상태에서 실행하는 기능입니다. 먼저 히스토리나 가중치 비교에서 가중치를 불러와 주세요.");
1050
1060
  return;
@@ -1153,8 +1163,23 @@ function generationStateForRequest() {
1153
1163
  return requestState;
1154
1164
  }
1155
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
+
1156
1180
  async function startGeneration() {
1157
1181
  syncEditorsToState();
1182
+ if (!confirmCustomEndpointTokenUse()) return;
1158
1183
  setProgressActive(true);
1159
1184
  $("#jobLog").textContent = "생성 작업을 시작합니다...\n";
1160
1185
  if (currentFixedArtists().length) $("#jobLog").textContent = "고정 작가가중치로 생성 작업을 시작합니다...\n";
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
@@ -43,6 +44,7 @@ from app import (
43
44
  WEB_DIR = APP_DIR / "web"
44
45
  JOBS: dict[str, dict] = {}
45
46
  STATE_LOCK = threading.Lock()
47
+ LOCAL_API_TOKEN = secrets.token_urlsafe(32)
46
48
 
47
49
 
48
50
  def dataclass_from_dict(cls, data: dict):
@@ -80,12 +82,24 @@ def media_url(path: str) -> str:
80
82
  try:
81
83
  raw = str(path or "")
82
84
  candidate = Path(raw)
83
- rel = candidate.resolve().relative_to(OUTPUT_DIR.resolve()) if candidate.is_absolute() else 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)
84
88
  except (ValueError, OSError):
85
89
  return ""
86
90
  return "/media/" + urllib.parse.quote(str(rel).replace("\\", "/"))
87
91
 
88
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
+
89
103
  def state_payload(state: AppState) -> dict:
90
104
  data = asdict(state)
91
105
  data.setdefault("api", {})["token"] = ""
@@ -432,27 +446,57 @@ class Handler(BaseHTTPRequestHandler):
432
446
  self.end_headers()
433
447
  self.wfile.write(body)
434
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
+
435
467
  def do_GET(self) -> None:
436
468
  parsed = urllib.parse.urlparse(self.path)
437
469
  route = parsed.path
438
470
  if route == "/":
439
- self.send_file(WEB_DIR / "index.html")
471
+ self.send_index()
440
472
  elif route.startswith("/web/"):
441
- 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)
442
478
  elif route == "/api/state":
479
+ if not self.authorized_api_request():
480
+ self.send_error(403)
481
+ return
443
482
  self.send_json({"state": state_payload(load_state())})
444
483
  elif route == "/api/preview":
484
+ if not self.authorized_api_request():
485
+ self.send_error(403)
486
+ return
445
487
  state = load_state()
446
488
  prompt, negative, uc_prompt, artists = build_prompt(state)
447
489
  self.send_json({"prompt": prompt, "negative": negative, "uc": uc_prompt, "artists": artists})
448
490
  elif route == "/api/job":
491
+ if not self.authorized_api_request():
492
+ self.send_error(403)
493
+ return
449
494
  query = urllib.parse.parse_qs(parsed.query)
450
495
  job_id = query.get("id", [""])[0]
451
496
  self.send_json({"job": JOBS.get(job_id, {"status": "missing"})})
452
497
  elif route.startswith("/media/"):
453
- rel = urllib.parse.unquote(route.removeprefix("/media/"))
454
- path = (OUTPUT_DIR / rel).resolve()
455
- if not str(path).startswith(str(OUTPUT_DIR.resolve())):
498
+ path = safe_child_path(OUTPUT_DIR, route.removeprefix("/media/"))
499
+ if not path:
456
500
  self.send_error(403)
457
501
  return
458
502
  self.send_file(path)
@@ -462,6 +506,9 @@ class Handler(BaseHTTPRequestHandler):
462
506
  def do_POST(self) -> None:
463
507
  parsed = urllib.parse.urlparse(self.path)
464
508
  route = parsed.path
509
+ if route.startswith("/api/") and not self.authorized_api_request():
510
+ self.send_error(403)
511
+ return
465
512
  data = self.read_json()
466
513
  if route == "/api/state":
467
514
  state = save_incoming_state(data.get("state", data))