dsh-pocket 1.11.0 → 1.11.2
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.en.md +1 -0
- package/README.md +1 -0
- package/client/client.js +20 -11
- package/client/mobile/MobileNavOverlay.tsx +23 -6
- package/client/mobile/mobile.css.ts +5 -2
- package/lib/proxy.mjs +90 -8
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -94,6 +94,7 @@ On the same page click "**Enable anywhere**" → wait for the tunnel (first run
|
|
|
94
94
|
- **DSH can execute code on your computer.** **LAN** QR/URL plus its own **8-digit PIN** is the key (PIN **on by default**, switchable off — then LAN scans connect directly, same-network devices only) — **never share the LAN QR, URL or PIN**.
|
|
95
95
|
- **Public** access is protected by an **8-digit PIN**: the link is random, the PIN rotates on every tunnel start by default, and old links die instantly — even a leaked link can't get in. **A custom PIN is never auto-rotated** (your value stays stable).
|
|
96
96
|
- Phone login state is tied to the computer's dsh web process: **no re-entry while dsh web stays up; one re-entry after a restart/update**.
|
|
97
|
+
- **Login rate limiting** (anti brute-force): **5** consecutive wrong PINs from the same IP lock it for **60s**; a global failure threshold briefly locks everyone (blocks distributed IP-rotation scans); a successful login resets the counter.
|
|
97
98
|
- The public URL is randomly assigned by cloudflared and **changes on every restart** (old links die automatically — a natural key rotation).
|
|
98
99
|
- LAN mode exposes nothing publicly; only devices on the same network can reach it.
|
|
99
100
|
- Built for personal use; the public PIN lives in `$DSH_HOME/dsh-pocket/token` (re-rolled per tunnel start unless customized), the LAN PIN in `$DSH_HOME/dsh-pocket/token-lan` (refreshed manually in Settings), and switches/custom flags in `$DSH_HOME/dsh-pocket/settings.json`.
|
package/README.md
CHANGED
|
@@ -94,6 +94,7 @@ npx @deepseek-ai/dsh web
|
|
|
94
94
|
- **DSH 能执行你电脑上的代码**。**局域网**二维码/URL 配上独立 **8 位数字密码**才是钥匙(密码**默认开启**,可关——关闭后局域网扫码直连,仅同一网络设备可访问),**请勿把局域网二维码、URL 或密码发给别人**
|
|
95
95
|
- **公网**有 **8 位数字密码**保护:链接随机分配、默认每次开启换新密码、旧链接立即作废——泄露了也进不来,改密码/重开即可作废;**自定义密码后不再自动换新**(你设的值即稳定密码)
|
|
96
96
|
- 手机登录状态与电脑上的 dsh web 进程绑定:**电脑 dsh web 一直开着就不用重复输入;重启/更新后需重新输入一次**
|
|
97
|
+
- **登录限速**(防暴力破解):同一 IP 连续输错 **5 次**锁定 **60 秒**;全局失败超阈值时短暂全锁(防换 IP 分布式扫描);输对密码后计数清零
|
|
97
98
|
- 公网 URL 由 cloudflared 随机分配,**每次重启会变化**(旧链接自动失效,相当于天然轮换)
|
|
98
99
|
- 局域网模式不暴露公网,只有同一网络内的设备能访问
|
|
99
100
|
- 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token`(默认每次开启公网自动换新,**自定义后不换**),局域网密码存 `$DSH_HOME/dsh-pocket/token-lan`(设置页手动刷新),开关/自定义标记存 `$DSH_HOME/dsh-pocket/settings.json`
|
package/client/client.js
CHANGED
|
@@ -209,16 +209,22 @@ function MobileNavOverlay({ toggleSidebar, t }) {
|
|
|
209
209
|
document.addEventListener("click", onDrawerClick, true);
|
|
210
210
|
return () => document.removeEventListener("click", onDrawerClick, true);
|
|
211
211
|
}, [mobile, open, toggleSidebar]);
|
|
212
|
+
(0, import_react.useEffect)(() => {
|
|
213
|
+
if (!mobile || !open) return;
|
|
214
|
+
const onOutsideClick = (event) => {
|
|
215
|
+
if (document.querySelector('[aria-modal="true"]') !== null) return;
|
|
216
|
+
const target = event.target;
|
|
217
|
+
if (target === null) return;
|
|
218
|
+
if (target.closest('[data-mobile-nav="toggle"]') !== null) return;
|
|
219
|
+
const drawer = document.querySelector('[data-mobile-nav="frame"] > :first-child');
|
|
220
|
+
if (drawer !== null && drawer.contains(target)) return;
|
|
221
|
+
toggleSidebar();
|
|
222
|
+
};
|
|
223
|
+
document.addEventListener("click", onOutsideClick, true);
|
|
224
|
+
return () => document.removeEventListener("click", onOutsideClick, true);
|
|
225
|
+
}, [mobile, open, toggleSidebar]);
|
|
212
226
|
if (!mobile) return null;
|
|
213
|
-
return /* @__PURE__ */ React.createElement(React.Fragment, null, open && /* @__PURE__ */ React.createElement(
|
|
214
|
-
"div",
|
|
215
|
-
{
|
|
216
|
-
"data-mobile-nav": "backdrop",
|
|
217
|
-
role: "button",
|
|
218
|
-
"aria-label": t("backdrop"),
|
|
219
|
-
onClick: () => toggleSidebar()
|
|
220
|
-
}
|
|
221
|
-
), fabVisible && !open && /* @__PURE__ */ React.createElement(
|
|
227
|
+
return /* @__PURE__ */ React.createElement(React.Fragment, null, open && /* @__PURE__ */ React.createElement("div", { "data-mobile-nav": "backdrop" }), fabVisible && !open && /* @__PURE__ */ React.createElement(
|
|
222
228
|
"button",
|
|
223
229
|
{
|
|
224
230
|
type: "button",
|
|
@@ -361,13 +367,16 @@ var MOBILE_CSS = `
|
|
|
361
367
|
outline-offset: 2px;
|
|
362
368
|
}
|
|
363
369
|
|
|
364
|
-
/* Dimmed backdrop under the open drawer; above every column, below the drawer.
|
|
370
|
+
/* Dimmed backdrop under the open drawer; above every column, below the drawer.
|
|
371
|
+
pointer-events: none \u2014\u2014 \u70B9\u51FB\u7A7F\u900F\uFF08issue #38\uFF09\uFF1Abackdrop \u53EA\u8D1F\u8D23\u89C6\u89C9\u538B\u6697\uFF0C
|
|
372
|
+
\u4E0D\u62A2\u70B9\u51FB\u3002\u5173\u95ED\u62BD\u5C49\u6539\u7531 MobileNavOverlay \u7684 document \u7EA7\u300C\u62BD\u5C49\u5916\u70B9\u51FB\u300D\u76D1\u542C\u5904\u7406
|
|
373
|
+
\uFF08\u7B49\u4EF7\u4E8E\u539F\u6765\u7684\u70B9\u51FB\u906E\u7F69\u5173\u95ED\uFF0C\u4E14\u62BD\u5C49\u5185\u70B9\u51FB\u4E0D\u518D\u88AB backdrop \u5403\u6389\uFF09\u3002 */
|
|
365
374
|
[data-mobile-nav="backdrop"] {
|
|
366
375
|
position: absolute;
|
|
367
376
|
inset: 0;
|
|
368
377
|
z-index: 30;
|
|
369
378
|
background: rgba(0, 0, 0, .45);
|
|
370
|
-
|
|
379
|
+
pointer-events: none;
|
|
371
380
|
animation: dsh-mobile-nav-fade .2s var(--ds-ease-in-out, ease-in-out);
|
|
372
381
|
-webkit-tap-highlight-color: transparent;
|
|
373
382
|
}
|
|
@@ -128,16 +128,33 @@ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
|
|
|
128
128
|
return () => document.removeEventListener('click', onDrawerClick, true)
|
|
129
129
|
}, [mobile, open, toggleSidebar])
|
|
130
130
|
|
|
131
|
+
// Tap-outside closes the drawer (issue #38). The backdrop is now
|
|
132
|
+
// pointer-events: none (pure dimming layer that never steals clicks), so
|
|
133
|
+
// "tap the dimmed area to close" moves here: any click outside the drawer
|
|
134
|
+
// (and outside the header toggle) closes it, keeping the standard
|
|
135
|
+
// interaction while letting drawer contents receive clicks normally.
|
|
136
|
+
// Capture phase: the close happens before the content processes the tap
|
|
137
|
+
// (same first-tap-closes behaviour as before).
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!mobile || !open) return
|
|
140
|
+
const onOutsideClick = (event: MouseEvent) => {
|
|
141
|
+
if (document.querySelector('[aria-modal="true"]') !== null) return
|
|
142
|
+
const target = event.target as HTMLElement | null
|
|
143
|
+
if (target === null) return
|
|
144
|
+
if (target.closest('[data-mobile-nav="toggle"]') !== null) return
|
|
145
|
+
const drawer = document.querySelector<HTMLElement>('[data-mobile-nav="frame"] > :first-child')
|
|
146
|
+
if (drawer !== null && drawer.contains(target)) return
|
|
147
|
+
toggleSidebar()
|
|
148
|
+
}
|
|
149
|
+
document.addEventListener('click', onOutsideClick, true)
|
|
150
|
+
return () => document.removeEventListener('click', onOutsideClick, true)
|
|
151
|
+
}, [mobile, open, toggleSidebar])
|
|
152
|
+
|
|
131
153
|
if (!mobile) return null
|
|
132
154
|
return (
|
|
133
155
|
<>
|
|
134
156
|
{open && (
|
|
135
|
-
<div
|
|
136
|
-
data-mobile-nav="backdrop"
|
|
137
|
-
role="button"
|
|
138
|
-
aria-label={t('backdrop')}
|
|
139
|
-
onClick={() => toggleSidebar()}
|
|
140
|
-
/>
|
|
157
|
+
<div data-mobile-nav="backdrop" />
|
|
141
158
|
)}
|
|
142
159
|
{fabVisible && !open && (
|
|
143
160
|
<button
|
|
@@ -106,13 +106,16 @@ export const MOBILE_CSS = `
|
|
|
106
106
|
outline-offset: 2px;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/* Dimmed backdrop under the open drawer; above every column, below the drawer.
|
|
109
|
+
/* Dimmed backdrop under the open drawer; above every column, below the drawer.
|
|
110
|
+
pointer-events: none —— 点击穿透(issue #38):backdrop 只负责视觉压暗,
|
|
111
|
+
不抢点击。关闭抽屉改由 MobileNavOverlay 的 document 级「抽屉外点击」监听处理
|
|
112
|
+
(等价于原来的点击遮罩关闭,且抽屉内点击不再被 backdrop 吃掉)。 */
|
|
110
113
|
[data-mobile-nav="backdrop"] {
|
|
111
114
|
position: absolute;
|
|
112
115
|
inset: 0;
|
|
113
116
|
z-index: 30;
|
|
114
117
|
background: rgba(0, 0, 0, .45);
|
|
115
|
-
|
|
118
|
+
pointer-events: none;
|
|
116
119
|
animation: dsh-mobile-nav-fade .2s var(--ds-ease-in-out, ease-in-out);
|
|
117
120
|
-webkit-tap-highlight-color: transparent;
|
|
118
121
|
}
|
package/lib/proxy.mjs
CHANGED
|
@@ -79,6 +79,67 @@ function cookieFor(token, sessionKey) {
|
|
|
79
79
|
return createHash('sha256').update(`${token}:${sessionKey}`).digest('hex');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// ---------- 登录速率限制(issue #40,改进版方案 A) ----------
|
|
83
|
+
// 8 位数字密码(10^8 组合)本身可接受,真正风险是「无限制重试」让穷举可行。
|
|
84
|
+
// 这里做三层防护(内存态,随进程生命周期,与 sessionKey 一致):
|
|
85
|
+
// 1) 单 IP 滑动窗口:60 秒内失败 ≥5 次 → 锁 60 秒(429)
|
|
86
|
+
// 2) 全局滑动窗口:1 分钟全局失败 > 50 次 → 全局锁 30 秒(防分布式扫描换 IP 绕过)
|
|
87
|
+
// 3) 成功登录清空该 IP 计数
|
|
88
|
+
// IP 识别:优先 cf-connecting-ip(Cloudflare 在隧道入口设置的**真实**客户端 IP,
|
|
89
|
+
// 可信);无则回退 socket remoteAddress。**不信任客户端 x-forwarded-for**(可伪造)。
|
|
90
|
+
export const DEFAULT_RATE_LIMIT = {
|
|
91
|
+
windowMs: 60_000, // 失败计数滑动窗口
|
|
92
|
+
maxFailures: 5, // 窗口内失败阈值 → 触发单 IP 锁
|
|
93
|
+
lockMs: 60_000, // 单 IP 锁定时长
|
|
94
|
+
globalMaxFailures: 50, // 全局失败阈值(同窗口)→ 触发全局锁
|
|
95
|
+
globalLockMs: 30_000, // 全局锁定时长
|
|
96
|
+
};
|
|
97
|
+
function createRateLimiter(cfg = {}) {
|
|
98
|
+
const c = { ...DEFAULT_RATE_LIMIT, ...cfg };
|
|
99
|
+
const failCounts = new Map(); // ip -> { count, windowStart }
|
|
100
|
+
const ipLocks = new Map(); // ip -> lockedUntil
|
|
101
|
+
const global = { count: 0, windowStart: 0, lockedUntil: 0 };
|
|
102
|
+
return {
|
|
103
|
+
/** 该 IP 当前是否被锁;返回 { locked, retryAfter }。 */
|
|
104
|
+
status(ip) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
if (global.lockedUntil > now) return { locked: true, retryAfter: Math.ceil((global.lockedUntil - now) / 1000) };
|
|
107
|
+
const until = ipLocks.get(ip) ?? 0;
|
|
108
|
+
if (until > now) return { locked: true, retryAfter: Math.ceil((until - now) / 1000) };
|
|
109
|
+
return { locked: false, retryAfter: 0 };
|
|
110
|
+
},
|
|
111
|
+
/** 记一次失败:维护滑动窗口计数,达阈值触发单 IP / 全局锁。 */
|
|
112
|
+
record(ip) {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
let rec = failCounts.get(ip);
|
|
115
|
+
if (!rec || now - rec.windowStart > c.windowMs) rec = { count: 0, windowStart: now };
|
|
116
|
+
rec.count++;
|
|
117
|
+
failCounts.set(ip, rec);
|
|
118
|
+
if (now - global.windowStart > c.windowMs) { global.count = 0; global.windowStart = now; }
|
|
119
|
+
global.count++;
|
|
120
|
+
if (rec.count >= c.maxFailures) ipLocks.set(ip, now + c.lockMs);
|
|
121
|
+
if (global.count >= c.globalMaxFailures) global.lockedUntil = now + c.globalLockMs;
|
|
122
|
+
// 防内存膨胀:超过 2000 条记录时清掉已过窗口期的条目
|
|
123
|
+
if (failCounts.size > 2000) {
|
|
124
|
+
for (const [k, v] of failCounts) {
|
|
125
|
+
if (now - v.windowStart > c.windowMs) failCounts.delete(k);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
/** 成功登录:清空该 IP 计数与锁。 */
|
|
130
|
+
clear(ip) {
|
|
131
|
+
failCounts.delete(ip);
|
|
132
|
+
ipLocks.delete(ip);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** 客户端真实 IP:cf-connecting-ip(隧道,可信)优先,否则 socket 地址;不信 XFF。 */
|
|
137
|
+
function clientIp(req) {
|
|
138
|
+
const cf = String(req.headers['cf-connecting-ip'] ?? '').trim();
|
|
139
|
+
if (cf) return cf;
|
|
140
|
+
return String(req.socket?.remoteAddress ?? 'unknown');
|
|
141
|
+
}
|
|
142
|
+
|
|
82
143
|
function parseCookies(header) {
|
|
83
144
|
const out = {};
|
|
84
145
|
for (const part of String(header ?? '').split(';')) {
|
|
@@ -88,10 +149,13 @@ function parseCookies(header) {
|
|
|
88
149
|
return out;
|
|
89
150
|
}
|
|
90
151
|
|
|
91
|
-
/** 登录页:按访问来源显示提示(局域网 /
|
|
92
|
-
function loginPageHtml(error, isPublic) {
|
|
152
|
+
/** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。 */
|
|
153
|
+
function loginPageHtml(error, isPublic, retryAfter = 0) {
|
|
93
154
|
const where = isPublic ? '此公网地址' : '此局域网地址';
|
|
94
155
|
const whereEn = isPublic ? 'This public address' : 'This LAN address';
|
|
156
|
+
const errMsg = error === 'locked'
|
|
157
|
+
? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — try again in ${retryAfter}s`
|
|
158
|
+
: error ? '密码错误,请重试 | Wrong PIN, try again' : '';
|
|
95
159
|
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
96
160
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
97
161
|
<title>DSH Pocket · 访问验证</title>
|
|
@@ -107,7 +171,7 @@ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;bord
|
|
|
107
171
|
</style></head><body><div class="card">
|
|
108
172
|
<h1>🔐 DSH Pocket</h1>
|
|
109
173
|
<p>${where}受访问密码保护,请输入 8 位数字密码 | ${whereEn} is password-protected — enter the 8-digit PIN</p>
|
|
110
|
-
<div class="err">${
|
|
174
|
+
<div class="err">${errMsg}</div>
|
|
111
175
|
<form method="post" action="/pocket-login">
|
|
112
176
|
<input name="token" type="password" inputmode="numeric" maxlength="8" autocomplete="one-time-code" autofocus required>
|
|
113
177
|
<button type="submit">进入 | Enter</button>
|
|
@@ -152,12 +216,14 @@ function loopbackAuthority(headers, upstream) {
|
|
|
152
216
|
* @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
|
|
153
217
|
* @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
|
|
154
218
|
* @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
|
|
155
|
-
* @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected }
|
|
219
|
+
* @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected, sessionKey }
|
|
220
|
+
* @param {object} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
|
|
156
221
|
* @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
|
|
157
222
|
*/
|
|
158
|
-
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null } = {}) {
|
|
223
|
+
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null } = {}) {
|
|
224
|
+
const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
|
|
159
225
|
const server = createServer((req, res) => {
|
|
160
|
-
// 访问令牌认证(issue #13 + #18 + #33):局域网与公网按开关/来源要求密码
|
|
226
|
+
// 访问令牌认证(issue #13 + #18 + #33 + #40):局域网与公网按开关/来源要求密码
|
|
161
227
|
if (auth) {
|
|
162
228
|
const host = String(req.headers.host ?? '');
|
|
163
229
|
const isPublic = /trycloudflare\.com$/i.test(host);
|
|
@@ -165,13 +231,25 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
165
231
|
const token = protectedHost ? (auth.getToken?.(host) ?? null) : null;
|
|
166
232
|
const sessionKey = auth.sessionKey ?? null;
|
|
167
233
|
if (protectedHost && token) {
|
|
168
|
-
|
|
234
|
+
const ip = clientIp(req);
|
|
235
|
+
// 登录提交:速率限制(issue #40)→ 校验密码 → 种持久 HttpOnly cookie(30 天,绑定进程会话密钥)→ 回首页
|
|
169
236
|
if (req.method === 'POST' && req.url?.startsWith('/pocket-login')) {
|
|
237
|
+
const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
|
|
238
|
+
if (rl.locked) {
|
|
239
|
+
res.writeHead(429, {
|
|
240
|
+
'content-type': 'text/html; charset=utf-8',
|
|
241
|
+
'cache-control': 'no-store',
|
|
242
|
+
'retry-after': String(rl.retryAfter),
|
|
243
|
+
});
|
|
244
|
+
res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
170
247
|
let body = '';
|
|
171
248
|
req.on('data', (c) => { body += c; if (body.length > 1024) req.destroy(); });
|
|
172
249
|
req.on('end', () => {
|
|
173
250
|
const submitted = String(new URLSearchParams(body).get('token') ?? '');
|
|
174
251
|
if (submitted === token) {
|
|
252
|
+
limiter?.clear(ip);
|
|
175
253
|
res.writeHead(302, {
|
|
176
254
|
location: '/',
|
|
177
255
|
'set-cookie': `${TOKEN_COOKIE}=${cookieFor(token, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
|
|
@@ -179,6 +257,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
179
257
|
});
|
|
180
258
|
res.end();
|
|
181
259
|
} else {
|
|
260
|
+
limiter?.record(ip);
|
|
261
|
+
log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`);
|
|
182
262
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
183
263
|
res.end(loginPageHtml(true, isPublic));
|
|
184
264
|
}
|
|
@@ -187,8 +267,10 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
187
267
|
}
|
|
188
268
|
if (!authCheck(req, token, sessionKey)) {
|
|
189
269
|
if (isHtmlRequest(req)) {
|
|
270
|
+
// 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
|
|
271
|
+
const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
|
|
190
272
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
191
|
-
res.end(loginPageHtml(false, isPublic));
|
|
273
|
+
res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter));
|
|
192
274
|
} else {
|
|
193
275
|
res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
194
276
|
res.end('{"error":"unauthorized"}');
|
package/package.json
CHANGED