dscs-server 0.0.0-stage → 0.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/LICENSE +661 -0
- package/README.md +25 -2
- package/dist/blobs.js +50 -0
- package/dist/db.js +262 -0
- package/dist/http.js +1030 -0
- package/dist/index.js +170 -0
- package/dist/leases.js +43 -0
- package/dist/sessions.js +218 -0
- package/dist/shared/batches.js +214 -0
- package/dist/shared/chain.js +139 -0
- package/dist/shared/crypto.js +70 -0
- package/dist/shared/frames.js +102 -0
- package/dist/shared/index.js +11 -0
- package/dist/shared/protocol.js +14 -0
- package/dist/shared/sealed-frame.js +118 -0
- package/dist/shared/vault.js +210 -0
- package/dist/shared/wire.js +25 -0
- package/dist/vault.js +147 -0
- package/package.json +22 -4
- package/web/admin.html +895 -0
- package/web/setup.html +591 -0
package/web/setup.html
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
7
|
+
<title>初始化 dsh-session-cloudsync</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: dark;
|
|
11
|
+
--bg: #0f1115;
|
|
12
|
+
--bg-soft: #14171d;
|
|
13
|
+
--panel: #1a1e25;
|
|
14
|
+
--panel-2: #20252e;
|
|
15
|
+
--line: #2a303a;
|
|
16
|
+
--line-strong: #3a4350;
|
|
17
|
+
--text: #e9edf3;
|
|
18
|
+
--muted: #9aa5b5;
|
|
19
|
+
--faint: #6f7b8c;
|
|
20
|
+
--accent: #5b88ff;
|
|
21
|
+
--accent-2: #7ba4ff;
|
|
22
|
+
--accent-soft: rgba(91, 136, 255, .14);
|
|
23
|
+
--good: #5ed392;
|
|
24
|
+
--good-soft: rgba(94, 211, 146, .12);
|
|
25
|
+
--bad: #ef7777;
|
|
26
|
+
--bad-soft: rgba(239, 119, 119, .12);
|
|
27
|
+
--radius: 16px;
|
|
28
|
+
--shadow: 0 28px 80px rgba(0, 0, 0, .4);
|
|
29
|
+
}
|
|
30
|
+
* { box-sizing: border-box; }
|
|
31
|
+
html, body { min-height: 100%; }
|
|
32
|
+
body {
|
|
33
|
+
margin: 0;
|
|
34
|
+
background:
|
|
35
|
+
radial-gradient(900px 520px at 15% -12%, rgba(91, 136, 255, .14), transparent 64%),
|
|
36
|
+
radial-gradient(700px 420px at 100% 0%, rgba(94, 211, 146, .06), transparent 60%),
|
|
37
|
+
var(--bg);
|
|
38
|
+
color: var(--text);
|
|
39
|
+
font: 14px/1.6 "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
|
|
40
|
+
-webkit-font-smoothing: antialiased;
|
|
41
|
+
}
|
|
42
|
+
button, input, select { font: inherit; }
|
|
43
|
+
button { cursor: pointer; }
|
|
44
|
+
[hidden] { display: none !important; }
|
|
45
|
+
code { font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; }
|
|
46
|
+
.muted { color: var(--muted); }
|
|
47
|
+
.faint { color: var(--faint); }
|
|
48
|
+
|
|
49
|
+
.setup-page {
|
|
50
|
+
min-height: 100vh;
|
|
51
|
+
display: grid;
|
|
52
|
+
place-items: center;
|
|
53
|
+
padding: 30px 18px;
|
|
54
|
+
}
|
|
55
|
+
.wizard-wrap { width: min(640px, 100%); }
|
|
56
|
+
|
|
57
|
+
.btn {
|
|
58
|
+
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
|
59
|
+
min-height: 36px; padding: 8px 15px;
|
|
60
|
+
border: 1px solid transparent; border-radius: 9px;
|
|
61
|
+
background: var(--accent); color: #fff;
|
|
62
|
+
font-size: 13px; font-weight: 600; white-space: nowrap;
|
|
63
|
+
transition: background .16s, border-color .16s, opacity .16s, transform .16s;
|
|
64
|
+
}
|
|
65
|
+
.btn:hover { background: #6b95ff; }
|
|
66
|
+
.btn:active { transform: translateY(1px); }
|
|
67
|
+
.btn:disabled { opacity: .45; cursor: default; transform: none; }
|
|
68
|
+
.btn.secondary { background: var(--panel-2); border-color: var(--line); color: var(--text); }
|
|
69
|
+
.btn.secondary:hover { background: #29303a; }
|
|
70
|
+
.btn.ghost { background: transparent; border-color: var(--line); color: var(--muted); }
|
|
71
|
+
.btn.ghost:hover { color: var(--text); border-color: var(--line-strong); background: rgba(255,255,255,.03); }
|
|
72
|
+
.btn.small { min-height: 30px; padding: 5px 10px; font-size: 12px; }
|
|
73
|
+
.btn.wide { width: 100%; }
|
|
74
|
+
|
|
75
|
+
input, select {
|
|
76
|
+
width: 100%; min-height: 39px;
|
|
77
|
+
padding: 8px 11px;
|
|
78
|
+
border: 1px solid var(--line-strong); border-radius: 9px;
|
|
79
|
+
background: var(--bg-soft); color: var(--text);
|
|
80
|
+
outline: none;
|
|
81
|
+
transition: border-color .16s, box-shadow .16s;
|
|
82
|
+
}
|
|
83
|
+
input:hover, select:hover { border-color: #4a5566; }
|
|
84
|
+
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
85
|
+
input::placeholder { color: var(--faint); }
|
|
86
|
+
label { display: block; color: var(--muted); font-size: 12px; font-weight: 500; margin: 16px 0 6px; }
|
|
87
|
+
|
|
88
|
+
.wizard {
|
|
89
|
+
position: relative;
|
|
90
|
+
overflow: hidden;
|
|
91
|
+
border: 1px solid var(--line);
|
|
92
|
+
border-radius: 20px;
|
|
93
|
+
background: linear-gradient(180deg, rgba(31,36,45,.95), rgba(24,28,35,.97));
|
|
94
|
+
box-shadow: var(--shadow);
|
|
95
|
+
padding: 27px 29px 24px;
|
|
96
|
+
animation: wizard-in .42s cubic-bezier(.16,1,.3,1) both;
|
|
97
|
+
}
|
|
98
|
+
.wizard:before {
|
|
99
|
+
content: "";
|
|
100
|
+
position: absolute; inset: 0 0 auto 0; height: 1px;
|
|
101
|
+
background: linear-gradient(90deg, transparent, rgba(123,164,255,.52), transparent);
|
|
102
|
+
}
|
|
103
|
+
.brandline { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
104
|
+
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
105
|
+
.brand img { width: 30px; height: 30px; }
|
|
106
|
+
.brand b { font-size: 13px; font-weight: 650; letter-spacing: -.01em; }
|
|
107
|
+
.brand span { display: block; color: var(--faint); font-size: 10.5px; line-height: 1.4; }
|
|
108
|
+
.eyebrow {
|
|
109
|
+
display: inline-block;
|
|
110
|
+
color: var(--accent-2); font-size: 10.5px; font-weight: 700;
|
|
111
|
+
letter-spacing: .14em; text-transform: uppercase;
|
|
112
|
+
}
|
|
113
|
+
.wizard-head { margin-top: 24px; }
|
|
114
|
+
.wizard-head h1 { margin: 6px 0 4px; font-size: 23px; line-height: 1.2; letter-spacing: -.015em; }
|
|
115
|
+
.wizard-head p { margin: 0; color: var(--muted); font-size: 12.5px; }
|
|
116
|
+
|
|
117
|
+
.stepper {
|
|
118
|
+
display: flex; align-items: center; gap: 7px;
|
|
119
|
+
margin: 22px 0 23px; padding: 0; list-style: none;
|
|
120
|
+
}
|
|
121
|
+
.step {
|
|
122
|
+
display: flex; align-items: center; gap: 7px;
|
|
123
|
+
flex: 1 1 0; min-width: 0;
|
|
124
|
+
color: var(--faint); font-size: 11.5px; white-space: nowrap;
|
|
125
|
+
}
|
|
126
|
+
.step i {
|
|
127
|
+
width: 24px; height: 24px; flex: 0 0 auto;
|
|
128
|
+
display: grid; place-items: center;
|
|
129
|
+
border: 1px solid var(--line-strong); border-radius: 50%;
|
|
130
|
+
font-style: normal; font-size: 10.5px;
|
|
131
|
+
transition: background .18s, border-color .18s, color .18s;
|
|
132
|
+
}
|
|
133
|
+
.step.on { color: var(--text); }
|
|
134
|
+
.step.on i { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
135
|
+
.step.done { color: var(--muted); }
|
|
136
|
+
.step.done i { color: var(--good); border-color: rgba(94,211,146,.52); background: var(--good-soft); }
|
|
137
|
+
.step:not(:last-child):after { content: ""; height: 1px; flex: 1 1 auto; min-width: 8px; background: var(--line); }
|
|
138
|
+
|
|
139
|
+
.wizard-body { position: relative; }
|
|
140
|
+
.step-panel { display: flex; flex-direction: column; min-height: 212px; }
|
|
141
|
+
.step-panel.step-next { animation: step-next .28s cubic-bezier(.16,1,.3,1) both; }
|
|
142
|
+
.step-panel.step-back { animation: step-back .28s cubic-bezier(.16,1,.3,1) both; }
|
|
143
|
+
.step-panel h2 { margin: 0; font-size: 16px; font-weight: 650; }
|
|
144
|
+
.step-panel .hint { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
|
|
145
|
+
.step-panel .spacer { flex: 1 1 auto; min-height: 16px; }
|
|
146
|
+
.actions { display: flex; align-items: center; gap: 9px; margin-top: 20px; }
|
|
147
|
+
.actions .grow { flex: 1 1 auto; }
|
|
148
|
+
.error { min-height: 19px; margin-top: 8px; color: #ffaaaa; font-size: 12px; }
|
|
149
|
+
.notice {
|
|
150
|
+
margin-top: 15px; padding: 9px 11px;
|
|
151
|
+
border: 1px solid rgba(240,182,74,.22); border-radius: 9px;
|
|
152
|
+
background: rgba(240,182,74,.07); color: #e7c37d; font-size: 11.5px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.code-input { letter-spacing: .14em; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 16px; text-transform: uppercase; }
|
|
156
|
+
.input-wrap { position: relative; }
|
|
157
|
+
.input-wrap input { padding-right: 56px; }
|
|
158
|
+
.input-wrap .reveal {
|
|
159
|
+
position: absolute; right: 5px; top: 50%; transform: translateY(-50%);
|
|
160
|
+
border: 0; background: transparent; color: var(--muted);
|
|
161
|
+
font-size: 11px; padding: 5px 6px; border-radius: 6px;
|
|
162
|
+
}
|
|
163
|
+
.input-wrap .reveal:hover { color: var(--text); background: rgba(255,255,255,.05); }
|
|
164
|
+
|
|
165
|
+
.requirements { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
166
|
+
.req { color: var(--faint); font-size: 10.5px; padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px; }
|
|
167
|
+
.req.ok { color: #9ce8bc; border-color: rgba(94,211,146,.3); background: var(--good-soft); }
|
|
168
|
+
|
|
169
|
+
.quota-grid { display: grid; grid-template-columns: 1fr 150px; gap: 10px; align-items: end; }
|
|
170
|
+
.quota-grid .unit { display: flex; align-items: center; gap: 7px; color: var(--faint); font-size: 12px; }
|
|
171
|
+
.quota-grid .unit input { width: 100%; }
|
|
172
|
+
.token-panel {
|
|
173
|
+
margin-top: 16px; padding: 13px;
|
|
174
|
+
border: 1px solid rgba(94,211,146,.25); border-radius: 11px;
|
|
175
|
+
background: rgba(94,211,146,.055);
|
|
176
|
+
}
|
|
177
|
+
.token-panel .token-title { color: #9ce8bc; font-size: 12px; font-weight: 600; }
|
|
178
|
+
.token-panel .token-note { margin: 3px 0 9px; color: var(--faint); font-size: 10.5px; }
|
|
179
|
+
.token-value {
|
|
180
|
+
padding: 10px; border: 1px solid var(--line); border-radius: 8px;
|
|
181
|
+
background: var(--bg); color: #c7dbff;
|
|
182
|
+
font-size: 11.5px; line-height: 1.55; word-break: break-all;
|
|
183
|
+
}
|
|
184
|
+
.token-actions { display: flex; gap: 7px; margin-top: 9px; }
|
|
185
|
+
|
|
186
|
+
.done-panel { align-items: flex-start; justify-content: center; }
|
|
187
|
+
.success-mark {
|
|
188
|
+
width: 46px; height: 46px; border-radius: 50%;
|
|
189
|
+
display: grid; place-items: center;
|
|
190
|
+
color: var(--good); background: var(--good-soft); border: 1px solid rgba(94,211,146,.3);
|
|
191
|
+
font-size: 21px; margin-bottom: 14px;
|
|
192
|
+
animation: pop-in .36s cubic-bezier(.16,1,.3,1) both;
|
|
193
|
+
}
|
|
194
|
+
.done-panel h2 { font-size: 22px; }
|
|
195
|
+
.setup-foot { margin: 17px 0 0; text-align: center; color: var(--faint); font-size: 11px; }
|
|
196
|
+
|
|
197
|
+
.toast {
|
|
198
|
+
position: fixed; left: 50%; bottom: 24px; z-index: 80;
|
|
199
|
+
max-width: min(520px, calc(100vw - 32px));
|
|
200
|
+
padding: 9px 14px; border: 1px solid var(--line-strong); border-radius: 10px;
|
|
201
|
+
background: rgba(30,35,44,.97); color: var(--text);
|
|
202
|
+
box-shadow: 0 16px 40px rgba(0,0,0,.4);
|
|
203
|
+
font-size: 12px; opacity: 0; pointer-events: none;
|
|
204
|
+
transform: translate(-50%, 8px);
|
|
205
|
+
transition: opacity .18s, transform .18s;
|
|
206
|
+
}
|
|
207
|
+
.toast.on { opacity: 1; transform: translate(-50%, 0); }
|
|
208
|
+
.toast.bad { border-color: rgba(239,119,119,.4); color: #ffb0b0; }
|
|
209
|
+
|
|
210
|
+
@keyframes wizard-in { from { opacity: 0; transform: translateY(-12px) scale(.985); } to { opacity: 1; transform: none; } }
|
|
211
|
+
@keyframes step-next { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: none; } }
|
|
212
|
+
@keyframes step-back { from { opacity: 0; transform: translateX(-10px); } to { opacity: 1; transform: none; } }
|
|
213
|
+
@keyframes pop-in { from { opacity: 0; transform: scale(.75); } to { opacity: 1; transform: none; } }
|
|
214
|
+
@media (prefers-reduced-motion: reduce) {
|
|
215
|
+
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
|
216
|
+
}
|
|
217
|
+
@media (max-width: 560px) {
|
|
218
|
+
.setup-page { padding: 16px 12px; }
|
|
219
|
+
.wizard { padding: 21px 18px 19px; border-radius: 16px; }
|
|
220
|
+
.wizard-head h1 { font-size: 20px; }
|
|
221
|
+
.step { font-size: 10px; gap: 4px; }
|
|
222
|
+
.step i { width: 21px; height: 21px; font-size: 9.5px; }
|
|
223
|
+
.step:not(:last-child):after { min-width: 4px; }
|
|
224
|
+
.quota-grid { grid-template-columns: 1fr; }
|
|
225
|
+
.actions { flex-wrap: wrap; }
|
|
226
|
+
}
|
|
227
|
+
</style>
|
|
228
|
+
</head>
|
|
229
|
+
<body>
|
|
230
|
+
<div class="setup-page">
|
|
231
|
+
<main class="wizard-wrap">
|
|
232
|
+
<section class="wizard" aria-labelledby="setupTitle">
|
|
233
|
+
<div class="brandline">
|
|
234
|
+
<div class="brand">
|
|
235
|
+
<img src="/logo.svg" alt="">
|
|
236
|
+
<div><b>dsh-session-cloudsync</b><span>Self-hosted setup</span></div>
|
|
237
|
+
</div>
|
|
238
|
+
<span class="eyebrow">E2E encrypted</span>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div class="wizard-head">
|
|
242
|
+
<h1 id="setupTitle">初始化你的同步服务</h1>
|
|
243
|
+
<p>设置管理员、存储配额,并可选地创建第一台设备。</p>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<ol id="stepper" class="stepper" aria-label="初始化进度">
|
|
247
|
+
<li class="step on" data-step="1"><i>1</i><span>密钥</span></li>
|
|
248
|
+
<li class="step" data-step="2"><i>2</i><span>管理员</span></li>
|
|
249
|
+
<li class="step" data-step="3"><i>3</i><span>配额</span></li>
|
|
250
|
+
<li class="step" data-step="4"><i>4</i><span>设备</span></li>
|
|
251
|
+
</ol>
|
|
252
|
+
|
|
253
|
+
<div class="wizard-body">
|
|
254
|
+
<section class="step-panel step-next" data-panel="1">
|
|
255
|
+
<h2>输入初始化密钥</h2>
|
|
256
|
+
<p class="hint">密钥打印在启动服务的控制台里,形如 <code>ABCD-EFGH</code>,10 分钟内有效。</p>
|
|
257
|
+
<div id="resumeHint" class="notice" hidden>服务端已保存部分初始化进度;验证密钥后继续。</div>
|
|
258
|
+
<label for="code">初始化密钥</label>
|
|
259
|
+
<input id="code" class="code-input" autocomplete="off" placeholder="ABCD-EFGH" maxlength="9" spellcheck="false" autofocus>
|
|
260
|
+
<div id="error1" class="error" role="alert"></div>
|
|
261
|
+
<div class="spacer"></div>
|
|
262
|
+
<div class="actions">
|
|
263
|
+
<button id="verify" class="btn wide" type="button">继续</button>
|
|
264
|
+
</div>
|
|
265
|
+
</section>
|
|
266
|
+
|
|
267
|
+
<section class="step-panel" data-panel="2" hidden>
|
|
268
|
+
<h2>设置管理密码</h2>
|
|
269
|
+
<p class="hint">至少 6 位,且字母、数字、特殊字符中至少占两类。</p>
|
|
270
|
+
<label for="password">密码</label>
|
|
271
|
+
<div class="input-wrap">
|
|
272
|
+
<input id="password" type="password" autocomplete="new-password">
|
|
273
|
+
<button class="reveal" type="button" data-reveal="password">显示</button>
|
|
274
|
+
</div>
|
|
275
|
+
<label for="confirm">再输一次</label>
|
|
276
|
+
<input id="confirm" type="password" autocomplete="new-password">
|
|
277
|
+
<div class="requirements" aria-live="polite">
|
|
278
|
+
<span id="reqLength" class="req">至少 6 位</span>
|
|
279
|
+
<span id="reqMix" class="req">两类字符</span>
|
|
280
|
+
</div>
|
|
281
|
+
<div id="error2" class="error" role="alert"></div>
|
|
282
|
+
<div class="spacer"></div>
|
|
283
|
+
<div class="actions">
|
|
284
|
+
<button id="back2" class="btn ghost" type="button">上一步</button>
|
|
285
|
+
<span class="grow"></span>
|
|
286
|
+
<button id="savePassword" class="btn" type="button">继续</button>
|
|
287
|
+
</div>
|
|
288
|
+
</section>
|
|
289
|
+
|
|
290
|
+
<section class="step-panel" data-panel="3" hidden>
|
|
291
|
+
<h2>设置存储配额</h2>
|
|
292
|
+
<p class="hint">按已上传密文的唯一字节计算;之后可以在管理台修改。</p>
|
|
293
|
+
<label for="quotaPreset">预设</label>
|
|
294
|
+
<select id="quotaPreset">
|
|
295
|
+
<option value="1">1 GiB</option>
|
|
296
|
+
<option value="5">5 GiB</option>
|
|
297
|
+
<option value="10" selected>10 GiB(默认)</option>
|
|
298
|
+
<option value="50">50 GiB</option>
|
|
299
|
+
<option value="custom">自定义</option>
|
|
300
|
+
</select>
|
|
301
|
+
<div id="customQuota" class="quota-grid" hidden>
|
|
302
|
+
<div>
|
|
303
|
+
<label for="quotaCustom">自定义配额</label>
|
|
304
|
+
<input id="quotaCustom" type="number" min="1" step="1" placeholder="例如 20">
|
|
305
|
+
</div>
|
|
306
|
+
<div class="unit">GiB</div>
|
|
307
|
+
</div>
|
|
308
|
+
<div id="error3" class="error" role="alert"></div>
|
|
309
|
+
<div class="spacer"></div>
|
|
310
|
+
<div class="actions">
|
|
311
|
+
<button id="back3" class="btn ghost" type="button">上一步</button>
|
|
312
|
+
<span class="grow"></span>
|
|
313
|
+
<button id="saveQuota" class="btn" type="button">继续</button>
|
|
314
|
+
</div>
|
|
315
|
+
</section>
|
|
316
|
+
|
|
317
|
+
<section class="step-panel" data-panel="4" hidden>
|
|
318
|
+
<h2>第一台设备</h2>
|
|
319
|
+
<p class="hint">给要同步的设备起个名字,生成一个 token;token 只显示一次。也可以先跳过。</p>
|
|
320
|
+
<label for="deviceLabel">设备标签</label>
|
|
321
|
+
<input id="deviceLabel" placeholder="例如 PC-1" autocomplete="off">
|
|
322
|
+
<div id="error4" class="error" role="alert"></div>
|
|
323
|
+
<div id="tokenPanel" class="token-panel" hidden>
|
|
324
|
+
<div class="token-title">设备已创建</div>
|
|
325
|
+
<div class="token-note">关闭页面后无法再次查看,请现在保存。</div>
|
|
326
|
+
<div id="tokenValue" class="token-value" tabindex="0"></div>
|
|
327
|
+
<div class="token-actions">
|
|
328
|
+
<button id="copyToken" class="btn secondary small" type="button">复制 token</button>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
<div class="spacer"></div>
|
|
332
|
+
<div class="actions">
|
|
333
|
+
<button id="back4" class="btn ghost" type="button">上一步</button>
|
|
334
|
+
<span class="grow"></span>
|
|
335
|
+
<button id="createDevice" class="btn secondary" type="button">创建设备</button>
|
|
336
|
+
<button id="skipDevice" class="btn secondary" type="button">跳过</button>
|
|
337
|
+
<button id="finish" class="btn" type="button" hidden>完成初始化</button>
|
|
338
|
+
</div>
|
|
339
|
+
</section>
|
|
340
|
+
|
|
341
|
+
<section class="step-panel done-panel" data-panel="5" hidden>
|
|
342
|
+
<div class="success-mark" aria-hidden="true">✓</div>
|
|
343
|
+
<h2>初始化完成</h2>
|
|
344
|
+
<p class="hint">管理台已经可以使用。之后可以在管理台查看设备、会话和存储。</p>
|
|
345
|
+
<div class="spacer"></div>
|
|
346
|
+
<div class="actions">
|
|
347
|
+
<button id="goAdmin" class="btn" type="button">进入管理台</button>
|
|
348
|
+
</div>
|
|
349
|
+
</section>
|
|
350
|
+
</div>
|
|
351
|
+
</section>
|
|
352
|
+
<p class="setup-foot">dsh-session-cloudsync · 端到端加密 · 自部署</p>
|
|
353
|
+
</main>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
|
357
|
+
|
|
358
|
+
<script>
|
|
359
|
+
const $ = (id) => document.getElementById(id)
|
|
360
|
+
let activeStep = 1
|
|
361
|
+
let toastTimer = null
|
|
362
|
+
|
|
363
|
+
function showToast(text, bad) {
|
|
364
|
+
const node = $('toast')
|
|
365
|
+
node.textContent = text
|
|
366
|
+
node.classList.toggle('bad', bad === true)
|
|
367
|
+
node.classList.add('on')
|
|
368
|
+
clearTimeout(toastTimer)
|
|
369
|
+
toastTimer = setTimeout(() => node.classList.remove('on'), 2800)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function copyText(text) {
|
|
373
|
+
try {
|
|
374
|
+
await navigator.clipboard.writeText(text)
|
|
375
|
+
} catch (error) {
|
|
376
|
+
const area = document.createElement('textarea')
|
|
377
|
+
area.value = text
|
|
378
|
+
area.style.position = 'fixed'
|
|
379
|
+
area.style.opacity = '0'
|
|
380
|
+
document.body.appendChild(area)
|
|
381
|
+
area.select()
|
|
382
|
+
document.execCommand('copy')
|
|
383
|
+
area.remove()
|
|
384
|
+
}
|
|
385
|
+
showToast('已复制')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function post(path, body) {
|
|
389
|
+
const response = await fetch(path, {
|
|
390
|
+
method: 'POST',
|
|
391
|
+
headers: { 'content-type': 'application/json' },
|
|
392
|
+
body: JSON.stringify(body || {}),
|
|
393
|
+
})
|
|
394
|
+
const payload = await response.json().catch(() => ({}))
|
|
395
|
+
if (!response.ok) throw new Error((payload && payload.error && payload.error.message) || '请求失败')
|
|
396
|
+
return payload.value
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function markSteps(index) {
|
|
400
|
+
for (const item of document.querySelectorAll('.step')) {
|
|
401
|
+
const number = Number(item.dataset.step)
|
|
402
|
+
const done = number < index
|
|
403
|
+
item.classList.toggle('on', number === index)
|
|
404
|
+
item.classList.toggle('done', done)
|
|
405
|
+
const icon = item.querySelector('i')
|
|
406
|
+
if (icon !== null) icon.textContent = done ? '✓' : String(number)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function showStep(index, direction) {
|
|
411
|
+
activeStep = index
|
|
412
|
+
for (const panel of document.querySelectorAll('.step-panel')) {
|
|
413
|
+
panel.hidden = Number(panel.dataset.panel) !== index
|
|
414
|
+
}
|
|
415
|
+
const panel = document.querySelector('.step-panel[data-panel="' + index + '"]')
|
|
416
|
+
if (panel !== null) {
|
|
417
|
+
panel.classList.remove('step-next', 'step-back')
|
|
418
|
+
void panel.offsetWidth
|
|
419
|
+
panel.classList.add(direction === 'back' ? 'step-back' : 'step-next')
|
|
420
|
+
const focusable = panel.querySelector('input:not([type="hidden"]), button')
|
|
421
|
+
if (focusable !== null) setTimeout(() => focusable.focus(), 60)
|
|
422
|
+
}
|
|
423
|
+
markSteps(index)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function setBusy(button, busy, label) {
|
|
427
|
+
button.disabled = busy
|
|
428
|
+
if (busy) {
|
|
429
|
+
button.dataset.label = button.textContent
|
|
430
|
+
button.textContent = label
|
|
431
|
+
} else {
|
|
432
|
+
button.textContent = button.dataset.label || button.textContent
|
|
433
|
+
delete button.dataset.label
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function passwordProblem(value) {
|
|
438
|
+
if (value.length < 6) return '密码至少 6 位'
|
|
439
|
+
const kinds = [/[A-Za-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((pattern) => pattern.test(value)).length
|
|
440
|
+
if (kinds < 2) return '密码需要字母、数字、特殊字符中至少两类'
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function updateRequirements() {
|
|
445
|
+
const value = $('password').value
|
|
446
|
+
const kinds = [/[A-Za-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((pattern) => pattern.test(value)).length
|
|
447
|
+
$('reqLength').classList.toggle('ok', value.length >= 6)
|
|
448
|
+
$('reqMix').classList.toggle('ok', kinds >= 2)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
$('code').addEventListener('input', () => {
|
|
452
|
+
const caret = $('code').selectionStart
|
|
453
|
+
$('code').value = $('code').value.toUpperCase()
|
|
454
|
+
if (caret !== null) $('code').setSelectionRange(caret, caret)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
$('verify').onclick = async () => {
|
|
458
|
+
const button = $('verify')
|
|
459
|
+
$('error1').textContent = ''
|
|
460
|
+
if ($('code').value.trim() === '') {
|
|
461
|
+
$('error1').textContent = '请输入初始化密钥'
|
|
462
|
+
$('code').focus()
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
setBusy(button, true, '验证中…')
|
|
466
|
+
try {
|
|
467
|
+
await post('/setup/verify', { code: $('code').value.trim() })
|
|
468
|
+
showStep(2, 'next')
|
|
469
|
+
} catch (error) {
|
|
470
|
+
$('error1').textContent = error.message
|
|
471
|
+
} finally {
|
|
472
|
+
setBusy(button, false)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
$('password').addEventListener('input', updateRequirements)
|
|
476
|
+
$('confirm').addEventListener('keydown', (event) => { if (event.key === 'Enter') $('savePassword').click() })
|
|
477
|
+
$('savePassword').onclick = async () => {
|
|
478
|
+
const button = $('savePassword')
|
|
479
|
+
$('error2').textContent = ''
|
|
480
|
+
const problem = passwordProblem($('password').value)
|
|
481
|
+
if (problem !== null) {
|
|
482
|
+
$('error2').textContent = problem
|
|
483
|
+
$('password').focus()
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
if ($('password').value !== $('confirm').value) {
|
|
487
|
+
$('error2').textContent = '两次输入不一致'
|
|
488
|
+
$('confirm').focus()
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
setBusy(button, true, '保存中…')
|
|
492
|
+
try {
|
|
493
|
+
await post('/setup/password', { password: $('password').value })
|
|
494
|
+
showStep(3, 'next')
|
|
495
|
+
} catch (error) {
|
|
496
|
+
$('error2').textContent = error.message
|
|
497
|
+
} finally {
|
|
498
|
+
setBusy(button, false)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
$('back2').onclick = () => showStep(1, 'back')
|
|
502
|
+
$('back3').onclick = () => showStep(2, 'back')
|
|
503
|
+
$('back4').onclick = () => showStep(3, 'back')
|
|
504
|
+
|
|
505
|
+
$('quotaPreset').onchange = () => {
|
|
506
|
+
const custom = $('quotaPreset').value === 'custom'
|
|
507
|
+
$('customQuota').hidden = !custom
|
|
508
|
+
if (custom) setTimeout(() => $('quotaCustom').focus(), 30)
|
|
509
|
+
}
|
|
510
|
+
$('saveQuota').onclick = async () => {
|
|
511
|
+
const button = $('saveQuota')
|
|
512
|
+
const preset = $('quotaPreset').value
|
|
513
|
+
const gib = preset === 'custom' ? Number($('quotaCustom').value) : Number(preset)
|
|
514
|
+
$('error3').textContent = ''
|
|
515
|
+
if (!Number.isFinite(gib) || gib < 1) {
|
|
516
|
+
$('error3').textContent = '配额至少 1 GiB'
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
setBusy(button, true, '保存中…')
|
|
520
|
+
try {
|
|
521
|
+
await post('/setup/quota', { maxBytes: Math.round(gib * 1024 * 1024 * 1024) })
|
|
522
|
+
showStep(4, 'next')
|
|
523
|
+
} catch (error) {
|
|
524
|
+
$('error3').textContent = error.message
|
|
525
|
+
} finally {
|
|
526
|
+
setBusy(button, false)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function prepareFinish() {
|
|
531
|
+
$('createDevice').hidden = true
|
|
532
|
+
$('skipDevice').hidden = true
|
|
533
|
+
$('finish').hidden = false
|
|
534
|
+
}
|
|
535
|
+
$('createDevice').onclick = async () => {
|
|
536
|
+
const button = $('createDevice')
|
|
537
|
+
$('error4').textContent = ''
|
|
538
|
+
setBusy(button, true, '创建中…')
|
|
539
|
+
try {
|
|
540
|
+
const created = await post('/setup/device', { label: $('deviceLabel').value.trim() || 'device' })
|
|
541
|
+
$('tokenValue').textContent = created.token
|
|
542
|
+
$('tokenPanel').hidden = false
|
|
543
|
+
$('copyToken').onclick = () => copyText(created.token)
|
|
544
|
+
prepareFinish()
|
|
545
|
+
} catch (error) {
|
|
546
|
+
$('error4').textContent = error.message
|
|
547
|
+
} finally {
|
|
548
|
+
setBusy(button, false)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
$('skipDevice').onclick = () => {
|
|
552
|
+
$('tokenPanel').hidden = true
|
|
553
|
+
prepareFinish()
|
|
554
|
+
}
|
|
555
|
+
$('finish').onclick = async () => {
|
|
556
|
+
const button = $('finish')
|
|
557
|
+
$('error4').textContent = ''
|
|
558
|
+
setBusy(button, true, '完成中…')
|
|
559
|
+
try {
|
|
560
|
+
await post('/setup/complete')
|
|
561
|
+
showStep(5, 'next')
|
|
562
|
+
for (const item of document.querySelectorAll('.step')) item.classList.add('done')
|
|
563
|
+
$('setupTitle').textContent = '初始化完成'
|
|
564
|
+
} catch (error) {
|
|
565
|
+
$('error4').textContent = error.message
|
|
566
|
+
} finally {
|
|
567
|
+
setBusy(button, false)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
$('goAdmin').onclick = () => { location.href = '/admin' }
|
|
571
|
+
|
|
572
|
+
for (const button of document.querySelectorAll('[data-reveal]')) {
|
|
573
|
+
button.onclick = () => {
|
|
574
|
+
const input = $(button.dataset.reveal)
|
|
575
|
+
const reveal = input.type === 'password'
|
|
576
|
+
input.type = reveal ? 'text' : 'password'
|
|
577
|
+
button.textContent = reveal ? '隐藏' : '显示'
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
fetch('/setup/state').then((response) => response.json()).then((payload) => {
|
|
582
|
+
const value = payload && payload.value
|
|
583
|
+
if (!value) return
|
|
584
|
+
if (value.initialized === true) { location.href = '/admin'; return }
|
|
585
|
+
if (value.hasPassword === true || value.hasQuota === true || value.devices > 0) $('resumeHint').hidden = false
|
|
586
|
+
}).catch(() => {})
|
|
587
|
+
|
|
588
|
+
showStep(1, 'next')
|
|
589
|
+
</script>
|
|
590
|
+
</body>
|
|
591
|
+
</html>
|