webhanger-front 1.0.0 → 1.0.1
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 +167 -0
- package/browser.js +132 -13
- package/index.js +71 -12
- package/package.json +8 -2
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# webhanger-front
|
|
2
|
+
|
|
3
|
+
Browser SDK for WebHanger. Loads encrypted UI components from CDN and injects them into the DOM. No eval, no readable source — decrypted in memory and applied directly.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install webhanger-front
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or via script tag:
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<script src="https://unpkg.com/webhanger-front/browser.js"></script>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### Script tag (plain HTML)
|
|
24
|
+
|
|
25
|
+
```html
|
|
26
|
+
<div data-wh></div>
|
|
27
|
+
|
|
28
|
+
<script src="https://unpkg.com/webhanger-front/browser.js"></script>
|
|
29
|
+
<script>
|
|
30
|
+
WebHangerFront.load(
|
|
31
|
+
"https://xxx.cloudfront.net/components/navbar@1.0.0.js",
|
|
32
|
+
"wh_1234567890", // projectId from webhanger.config.json
|
|
33
|
+
"your-token",
|
|
34
|
+
0 // expires: 0 = never, or unix timestamp
|
|
35
|
+
);
|
|
36
|
+
</script>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### ESM (Vite, Webpack, Rollup)
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { load } from "webhanger-front";
|
|
43
|
+
|
|
44
|
+
await load(
|
|
45
|
+
"https://xxx.cloudfront.net/components/navbar@1.0.0.js",
|
|
46
|
+
"wh_1234567890",
|
|
47
|
+
"your-token",
|
|
48
|
+
0
|
|
49
|
+
);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### React
|
|
53
|
+
|
|
54
|
+
```jsx
|
|
55
|
+
import { useEffect } from "react";
|
|
56
|
+
import { load } from "webhanger-front";
|
|
57
|
+
|
|
58
|
+
export default function Navbar() {
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
load(
|
|
61
|
+
"https://xxx.cloudfront.net/components/navbar@1.0.0.js",
|
|
62
|
+
"wh_1234567890",
|
|
63
|
+
"your-token",
|
|
64
|
+
0,
|
|
65
|
+
"#navbar-mount"
|
|
66
|
+
);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
return <div id="navbar-mount" />;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Vue
|
|
74
|
+
|
|
75
|
+
```vue
|
|
76
|
+
<template>
|
|
77
|
+
<div ref="mount" />
|
|
78
|
+
</template>
|
|
79
|
+
|
|
80
|
+
<script setup>
|
|
81
|
+
import { onMounted, ref } from "vue";
|
|
82
|
+
import { load } from "webhanger-front";
|
|
83
|
+
|
|
84
|
+
const mount = ref(null);
|
|
85
|
+
|
|
86
|
+
onMounted(() => {
|
|
87
|
+
load(
|
|
88
|
+
"https://xxx.cloudfront.net/components/navbar@1.0.0.js",
|
|
89
|
+
"wh_1234567890",
|
|
90
|
+
"your-token",
|
|
91
|
+
0,
|
|
92
|
+
"#navbar-mount"
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
</script>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## API
|
|
101
|
+
|
|
102
|
+
### `load(cdnUrl, projectId, token, expires, selector?)`
|
|
103
|
+
|
|
104
|
+
| Param | Type | Description |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `cdnUrl` | `string` | Full CDN URL of the component |
|
|
107
|
+
| `projectId` | `string` | Your WebHanger project ID — used as decrypt key |
|
|
108
|
+
| `token` | `string` | HMAC signed token from `wh deploy` |
|
|
109
|
+
| `expires` | `number` | Unix timestamp expiry. `0` = never expires |
|
|
110
|
+
| `selector` | `string` | CSS selector for mount target. Default: `[data-wh]` |
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
await load(cdnUrl, projectId, token, expires, "[data-wh]");
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### `clearCache()`
|
|
119
|
+
|
|
120
|
+
Clears all cached components from localStorage and IndexedDB.
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { clearCache } from "webhanger-front";
|
|
124
|
+
await clearCache();
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
### `version`
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
import { version } from "webhanger-front";
|
|
133
|
+
console.log(version); // "1.0.0"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Caching
|
|
139
|
+
|
|
140
|
+
Components are automatically cached after first load:
|
|
141
|
+
|
|
142
|
+
| Size | Storage |
|
|
143
|
+
|---|---|
|
|
144
|
+
| < 50KB | `localStorage` |
|
|
145
|
+
| >= 50KB | `IndexedDB` |
|
|
146
|
+
|
|
147
|
+
Cache key is `cdnUrl@expires` — changing the version or expiry busts the cache automatically.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Offline support
|
|
152
|
+
|
|
153
|
+
Register the service worker from the `webhanger` package for offline component delivery:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
navigator.serviceWorker.register("/webhanger.sw.js");
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## How it works
|
|
162
|
+
|
|
163
|
+
1. Fetches encrypted JSON payload from CDN
|
|
164
|
+
2. Decrypts each chunk in memory using `projectId` as cipher key
|
|
165
|
+
3. Injects CSS into `<head>`, HTML into mount target, JS via `<script>`
|
|
166
|
+
4. No `eval` — browser parses JS natively
|
|
167
|
+
5. Payload on CDN is unreadable without the projectId
|
package/browser.js
CHANGED
|
@@ -8,17 +8,17 @@
|
|
|
8
8
|
const LS_PREFIX = "wh_";
|
|
9
9
|
const SIZE_THRESHOLD = 50 * 1024;
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
let out = "";
|
|
13
|
-
for (let i = 0; i < str.length; i++) {
|
|
14
|
-
out += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
|
15
|
-
}
|
|
16
|
-
return out;
|
|
17
|
-
}
|
|
18
|
-
|
|
11
|
+
// UTF-8 safe XOR decrypt — mirrors bundler byte-level encoding
|
|
19
12
|
function decrypt(b64, projectId, salt) {
|
|
20
13
|
if (!b64) return "";
|
|
21
|
-
|
|
14
|
+
const key = projectId + salt;
|
|
15
|
+
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
16
|
+
const keyBytes = new TextEncoder().encode(key);
|
|
17
|
+
const out = new Uint8Array(bytes.length);
|
|
18
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
19
|
+
out[i] = bytes[i] ^ keyBytes[i % keyBytes.length];
|
|
20
|
+
}
|
|
21
|
+
return new TextDecoder().decode(out);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function openIDB() {
|
|
@@ -77,6 +77,38 @@
|
|
|
77
77
|
return await res.text();
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// ─── Load external CDN assets ─────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function loadAsset(asset) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const existing = asset.type === "style"
|
|
85
|
+
? document.querySelector(`link[href="${asset.url}"]`)
|
|
86
|
+
: document.querySelector(`script[src="${asset.url}"]`);
|
|
87
|
+
if (existing) return resolve();
|
|
88
|
+
|
|
89
|
+
if (asset.type === "style") {
|
|
90
|
+
const link = document.createElement("link");
|
|
91
|
+
link.rel = "stylesheet";
|
|
92
|
+
link.href = asset.url;
|
|
93
|
+
link.onload = resolve;
|
|
94
|
+
link.onerror = resolve;
|
|
95
|
+
document.head.appendChild(link);
|
|
96
|
+
} else {
|
|
97
|
+
const script = document.createElement("script");
|
|
98
|
+
script.src = asset.url;
|
|
99
|
+
if (asset.defer) script.defer = true;
|
|
100
|
+
if (asset.async) script.async = true;
|
|
101
|
+
script.onload = resolve;
|
|
102
|
+
script.onerror = resolve;
|
|
103
|
+
document.head.appendChild(script);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function loadAssets(assets = []) {
|
|
109
|
+
for (const asset of assets) await loadAsset(asset);
|
|
110
|
+
}
|
|
111
|
+
|
|
80
112
|
function injectComponent(encryptedPayload, projectId, targetSelector) {
|
|
81
113
|
const target = document.querySelector(targetSelector || "[data-wh]");
|
|
82
114
|
if (!target) { console.warn("[WebHanger] No mount target found."); return; }
|
|
@@ -104,7 +136,50 @@
|
|
|
104
136
|
}
|
|
105
137
|
}
|
|
106
138
|
|
|
107
|
-
|
|
139
|
+
// ─── Loading Signaler ─────────────────────────────────────────────────────
|
|
140
|
+
// Fires callbacks at each stage of the load lifecycle.
|
|
141
|
+
// Stages: "start" | "fetching" | "assets" | "injecting" | "done" | "error"
|
|
142
|
+
|
|
143
|
+
function createSignaler(onSignal) {
|
|
144
|
+
return function signal(stage, detail = {}) {
|
|
145
|
+
if (typeof onSignal === "function") {
|
|
146
|
+
onSignal({ stage, ...detail });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── Built-in preloader ───────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function createPreloader(selector) {
|
|
154
|
+
const target = document.querySelector(selector || "[data-wh]");
|
|
155
|
+
if (!target) return { show: () => {}, hide: () => {} };
|
|
156
|
+
|
|
157
|
+
const loader = document.createElement("div");
|
|
158
|
+
loader.setAttribute("data-wh-loader", "");
|
|
159
|
+
loader.style.cssText = `
|
|
160
|
+
display:flex; align-items:center; justify-content:center;
|
|
161
|
+
padding: 24px; width:100%; box-sizing:border-box;
|
|
162
|
+
`;
|
|
163
|
+
loader.innerHTML = `
|
|
164
|
+
<div style="
|
|
165
|
+
width:28px; height:28px;
|
|
166
|
+
border:3px solid #e5e7eb;
|
|
167
|
+
border-top-color:#6366f1;
|
|
168
|
+
border-radius:50%;
|
|
169
|
+
animation:wh-spin 0.7s linear infinite;
|
|
170
|
+
"></div>
|
|
171
|
+
<style>
|
|
172
|
+
@keyframes wh-spin { to { transform: rotate(360deg); } }
|
|
173
|
+
</style>
|
|
174
|
+
`;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
show() { target.appendChild(loader); },
|
|
178
|
+
hide() { if (loader.parentNode) loader.parentNode.removeChild(loader); }
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function load(cdnUrl, projectId, token, expires, selector = "[data-wh]", onSignal = null) {
|
|
108
183
|
if (!cdnUrl || !projectId || !token || expires === undefined || expires === null) {
|
|
109
184
|
console.error("[WebHanger] Missing required params: cdnUrl, projectId, token, expires");
|
|
110
185
|
return;
|
|
@@ -113,26 +188,70 @@
|
|
|
113
188
|
console.warn("[WebHanger] Token expired.");
|
|
114
189
|
return;
|
|
115
190
|
}
|
|
191
|
+
|
|
192
|
+
const signal = createSignaler(onSignal);
|
|
193
|
+
const preloader = createPreloader(selector);
|
|
194
|
+
|
|
195
|
+
signal("start", { cdnUrl, selector });
|
|
196
|
+
preloader.show();
|
|
197
|
+
|
|
116
198
|
const cacheKey = `${cdnUrl}@${expires}`;
|
|
117
199
|
try {
|
|
200
|
+
signal("fetching");
|
|
118
201
|
let payload = await cacheGet(cacheKey);
|
|
119
202
|
if (!payload) {
|
|
120
203
|
payload = await fetchComponent(cdnUrl, token, expires);
|
|
121
204
|
await cacheSet(cacheKey, payload);
|
|
122
205
|
}
|
|
206
|
+
|
|
207
|
+
let parsed;
|
|
208
|
+
try { parsed = JSON.parse(payload); } catch (_) { parsed = {}; }
|
|
209
|
+
|
|
210
|
+
if (parsed.assets && parsed.assets.length) {
|
|
211
|
+
signal("assets", { count: parsed.assets.length, assets: parsed.assets });
|
|
212
|
+
await loadAssets(parsed.assets);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
signal("injecting");
|
|
216
|
+
preloader.hide();
|
|
123
217
|
injectComponent(payload, projectId, selector);
|
|
218
|
+
signal("done");
|
|
124
219
|
} catch (err) {
|
|
220
|
+
preloader.hide();
|
|
221
|
+
signal("error", { message: err.message });
|
|
125
222
|
console.error("[WebHanger] Load failed:", err.message);
|
|
126
223
|
}
|
|
127
224
|
}
|
|
128
225
|
|
|
129
226
|
async function clearCache() {
|
|
227
|
+
// 1. Clear all wh_ keys from localStorage
|
|
130
228
|
Object.keys(localStorage)
|
|
131
229
|
.filter(k => k.startsWith(LS_PREFIX))
|
|
132
230
|
.forEach(k => localStorage.removeItem(k));
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
231
|
+
|
|
232
|
+
// 2. Clear IndexedDB store
|
|
233
|
+
try {
|
|
234
|
+
const db = await openIDB();
|
|
235
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
236
|
+
tx.objectStore(IDB_STORE).clear();
|
|
237
|
+
} catch (_) {}
|
|
238
|
+
|
|
239
|
+
// 3. Clear service worker caches
|
|
240
|
+
if ("caches" in window) {
|
|
241
|
+
const keys = await caches.keys();
|
|
242
|
+
await Promise.all(keys.map(k => caches.delete(k)));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 4. Unregister service workers
|
|
246
|
+
if ("serviceWorker" in navigator) {
|
|
247
|
+
const regs = await navigator.serviceWorker.getRegistrations();
|
|
248
|
+
await Promise.all(regs.map(r => r.unregister()));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 5. Clear sessionStorage wh_ keys
|
|
252
|
+
Object.keys(sessionStorage)
|
|
253
|
+
.filter(k => k.startsWith(LS_PREFIX))
|
|
254
|
+
.forEach(k => sessionStorage.removeItem(k));
|
|
136
255
|
}
|
|
137
256
|
|
|
138
257
|
global.WebHangerFront = { load, clearCache, version: VERSION };
|
package/index.js
CHANGED
|
@@ -9,17 +9,17 @@ const SIZE_THRESHOLD = 50 * 1024;
|
|
|
9
9
|
|
|
10
10
|
// ─── Decrypt ─────────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
let out = "";
|
|
14
|
-
for (let i = 0; i < str.length; i++) {
|
|
15
|
-
out += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
|
16
|
-
}
|
|
17
|
-
return out;
|
|
18
|
-
}
|
|
19
|
-
|
|
12
|
+
// UTF-8 safe XOR decrypt
|
|
20
13
|
function decrypt(b64, projectId, salt) {
|
|
21
14
|
if (!b64) return "";
|
|
22
|
-
|
|
15
|
+
const key = projectId + salt;
|
|
16
|
+
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
17
|
+
const keyBytes = new TextEncoder().encode(key);
|
|
18
|
+
const out = new Uint8Array(bytes.length);
|
|
19
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
20
|
+
out[i] = bytes[i] ^ keyBytes[i % keyBytes.length];
|
|
21
|
+
}
|
|
22
|
+
return new TextDecoder().decode(out);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// ─── IndexedDB ────────────────────────────────────────────────────────────────
|
|
@@ -73,6 +73,38 @@ async function cacheSet(key, value) {
|
|
|
73
73
|
await idbSet(key, value);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// ─── Load external CDN assets ─────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function loadAsset(asset) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const existing = asset.type === "style"
|
|
81
|
+
? document.querySelector(`link[href="${asset.url}"]`)
|
|
82
|
+
: document.querySelector(`script[src="${asset.url}"]`);
|
|
83
|
+
if (existing) return resolve();
|
|
84
|
+
|
|
85
|
+
if (asset.type === "style") {
|
|
86
|
+
const link = document.createElement("link");
|
|
87
|
+
link.rel = "stylesheet";
|
|
88
|
+
link.href = asset.url;
|
|
89
|
+
link.onload = resolve;
|
|
90
|
+
link.onerror = resolve;
|
|
91
|
+
document.head.appendChild(link);
|
|
92
|
+
} else {
|
|
93
|
+
const script = document.createElement("script");
|
|
94
|
+
script.src = asset.url;
|
|
95
|
+
if (asset.defer) script.defer = true;
|
|
96
|
+
if (asset.async) script.async = true;
|
|
97
|
+
script.onload = resolve;
|
|
98
|
+
script.onerror = resolve;
|
|
99
|
+
document.head.appendChild(script);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function loadAssets(assets = []) {
|
|
105
|
+
for (const asset of assets) await loadAsset(asset);
|
|
106
|
+
}
|
|
107
|
+
|
|
76
108
|
// ─── Fetch ────────────────────────────────────────────────────────────────────
|
|
77
109
|
|
|
78
110
|
async function fetchComponent(cdnUrl, token, expires) {
|
|
@@ -143,6 +175,12 @@ export async function load(cdnUrl, projectId, token, expires, selector = "[data-
|
|
|
143
175
|
payload = await fetchComponent(cdnUrl, token, expires);
|
|
144
176
|
await cacheSet(cacheKey, payload);
|
|
145
177
|
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const parsed = JSON.parse(payload);
|
|
181
|
+
if (parsed.assets && parsed.assets.length) await loadAssets(parsed.assets);
|
|
182
|
+
} catch (_) {}
|
|
183
|
+
|
|
146
184
|
injectComponent(payload, projectId, selector);
|
|
147
185
|
} catch (err) {
|
|
148
186
|
console.error("[WebHanger] Load failed:", err.message);
|
|
@@ -153,13 +191,34 @@ export async function load(cdnUrl, projectId, token, expires, selector = "[data-
|
|
|
153
191
|
* Clear cached components from localStorage + IndexedDB.
|
|
154
192
|
*/
|
|
155
193
|
export async function clearCache() {
|
|
194
|
+
// localStorage
|
|
156
195
|
Object.keys(localStorage)
|
|
157
196
|
.filter(k => k.startsWith(LS_PREFIX))
|
|
158
197
|
.forEach(k => localStorage.removeItem(k));
|
|
159
198
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
199
|
+
// IndexedDB
|
|
200
|
+
try {
|
|
201
|
+
const db = await openIDB();
|
|
202
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
203
|
+
tx.objectStore(IDB_STORE).clear();
|
|
204
|
+
} catch (_) {}
|
|
205
|
+
|
|
206
|
+
// Service worker caches
|
|
207
|
+
if ("caches" in window) {
|
|
208
|
+
const keys = await caches.keys();
|
|
209
|
+
await Promise.all(keys.map(k => caches.delete(k)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Unregister service workers
|
|
213
|
+
if ("serviceWorker" in navigator) {
|
|
214
|
+
const regs = await navigator.serviceWorker.getRegistrations();
|
|
215
|
+
await Promise.all(regs.map(r => r.unregister()));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// sessionStorage
|
|
219
|
+
Object.keys(sessionStorage)
|
|
220
|
+
.filter(k => k.startsWith(LS_PREFIX))
|
|
221
|
+
.forEach(k => sessionStorage.removeItem(k));
|
|
163
222
|
}
|
|
164
223
|
|
|
165
224
|
export const version = VERSION;
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webhanger-front",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "WebHanger browser SDK — load encrypted UI components from CDN",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"module": "index.js",
|
|
7
7
|
"browser": "browser.js",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"keywords": [
|
|
9
|
+
"keywords": [
|
|
10
|
+
"webhanger",
|
|
11
|
+
"cdn",
|
|
12
|
+
"components",
|
|
13
|
+
"frontend",
|
|
14
|
+
"sdk"
|
|
15
|
+
],
|
|
10
16
|
"license": "ISC"
|
|
11
17
|
}
|