@mug-lab/cookie-auth-proxy 0.1.0
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/LICENSE +21 -0
- package/README.md +642 -0
- package/SECURITY.md +9 -0
- package/config.json +43 -0
- package/cookie-auth-proxy.config.example.json +43 -0
- package/overrides/README.md +4 -0
- package/package.json +35 -0
- package/src/admin/admin.css +508 -0
- package/src/admin/editors.js +600 -0
- package/src/admin/events.js +325 -0
- package/src/admin/files.js +113 -0
- package/src/admin/renderers.js +513 -0
- package/src/admin/state.js +325 -0
- package/src/admin.html +178 -0
- package/src/config/index.js +372 -0
- package/src/cookie-auth-proxy.schema.json +233 -0
- package/src/diagnostics.js +154 -0
- package/src/init.js +201 -0
- package/src/logger.js +18 -0
- package/src/proxy.js +158 -0
- package/src/regex.js +101 -0
- package/src/runtime/index.js +671 -0
- package/src/sensitive.js +107 -0
- package/src/server/admin-server.js +562 -0
- package/src/server/proxy-server.js +671 -0
- package/src/server/rewrite.js +228 -0
- package/stubs/README.md +8 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub一覧と編集フォームを描画する。
|
|
3
|
+
*/
|
|
4
|
+
function renderStubs(stubs) {
|
|
5
|
+
lastStubs = stubs || [];
|
|
6
|
+
const openIds = new Set(
|
|
7
|
+
Array.from(document.querySelectorAll("#stubs details[open]")).map(
|
|
8
|
+
(entry) => entry.dataset.id,
|
|
9
|
+
),
|
|
10
|
+
);
|
|
11
|
+
const filtered = lastStubs.filter(matchesStubFilter);
|
|
12
|
+
$("stubs").innerHTML =
|
|
13
|
+
filtered
|
|
14
|
+
.map((stub) => {
|
|
15
|
+
const statusClass = statusBucket(stub.status);
|
|
16
|
+
return `
|
|
17
|
+
<details class="entry ${statusClass} ${stub.enabled && allStubsEnabled ? "" : "disabled-stub"}" data-id="${escapeAttr(stub.id)}" data-original="${escapeAttr(JSON.stringify(stub))}" ${openIds.has(String(stub.id)) ? "open" : ""}>
|
|
18
|
+
<summary><span class="entry-head">
|
|
19
|
+
<span class="entry-title">
|
|
20
|
+
<span class="pill">${escapeHtml(stub.method)}</span>
|
|
21
|
+
<b>${escapeHtml(stub.pathPattern)}</b>
|
|
22
|
+
<span class="pill status-pill">${escapeHtml(stub.status)}</span>
|
|
23
|
+
<span class="muted">${escapeHtml(stub.name || "Untitled stub")}</span>
|
|
24
|
+
</span>
|
|
25
|
+
<span class="entry-actions">
|
|
26
|
+
<label class="switch" title="Enable this stub" onclick="event.stopPropagation()">
|
|
27
|
+
<input type="checkbox" ${stub.enabled ? "checked" : ""} onchange="toggleStub(event, '${escapeAttr(stub.id)}', this.checked)">
|
|
28
|
+
<span class="slider"></span>
|
|
29
|
+
</label>
|
|
30
|
+
<button class="icon" title="Move up" onclick="moveStub(event, '${escapeAttr(stub.id)}', 'up')">↑</button>
|
|
31
|
+
<button class="icon" title="Move down" onclick="moveStub(event, '${escapeAttr(stub.id)}', 'down')">↓</button>
|
|
32
|
+
<button class="icon" title="Copy stub" onclick="copyStub(event, '${escapeAttr(stub.id)}')">Copy</button>
|
|
33
|
+
<button class="icon danger" title="Delete stub" onclick="deleteStub(event, '${escapeAttr(stub.id)}')">🗑</button>
|
|
34
|
+
</span>
|
|
35
|
+
</span></summary>
|
|
36
|
+
<label>Name<input data-field="name" value="${escapeAttr(stub.name || "Untitled stub")}"></label>
|
|
37
|
+
<div class="editor-section">
|
|
38
|
+
<h3>Criteria</h3>
|
|
39
|
+
<label>Method<select data-field="method"><option ${selected(stub.method, "GET")}>GET</option><option ${selected(stub.method, "POST")}>POST</option><option ${selected(stub.method, "PUT")}>PUT</option><option ${selected(stub.method, "PATCH")}>PATCH</option><option ${selected(stub.method, "DELETE")}>DELETE</option><option ${selected(stub.method, "*")}>*</option></select></label>
|
|
40
|
+
<label>Path Pattern<input data-field="pathPattern" value="${escapeAttr(stub.pathPattern)}"></label>
|
|
41
|
+
<label>Query Params JSON<textarea data-field="queryParams">${escapeHtml(JSON.stringify(stub.queryParams || {}, null, 2))}</textarea></label>
|
|
42
|
+
<label data-request-body-criteria class="${stub.method === "GET" ? "hidden" : ""}">Request Body JSON<textarea data-field="requestBody">${escapeHtml(JSON.stringify(stub.requestBody || {}, null, 2))}</textarea></label>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="editor-section">
|
|
45
|
+
<h3>Return Value</h3>
|
|
46
|
+
<label>Status<input data-field="status" value="${escapeAttr(stub.status)}"></label>
|
|
47
|
+
<label>Headers JSON<textarea data-field="headers">${escapeHtml(JSON.stringify(stub.headers || {}, null, 2))}</textarea></label>
|
|
48
|
+
<label>Body Type<select data-field="bodyEncoding" onchange="toggleStubBodyEditor(this.closest('details'))">
|
|
49
|
+
<option value="json" ${selected(stub.bodyEncoding || "json", "json")}>JSON</option>
|
|
50
|
+
<option value="text" ${selected(stub.bodyEncoding || "json", "text")}>Text</option>
|
|
51
|
+
<option value="base64" ${selected(stub.bodyEncoding || "json", "base64")}>Binary</option>
|
|
52
|
+
</select></label>
|
|
53
|
+
<div data-body-panel="text" class="${stub.bodyEncoding === "base64" ? "hidden" : ""}">
|
|
54
|
+
<label>Body<textarea data-field="body">${escapeHtml(formatBody(stub.body || ""))}</textarea></label>
|
|
55
|
+
</div>
|
|
56
|
+
<div data-body-panel="base64" class="${stub.bodyEncoding === "base64" ? "" : "hidden"}">
|
|
57
|
+
<input type="hidden" data-field="bodyBase64" value="${escapeAttr(stub.bodyBase64 || "")}">
|
|
58
|
+
<div class="row" style="margin-top:8px">
|
|
59
|
+
<button type="button" onclick="downloadStubBody(event, '${escapeAttr(stub.id)}')">Download Body</button>
|
|
60
|
+
<label>Replace Body File<input type="file" data-field="bodyFile" onchange="loadStubBodyFile(event)"></label>
|
|
61
|
+
</div>
|
|
62
|
+
<p class="muted" data-field="bodyBinaryNote">${binaryBodyNote(stub)}</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<p class="muted error-message" data-editor-error></p>
|
|
66
|
+
<button class="primary" data-action="apply-stub" onclick="saveStub(event, '${escapeAttr(stub.id)}')" ${dirtyNewStubIds.has(String(stub.id)) ? "" : "disabled"}>Apply Stub</button>
|
|
67
|
+
</details>`;
|
|
68
|
+
})
|
|
69
|
+
.join("") +
|
|
70
|
+
renderFilteredOutSummary(lastStubs.length - filtered.length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* select要素のoptionへselected属性を付けるか判定する。
|
|
75
|
+
*/
|
|
76
|
+
function selected(value, expected) {
|
|
77
|
+
return value === expected ? "selected" : "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Override一覧と編集フォームを描画する。
|
|
82
|
+
*/
|
|
83
|
+
function renderOverrides(overrides) {
|
|
84
|
+
lastOverrides = overrides || [];
|
|
85
|
+
const openIds = new Set(
|
|
86
|
+
Array.from(document.querySelectorAll("#overrides details[open]")).map(
|
|
87
|
+
(entry) => entry.dataset.id,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
const filtered = lastOverrides.filter(matchesOverrideFilter);
|
|
91
|
+
$("overrides").innerHTML =
|
|
92
|
+
filtered
|
|
93
|
+
.map(
|
|
94
|
+
(override) => `
|
|
95
|
+
<details class="entry ${override.enabled && allJsonOverridesEnabled ? "" : "disabled-stub"}" data-id="${escapeAttr(override.id)}" data-original="${escapeAttr(JSON.stringify(override))}" ${openIds.has(String(override.id)) ? "open" : ""}>
|
|
96
|
+
<summary><span class="entry-head">
|
|
97
|
+
<span class="entry-title">
|
|
98
|
+
<span class="pill">${escapeHtml(override.method)}</span>
|
|
99
|
+
<b>${escapeHtml(override.pathPattern)}</b>
|
|
100
|
+
<span class="muted">${escapeHtml(override.name || "Untitled override")}</span>
|
|
101
|
+
</span>
|
|
102
|
+
<span class="entry-actions">
|
|
103
|
+
<label class="switch" title="Enable this override" onclick="event.stopPropagation()">
|
|
104
|
+
<input type="checkbox" ${override.enabled ? "checked" : ""} onchange="toggleOverride(event, '${escapeAttr(override.id)}', this.checked)">
|
|
105
|
+
<span class="slider"></span>
|
|
106
|
+
</label>
|
|
107
|
+
<button class="icon" title="Move up" onclick="moveOverride(event, '${escapeAttr(override.id)}', 'up')">↑</button>
|
|
108
|
+
<button class="icon" title="Move down" onclick="moveOverride(event, '${escapeAttr(override.id)}', 'down')">↓</button>
|
|
109
|
+
<button class="icon" title="Copy override" onclick="copyOverride(event, '${escapeAttr(override.id)}')">Copy</button>
|
|
110
|
+
<button class="icon danger" title="Delete override" onclick="deleteOverride(event, '${escapeAttr(override.id)}')">🗑</button>
|
|
111
|
+
</span>
|
|
112
|
+
</span></summary>
|
|
113
|
+
<label>Name<input data-field="name" value="${escapeAttr(override.name || "Untitled override")}"></label>
|
|
114
|
+
<div class="editor-section">
|
|
115
|
+
<h3>Criteria</h3>
|
|
116
|
+
<label>Method<select data-field="method"><option ${selected(override.method, "GET")}>GET</option><option ${selected(override.method, "POST")}>POST</option><option ${selected(override.method, "PUT")}>PUT</option><option ${selected(override.method, "PATCH")}>PATCH</option><option ${selected(override.method, "DELETE")}>DELETE</option><option ${selected(override.method, "*")}>*</option></select></label>
|
|
117
|
+
<label>Path Pattern<input data-field="pathPattern" value="${escapeAttr(override.pathPattern)}"></label>
|
|
118
|
+
<label>Query Params JSON<textarea data-field="queryParams">${escapeHtml(JSON.stringify(override.queryParams || {}, null, 2))}</textarea></label>
|
|
119
|
+
<label data-request-body-criteria class="${override.method === "GET" ? "hidden" : ""}">Request Body JSON<textarea data-field="requestBody">${escapeHtml(JSON.stringify(override.requestBody || {}, null, 2))}</textarea></label>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="editor-section">
|
|
122
|
+
<h3>Return Value</h3>
|
|
123
|
+
<label>Override JSON<textarea data-field="value">${escapeHtml(JSON.stringify(override.value || {}, null, 2))}</textarea></label>
|
|
124
|
+
</div>
|
|
125
|
+
<p class="muted error-message" data-editor-error></p>
|
|
126
|
+
<button class="primary" data-action="apply-override" onclick="saveOverride(event, '${escapeAttr(override.id)}')" ${dirtyNewOverrideIds.has(String(override.id)) ? "" : "disabled"}>Apply Override</button>
|
|
127
|
+
</details>`,
|
|
128
|
+
)
|
|
129
|
+
.join("") +
|
|
130
|
+
renderFilteredOutSummary(lastOverrides.length - filtered.length);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Stub行がName/Method/Pathのフィルタに一致するか判定する。
|
|
135
|
+
*/
|
|
136
|
+
function matchesStubFilter(stub) {
|
|
137
|
+
return matchesRuleFilter(stub, stubFilter);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Override行がName/Method/Pathのフィルタに一致するか判定する。
|
|
142
|
+
*/
|
|
143
|
+
function matchesOverrideFilter(override) {
|
|
144
|
+
return matchesRuleFilter(override, overrideFilter);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* ルール行が入力フィルタに一致するか判定する。
|
|
149
|
+
*/
|
|
150
|
+
function matchesRuleFilter(rule, filterText) {
|
|
151
|
+
const needle = String(filterText || "").trim().toLowerCase();
|
|
152
|
+
if (!needle) return true;
|
|
153
|
+
return [rule.name, rule.method, rule.pathPattern]
|
|
154
|
+
.map((value) => String(value || "").toLowerCase())
|
|
155
|
+
.some((value) => value.includes(needle));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 履歴一覧をフィルタ適用後の表示順で描画する。
|
|
160
|
+
*/
|
|
161
|
+
function renderHistory(items) {
|
|
162
|
+
const knownIds = new Set(items.map((item) => String(item.id)));
|
|
163
|
+
for (const id of historyDetails.keys()) {
|
|
164
|
+
if (!knownIds.has(id)) historyDetails.delete(id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const openIds = new Set(
|
|
168
|
+
Array.from(document.querySelectorAll("#history details[open]")).map(
|
|
169
|
+
(entry) => entry.dataset.id,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
const scrollPositions = capturePreScroll();
|
|
173
|
+
const displayed = items.slice(-historyDisplayLimit).reverse();
|
|
174
|
+
const signature = [
|
|
175
|
+
historyFilter,
|
|
176
|
+
pathFilter,
|
|
177
|
+
displayed
|
|
178
|
+
.map(
|
|
179
|
+
(item) =>
|
|
180
|
+
`${item.id}:${matchesHistoryFilters(item) ? 1 : 0}:${item.status}:${item.durationMs}:${item.responseBodySize || 0}`,
|
|
181
|
+
)
|
|
182
|
+
.join(","),
|
|
183
|
+
].join("|");
|
|
184
|
+
if (signature === historyRenderSignature) return;
|
|
185
|
+
historyRenderSignature = signature;
|
|
186
|
+
|
|
187
|
+
$("history").innerHTML = renderHistoryRows(displayed, openIds);
|
|
188
|
+
openIds.forEach((id) => renderHistoryDetail(id));
|
|
189
|
+
restorePreScroll(scrollPositions);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 履歴行とフィルタ省略行を時系列上の位置に合わせて描画する。
|
|
194
|
+
*/
|
|
195
|
+
function renderHistoryRows(items, openIds) {
|
|
196
|
+
const rows = [];
|
|
197
|
+
let filteredRun = 0;
|
|
198
|
+
|
|
199
|
+
items.forEach((item) => {
|
|
200
|
+
if (!matchesHistoryFilters(item)) {
|
|
201
|
+
filteredRun += 1;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (filteredRun) {
|
|
206
|
+
rows.push(renderFilteredOutSummary(filteredRun));
|
|
207
|
+
filteredRun = 0;
|
|
208
|
+
}
|
|
209
|
+
rows.push(renderHistoryEntry(item, openIds));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (filteredRun) rows.push(renderFilteredOutSummary(filteredRun));
|
|
213
|
+
return rows.join("");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 履歴行が現在のフィルタ条件に一致するか判定する。
|
|
218
|
+
*/
|
|
219
|
+
function matchesHistoryFilters(item) {
|
|
220
|
+
return matchesStatusFilter(item.status) && matchesPathFilter(item.path);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 履歴の1行を描画する。
|
|
225
|
+
*/
|
|
226
|
+
function renderHistoryEntry(item, openIds) {
|
|
227
|
+
const statusClass = statusBucket(item.status);
|
|
228
|
+
const binary = isBinaryBody(item);
|
|
229
|
+
return `
|
|
230
|
+
<details class="entry ${statusClass}" data-id="${escapeAttr(item.id)}" ${openIds.has(String(item.id)) ? "open" : ""} ontoggle="toggleHistoryDetail(event, '${escapeAttr(item.id)}')">
|
|
231
|
+
<summary><span class="entry-head">
|
|
232
|
+
<span class="entry-title">
|
|
233
|
+
${escapeHtml(formatDate(item.at))} ${escapeHtml(item.method)} ${escapeHtml(item.path)}
|
|
234
|
+
<span class="pill status-pill">${escapeHtml(item.status)}</span>
|
|
235
|
+
<span class="pill">${escapeHtml(item.type)}</span>
|
|
236
|
+
<span class="pill">${escapeHtml(item.durationMs)}ms</span>
|
|
237
|
+
${binary ? `<span class="pill">${bodySizeLabel(item.responseBodySize)}</span>` : ""}
|
|
238
|
+
${item.responseBodyTruncated ? `<span class="pill">body truncated</span>` : ""}
|
|
239
|
+
${item.appliedOverrideIds && item.appliedOverrideIds.length ? `<span class="pill">override ${item.appliedOverrideIds.length}</span>` : ""}
|
|
240
|
+
${item.responseBodyRewriteSkipped ? `<span class="pill">body passthrough</span>` : ""}
|
|
241
|
+
${item.jsonOverrideSkipped ? `<span class="pill">override skipped ${item.skippedOverrideIds ? item.skippedOverrideIds.length : ""}</span>` : ""}
|
|
242
|
+
</span>
|
|
243
|
+
<span class="entry-actions">
|
|
244
|
+
<button title="Add stub from this response" onclick="addStubFromHistory(event, '${escapeAttr(item.id)}')">Stub</button>
|
|
245
|
+
<button title="Add JSON override from this response" onclick="addOverrideFromHistory(event, '${escapeAttr(item.id)}')">Override</button>
|
|
246
|
+
</span>
|
|
247
|
+
</span></summary>
|
|
248
|
+
<div class="history-detail" data-history-detail="${escapeAttr(item.id)}"></div>
|
|
249
|
+
</details>`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* フィルタで非表示になった行数をリスト末尾へ控えめに表示する。
|
|
254
|
+
*/
|
|
255
|
+
function renderFilteredOutSummary(count) {
|
|
256
|
+
if (!count) return "";
|
|
257
|
+
return `<div class="filter-summary" aria-label="${escapeAttr(count)} filtered out">... ${escapeHtml(count)} filtered out</div>`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 履歴行が開かれたときに詳細を遅延取得する。
|
|
262
|
+
*/
|
|
263
|
+
async function toggleHistoryDetail(event, id) {
|
|
264
|
+
if (!event.currentTarget.open) return;
|
|
265
|
+
await renderHistoryDetail(id);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 選択された履歴行の詳細レスポンスを描画する。
|
|
270
|
+
*/
|
|
271
|
+
async function renderHistoryDetail(id) {
|
|
272
|
+
const root = document.querySelector(`[data-history-detail="${id}"]`);
|
|
273
|
+
if (!root || root.dataset.loaded === "true") return;
|
|
274
|
+
|
|
275
|
+
root.innerHTML = `<p class="muted">Loading detail...</p>`;
|
|
276
|
+
const item = await getHistoryDetail(id);
|
|
277
|
+
const binary = isBinaryBody(item);
|
|
278
|
+
root.dataset.loaded = "true";
|
|
279
|
+
root.innerHTML = `
|
|
280
|
+
<div class="muted">${escapeHtml(item.at)} / ${escapeHtml(item.origin)}</div>
|
|
281
|
+
${
|
|
282
|
+
item.responseBodyTruncated
|
|
283
|
+
? `<p class="muted">Response body display was truncated by size limit. Showing the newest ${bodySizeLabel(item.responseBodyStoredSize)} of ${bodySizeLabel(item.responseBodySize)}.</p>`
|
|
284
|
+
: ""
|
|
285
|
+
}
|
|
286
|
+
${
|
|
287
|
+
item.responseBodyRewriteSkipped
|
|
288
|
+
? `<p class="muted">Response body rewrite was skipped because the body exceeded the configured rewrite size limit.</p>`
|
|
289
|
+
: ""
|
|
290
|
+
}
|
|
291
|
+
${
|
|
292
|
+
item.jsonOverrideSkipped
|
|
293
|
+
? `<p class="muted">Matching JSON override rules were found but skipped because the response body was streamed without rewriting.</p>`
|
|
294
|
+
: ""
|
|
295
|
+
}
|
|
296
|
+
${renderRuleApplicationDetail(item)}
|
|
297
|
+
<h3>Headers</h3><pre data-scroll-key="${escapeAttr(item.id)}:headers">${escapeHtml(JSON.stringify(item.responseHeaders || {}, null, 2))}</pre>
|
|
298
|
+
<div class="section-head">
|
|
299
|
+
<h3>Body</h3>
|
|
300
|
+
${
|
|
301
|
+
binary
|
|
302
|
+
? `<button class="icon" title="Download response body" onclick="downloadHistoryBody(event, '${escapeAttr(item.id)}')">DL</button>`
|
|
303
|
+
: `<button class="icon" title="Copy response body" onclick="copyBody(event, '${escapeAttr(item.id)}')">Copy</button>`
|
|
304
|
+
}
|
|
305
|
+
</div>
|
|
306
|
+
${
|
|
307
|
+
binary
|
|
308
|
+
? `<p class="muted">Binary body (${bodySizeLabel(item.responseBodySize)}). Use Download or create a stub from this history row.</p>`
|
|
309
|
+
: `<pre data-scroll-key="${escapeAttr(item.id)}:body">${escapeHtml(formatBody(item.responseBody || ""))}</pre>`
|
|
310
|
+
}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 履歴詳細へStub/Override適用情報を表示する。
|
|
315
|
+
*/
|
|
316
|
+
function renderRuleApplicationDetail(item) {
|
|
317
|
+
const sections = [];
|
|
318
|
+
if (item.stub) {
|
|
319
|
+
sections.push(`Matched Stub: ${ruleSummary(item.stub)}`);
|
|
320
|
+
}
|
|
321
|
+
if (item.appliedOverrides && item.appliedOverrides.length) {
|
|
322
|
+
sections.push(
|
|
323
|
+
`Applied JSON Overrides: ${item.appliedOverrides.map(ruleSummary).join(", ")}`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if (item.skippedOverrides && item.skippedOverrides.length) {
|
|
327
|
+
sections.push(
|
|
328
|
+
`Skipped JSON Overrides: ${item.skippedOverrides.map(ruleSummary).join(", ")}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (sections.length === 0) return "";
|
|
332
|
+
|
|
333
|
+
return `<h3>Rule Match</h3><pre>${escapeHtml(sections.join("\n"))}</pre>`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 履歴詳細用のルール概要文字列を作る。
|
|
338
|
+
*/
|
|
339
|
+
function ruleSummary(rule) {
|
|
340
|
+
return [
|
|
341
|
+
rule.name || "Untitled",
|
|
342
|
+
rule.method || "",
|
|
343
|
+
rule.pathPattern || "",
|
|
344
|
+
]
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.join(" / ");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 履歴詳細をキャッシュ優先で取得する。
|
|
351
|
+
*/
|
|
352
|
+
async function getHistoryDetail(id) {
|
|
353
|
+
if (historyDetails.has(String(id))) return historyDetails.get(String(id));
|
|
354
|
+
const item = await api(`/admin/history/${id}`);
|
|
355
|
+
historyDetails.set(String(id), item);
|
|
356
|
+
return item;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* HTTPステータスが画面のステータスフィルタに一致するか判定する。
|
|
361
|
+
*/
|
|
362
|
+
function matchesStatusFilter(status) {
|
|
363
|
+
const code = Number(status);
|
|
364
|
+
if (historyFilter === "all") return true;
|
|
365
|
+
if (historyFilter === "other")
|
|
366
|
+
return ![2, 3, 4, 5].includes(Math.floor(code / 100));
|
|
367
|
+
return Math.floor(code / 100) === Number(historyFilter);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 履歴行が画面のパスフィルタに一致するか判定する。
|
|
372
|
+
*/
|
|
373
|
+
function matchesPathFilter(path) {
|
|
374
|
+
if (!pathFilter.trim()) return true;
|
|
375
|
+
try {
|
|
376
|
+
return compileSafeRegex(pathFilter, "Path Filter Regex").test(path);
|
|
377
|
+
} catch {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* HTTPステータスを色分け用の2xx/3xx/4xx/5xx分類へ変換する。
|
|
384
|
+
*/
|
|
385
|
+
function statusBucket(status) {
|
|
386
|
+
const family = Math.floor(Number(status) / 100);
|
|
387
|
+
return [2, 3, 4, 5].includes(family) ? "status-" + family + "xx" : "";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 履歴またはStub本文がBase64バイナリ扱いか判定する。
|
|
392
|
+
*/
|
|
393
|
+
function isBinaryBody(item) {
|
|
394
|
+
return item && item.responseBodyEncoding === "base64";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* バイト数を画面表示しやすいサイズ表記へ変換する。
|
|
399
|
+
*/
|
|
400
|
+
function bodySizeLabel(size) {
|
|
401
|
+
const bytes = Number(size || 0);
|
|
402
|
+
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + " MB";
|
|
403
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
404
|
+
return bytes + " B";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* バイナリStub本文の選択状態を画面表示用テキストにする。
|
|
409
|
+
*/
|
|
410
|
+
function binaryBodyNote(stub) {
|
|
411
|
+
const base64 = stub.bodyBase64 || "";
|
|
412
|
+
if (!base64) return "No binary body is selected.";
|
|
413
|
+
return "Binary body: " + bodySizeLabel(base64ToBytes(base64).length);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Base64文字列をUint8Arrayへ変換する。
|
|
418
|
+
*/
|
|
419
|
+
function base64ToBytes(base64) {
|
|
420
|
+
const bin = atob(base64 || "");
|
|
421
|
+
const bytes = new Uint8Array(bin.length);
|
|
422
|
+
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
|
|
423
|
+
return bytes;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Uint8ArrayをBase64文字列へ変換する。
|
|
428
|
+
*/
|
|
429
|
+
function bytesToBase64(bytes) {
|
|
430
|
+
const chunks = [];
|
|
431
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
432
|
+
chunks.push(String.fromCharCode(...bytes.slice(i, i + 0x8000)));
|
|
433
|
+
}
|
|
434
|
+
return btoa(chunks.join(""));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* JSONらしい本文を整形し、それ以外は元の文字列のまま返す。
|
|
439
|
+
*/
|
|
440
|
+
function formatBody(body) {
|
|
441
|
+
if (typeof body !== "string") return JSON.stringify(body, null, 2);
|
|
442
|
+
const text = body.trim();
|
|
443
|
+
if (!text) return "";
|
|
444
|
+
if (!text.startsWith("{") && !text.startsWith("[")) return body;
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
448
|
+
} catch {
|
|
449
|
+
return body;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* ISO日時などをローカル表示用の日時文字列へ変換する。
|
|
455
|
+
*/
|
|
456
|
+
function formatDate(value) {
|
|
457
|
+
const date = new Date(value);
|
|
458
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
459
|
+
return date.toLocaleString();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* ボタンに短いフィードバックアニメーションを付ける。
|
|
464
|
+
*/
|
|
465
|
+
function flash(button) {
|
|
466
|
+
button.classList.remove("flash");
|
|
467
|
+
void button.offsetWidth;
|
|
468
|
+
button.classList.add("flash");
|
|
469
|
+
window.setTimeout(() => button.classList.remove("flash"), 450);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 再描画前のpre要素スクロール位置を退避する。
|
|
474
|
+
*/
|
|
475
|
+
function capturePreScroll() {
|
|
476
|
+
const map = {};
|
|
477
|
+
document.querySelectorAll("pre[data-scroll-key]").forEach((pre) => {
|
|
478
|
+
map[pre.dataset.scrollKey] = { top: pre.scrollTop, left: pre.scrollLeft };
|
|
479
|
+
});
|
|
480
|
+
return map;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* 再描画後のpre要素スクロール位置を復元する。
|
|
485
|
+
*/
|
|
486
|
+
function restorePreScroll(map) {
|
|
487
|
+
document.querySelectorAll("pre[data-scroll-key]").forEach((pre) => {
|
|
488
|
+
const position = map[pre.dataset.scrollKey];
|
|
489
|
+
if (!position) return;
|
|
490
|
+
pre.scrollTop = position.top;
|
|
491
|
+
pre.scrollLeft = position.left;
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* HTML本文へ埋め込めるよう文字列をエスケープする。
|
|
497
|
+
*/
|
|
498
|
+
function escapeHtml(value) {
|
|
499
|
+
return String(value).replace(
|
|
500
|
+
/[&<>"']/g,
|
|
501
|
+
(ch) =>
|
|
502
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
503
|
+
ch
|
|
504
|
+
],
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* HTML属性へ埋め込めるよう文字列をエスケープする。
|
|
510
|
+
*/
|
|
511
|
+
function escapeAttr(value) {
|
|
512
|
+
return escapeHtml(value).replace(/`/g, "`");
|
|
513
|
+
}
|