docnest 0.1.9 → 0.1.11
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 +3 -1
- package/dist/auth.d.ts +0 -2
- package/dist/auth.js +1 -18
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/server.js +13 -29
- package/docnest.config.example.mjs +3 -1
- package/package.json +1 -1
- package/server/static/css/styles.css +9 -94
- package/server/static/js/core/auth.js +144 -53
- package/server/static/js/core/doc-client-nav.js +19 -1
- package/server/views/login.ejs +8 -3
- package/server/views/partials/appearance-head.ejs +5 -0
- package/server/views/partials/header.ejs +0 -42
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ export default defineConfig({
|
|
|
68
68
|
enabled: true,
|
|
69
69
|
passphrase: '请替换为项目自己的长口令',
|
|
70
70
|
stateFile: '.docnest/auth.json',
|
|
71
|
+
localStorageKey: 'my-project:auth-passphrase',
|
|
71
72
|
sessionTtlMinutes: 24 * 60,
|
|
72
73
|
},
|
|
73
74
|
})
|
|
@@ -75,7 +76,8 @@ export default defineConfig({
|
|
|
75
76
|
|
|
76
77
|
授权只有一道关口,没有账号、密码组合或角色体系。首次启动时,`passphrase` 会初始化到由
|
|
77
78
|
`stateFile` 指定的本地授权状态文件;状态文件只保存带随机盐的口令哈希,不保存明文口令。
|
|
78
|
-
|
|
79
|
+
按极简模式约定,前端会把可回填的口令永久保存到浏览器 `localStorage`(不写入 URL);每次打开或刷新文档页面,前端都会把当前缓存的口令交给后台重新校验。校验失败会回到口令页,校验成功才会继续打开当前页面;输入页会保留跳转前的目标地址,错误口令会停留在输入页。由于 `localStorage` 是浏览器本地明文存储,请只在受信任的浏览器环境使用此模式。
|
|
80
|
+
`localStorageKey` 可选,用于为不同宿主项目隔离浏览器存储键。内置授权不提供“修改口令”或“退出”按钮;需要更换口令时,停止服务、删除 `stateFile`,修改配置中的 `passphrase` 后重新启动。
|
|
79
81
|
建议把 `.docnest/` 加入宿主项目的 `.gitignore`,并为口令状态文件设置仅当前用户可读写的权限。
|
|
80
82
|
|
|
81
83
|
授权开启但既没有状态文件,也没有初始 `passphrase` 时,DocNest 会拒绝启动,避免意外以空口令开放文档。
|
package/dist/auth.d.ts
CHANGED
|
@@ -12,7 +12,5 @@ export type DocNestAuthManager = {
|
|
|
12
12
|
verifyPassphrase: (passphrase: string) => boolean;
|
|
13
13
|
createSession: (passphrase: string) => string | null;
|
|
14
14
|
authenticateSession: (token: string | undefined) => boolean;
|
|
15
|
-
revokeSession: (token: string | undefined) => void;
|
|
16
|
-
changePassphrase: (currentPassphrase: string, nextPassphrase: string) => boolean;
|
|
17
15
|
};
|
|
18
16
|
export declare function createAuthManager(config: DocNestAuthConfig): DocNestAuthManager;
|
package/dist/auth.js
CHANGED
|
@@ -130,11 +130,9 @@ function createAuthManager(config) {
|
|
|
130
130
|
verifyPassphrase: () => true,
|
|
131
131
|
createSession: () => null,
|
|
132
132
|
authenticateSession: () => true,
|
|
133
|
-
revokeSession: () => undefined,
|
|
134
|
-
changePassphrase: () => false,
|
|
135
133
|
};
|
|
136
134
|
}
|
|
137
|
-
|
|
135
|
+
const state = readOrCreateState(config);
|
|
138
136
|
function verifyPassphrase(passphrase) {
|
|
139
137
|
return matchesHash(passphrase, state);
|
|
140
138
|
}
|
|
@@ -163,19 +161,6 @@ function createAuthManager(config) {
|
|
|
163
161
|
session.expiresAt = Date.now() + sessionMaxAgeSeconds * 1000;
|
|
164
162
|
return true;
|
|
165
163
|
}
|
|
166
|
-
function revokeSession(token) {
|
|
167
|
-
if (token)
|
|
168
|
-
sessions.delete(token);
|
|
169
|
-
}
|
|
170
|
-
function changePassphrase(currentPassphrase, nextPassphrase) {
|
|
171
|
-
if (!verifyPassphrase(currentPassphrase))
|
|
172
|
-
return false;
|
|
173
|
-
assertPassphrase(nextPassphrase, '新授权口令');
|
|
174
|
-
state = createState(nextPassphrase);
|
|
175
|
-
writeState(config.stateFile, state);
|
|
176
|
-
sessions.clear();
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
164
|
return {
|
|
180
165
|
enabled,
|
|
181
166
|
stateFile: config.stateFile,
|
|
@@ -184,7 +169,5 @@ function createAuthManager(config) {
|
|
|
184
169
|
verifyPassphrase,
|
|
185
170
|
createSession,
|
|
186
171
|
authenticateSession,
|
|
187
|
-
revokeSession,
|
|
188
|
-
changePassphrase,
|
|
189
172
|
};
|
|
190
173
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export type DocNestConfig = {
|
|
|
29
29
|
enabled?: boolean;
|
|
30
30
|
passphrase?: string;
|
|
31
31
|
stateFile?: string;
|
|
32
|
+
localStorageKey?: string;
|
|
32
33
|
sessionTtlMinutes?: number;
|
|
33
34
|
};
|
|
34
35
|
export?: {
|
|
@@ -50,6 +51,7 @@ export type ResolvedDocNestConfig = Required<DocNestConfig> & {
|
|
|
50
51
|
enabled: boolean;
|
|
51
52
|
passphrase: string;
|
|
52
53
|
stateFile: string;
|
|
54
|
+
localStorageKey: string;
|
|
53
55
|
sessionTtlMinutes: number;
|
|
54
56
|
};
|
|
55
57
|
export: {
|
package/dist/config.js
CHANGED
|
@@ -89,6 +89,7 @@ async function loadConfig(projectRoot = process.cwd()) {
|
|
|
89
89
|
const auth = userConfig.auth ?? {};
|
|
90
90
|
const watermark = userConfig.export?.watermark ?? {};
|
|
91
91
|
const storageKeyPrefix = site.storageKeyPrefix?.trim() || `docnest:${projectName}`;
|
|
92
|
+
const authLocalStorageKey = auth.localStorageKey?.trim() || `${storageKeyPrefix}:auth-passphrase`;
|
|
92
93
|
return {
|
|
93
94
|
projectRoot,
|
|
94
95
|
docsDir: node_path_1.default.resolve(projectRoot, userConfig.docsDir?.trim() || 'docs'),
|
|
@@ -114,6 +115,7 @@ async function loadConfig(projectRoot = process.cwd()) {
|
|
|
114
115
|
enabled: auth.enabled ?? false,
|
|
115
116
|
passphrase: typeof auth.passphrase === 'string' ? auth.passphrase : '',
|
|
116
117
|
stateFile: node_path_1.default.resolve(projectRoot, auth.stateFile?.trim() || '.docnest/auth.json'),
|
|
118
|
+
localStorageKey: authLocalStorageKey,
|
|
117
119
|
sessionTtlMinutes: normalizePositiveNumber(auth.sessionTtlMinutes, 24 * 60),
|
|
118
120
|
},
|
|
119
121
|
export: {
|
package/dist/server.js
CHANGED
|
@@ -92,6 +92,7 @@ const AUTH_CONFIG = globalThis.__DOCNEST_AUTH_CONFIG__ || {
|
|
|
92
92
|
enabled: false,
|
|
93
93
|
passphrase: '',
|
|
94
94
|
stateFile: path.join(process.cwd(), '.docnest', 'auth.json'),
|
|
95
|
+
localStorageKey: `${DOCS_STORAGE_KEY_PREFIX}:auth-passphrase`,
|
|
95
96
|
sessionTtlMinutes: 24 * 60,
|
|
96
97
|
};
|
|
97
98
|
const AUTH_MANAGER = createAuthManager(AUTH_CONFIG);
|
|
@@ -110,6 +111,7 @@ app.locals.docsThemeEnabled = DOCS_THEME_ENABLED;
|
|
|
110
111
|
app.locals.docsWatermarkEnabled = DOCS_WATERMARK_ENABLED;
|
|
111
112
|
app.locals.docsWatermarkText = DOCS_WATERMARK_TEXT;
|
|
112
113
|
app.locals.docsAuthEnabled = BUILT_IN_AUTH_ENABLED;
|
|
114
|
+
app.locals.docsAuthStorageKey = AUTH_CONFIG.localStorageKey || `${DOCS_STORAGE_KEY_PREFIX}:auth-passphrase`;
|
|
113
115
|
function normalizeDocRelativePath(value) {
|
|
114
116
|
const raw = String(value || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
115
117
|
const normalized = path.posix.normalize(raw);
|
|
@@ -295,13 +297,18 @@ function renderLoginPage(res, nextPath, error, status = 200) {
|
|
|
295
297
|
app.get('/login', (req, res) => {
|
|
296
298
|
if (!BUILT_IN_AUTH_ENABLED)
|
|
297
299
|
return res.redirect('/');
|
|
298
|
-
|
|
299
|
-
|
|
300
|
+
return renderLoginPage(res, req.query.next, '');
|
|
301
|
+
});
|
|
302
|
+
app.post('/auth/verify', (req, res) => {
|
|
303
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
304
|
+
return res.json({ ok: true });
|
|
305
|
+
const token = AUTH_MANAGER.createSession(String(req.body?.passphrase || ''));
|
|
306
|
+
if (!token) {
|
|
307
|
+
clearAuthCookie(res);
|
|
308
|
+
return res.status(401).json({ ok: false, error: '口令不正确,请重试。' });
|
|
300
309
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
: '';
|
|
304
|
-
return renderLoginPage(res, req.query.next, message);
|
|
310
|
+
setAuthCookie(res, token);
|
|
311
|
+
return res.json({ ok: true });
|
|
305
312
|
});
|
|
306
313
|
app.post('/auth/login', (req, res) => {
|
|
307
314
|
if (!BUILT_IN_AUTH_ENABLED)
|
|
@@ -313,29 +320,6 @@ app.post('/auth/login', (req, res) => {
|
|
|
313
320
|
setAuthCookie(res, token);
|
|
314
321
|
return res.redirect(nextPath);
|
|
315
322
|
});
|
|
316
|
-
app.post('/auth/logout', (req, res) => {
|
|
317
|
-
AUTH_MANAGER.revokeSession(readAuthToken(req));
|
|
318
|
-
clearAuthCookie(res);
|
|
319
|
-
return res.redirect('/login');
|
|
320
|
-
});
|
|
321
|
-
app.post('/auth/change-password', (req, res) => {
|
|
322
|
-
if (!BUILT_IN_AUTH_ENABLED || !AUTH_MANAGER.authenticateSession(readAuthToken(req))) {
|
|
323
|
-
return res.status(401).json({ ok: false, error: '当前会话未授权。' });
|
|
324
|
-
}
|
|
325
|
-
try {
|
|
326
|
-
const changed = AUTH_MANAGER.changePassphrase(String(req.body?.currentPassphrase || ''), String(req.body?.nextPassphrase || ''));
|
|
327
|
-
if (!changed)
|
|
328
|
-
return res.status(401).json({ ok: false, error: '当前口令不正确。' });
|
|
329
|
-
clearAuthCookie(res);
|
|
330
|
-
return res.json({ ok: true });
|
|
331
|
-
}
|
|
332
|
-
catch (error) {
|
|
333
|
-
return res.status(400).json({
|
|
334
|
-
ok: false,
|
|
335
|
-
error: error instanceof Error ? error.message : '新口令无效。',
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
323
|
function documentAuthMiddleware(req, res, next) {
|
|
340
324
|
if (!BUILT_IN_AUTH_ENABLED)
|
|
341
325
|
return next();
|
|
@@ -34,9 +34,11 @@ export default defineConfig({
|
|
|
34
34
|
auth: {
|
|
35
35
|
// 极简单口令授权;关闭时文档中心保持本地开放。
|
|
36
36
|
enabled: false,
|
|
37
|
-
//
|
|
37
|
+
// 仅首次启动且授权状态文件不存在时使用;更换口令需手动删除状态文件后重启。
|
|
38
38
|
passphrase: '',
|
|
39
39
|
stateFile: '.docnest/auth.json',
|
|
40
|
+
// 前端 localStorage 的键;口令会永久保存在浏览器本地,直到手动清除或口令失效。
|
|
41
|
+
localStorageKey: 'my-project:auth-passphrase',
|
|
40
42
|
sessionTtlMinutes: 24 * 60,
|
|
41
43
|
},
|
|
42
44
|
export: {
|
package/package.json
CHANGED
|
@@ -5762,43 +5762,22 @@ body[data-theme="dark"] .dir-listing-count {
|
|
|
5762
5762
|
}
|
|
5763
5763
|
|
|
5764
5764
|
/* 极简单口令授权 */
|
|
5765
|
-
.auth-
|
|
5766
|
-
|
|
5767
|
-
align-items: center;
|
|
5768
|
-
gap: 0.4rem;
|
|
5769
|
-
}
|
|
5770
|
-
|
|
5771
|
-
.auth-logout-form {
|
|
5772
|
-
display: inline-flex;
|
|
5773
|
-
margin: 0;
|
|
5774
|
-
}
|
|
5775
|
-
|
|
5776
|
-
.auth-change-password-btn,
|
|
5777
|
-
.auth-logout-btn {
|
|
5778
|
-
white-space: nowrap;
|
|
5779
|
-
}
|
|
5780
|
-
|
|
5781
|
-
.auth-modal[hidden] {
|
|
5782
|
-
display: none;
|
|
5765
|
+
html.docnest-auth-pending body > * {
|
|
5766
|
+
visibility: hidden;
|
|
5783
5767
|
}
|
|
5784
5768
|
|
|
5785
|
-
.auth-
|
|
5769
|
+
html.docnest-auth-pending::before {
|
|
5770
|
+
content: '正在校验授权…';
|
|
5786
5771
|
position: fixed;
|
|
5787
5772
|
inset: 0;
|
|
5788
|
-
z-index:
|
|
5773
|
+
z-index: 2000;
|
|
5789
5774
|
display: grid;
|
|
5790
5775
|
place-items: center;
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
.auth-modal-backdrop {
|
|
5795
|
-
position: absolute;
|
|
5796
|
-
inset: 0;
|
|
5797
|
-
background: rgba(10, 16, 24, 0.42);
|
|
5798
|
-
backdrop-filter: blur(4px);
|
|
5776
|
+
color: var(--linear-text-muted, #63727f);
|
|
5777
|
+
background: var(--linear-bg, #ffffff);
|
|
5778
|
+
font-size: 0.9rem;
|
|
5799
5779
|
}
|
|
5800
5780
|
|
|
5801
|
-
.auth-modal-card,
|
|
5802
5781
|
.auth-login-card {
|
|
5803
5782
|
position: relative;
|
|
5804
5783
|
z-index: 1;
|
|
@@ -5809,25 +5788,11 @@ body[data-theme="dark"] .dir-listing-count {
|
|
|
5809
5788
|
box-shadow: 0 24px 70px rgba(17, 29, 40, 0.2);
|
|
5810
5789
|
}
|
|
5811
5790
|
|
|
5812
|
-
.auth-modal-card {
|
|
5813
|
-
width: min(100%, 440px);
|
|
5814
|
-
padding: 1.5rem;
|
|
5815
|
-
}
|
|
5816
|
-
|
|
5817
|
-
.auth-modal-header {
|
|
5818
|
-
display: flex;
|
|
5819
|
-
align-items: flex-start;
|
|
5820
|
-
justify-content: space-between;
|
|
5821
|
-
gap: 1rem;
|
|
5822
|
-
}
|
|
5823
|
-
|
|
5824
|
-
.auth-modal-header h2,
|
|
5825
5791
|
.auth-login-card h1 {
|
|
5826
5792
|
margin: 0;
|
|
5827
5793
|
color: var(--linear-text, #18212a);
|
|
5828
5794
|
}
|
|
5829
5795
|
|
|
5830
|
-
.auth-modal-eyebrow,
|
|
5831
5796
|
.auth-login-eyebrow {
|
|
5832
5797
|
margin: 0 0 0.35rem;
|
|
5833
5798
|
color: var(--linear-accent, #267c83);
|
|
@@ -5836,35 +5801,12 @@ body[data-theme="dark"] .dir-listing-count {
|
|
|
5836
5801
|
letter-spacing: 0.12em;
|
|
5837
5802
|
}
|
|
5838
5803
|
|
|
5839
|
-
.auth-modal-close {
|
|
5840
|
-
width: 2rem;
|
|
5841
|
-
height: 2rem;
|
|
5842
|
-
padding: 0;
|
|
5843
|
-
border: 0;
|
|
5844
|
-
border-radius: 999px;
|
|
5845
|
-
color: var(--linear-text-muted, #63727f);
|
|
5846
|
-
background: transparent;
|
|
5847
|
-
font-size: 1.5rem;
|
|
5848
|
-
line-height: 1;
|
|
5849
|
-
cursor: pointer;
|
|
5850
|
-
}
|
|
5851
|
-
|
|
5852
|
-
.auth-modal-close:hover {
|
|
5853
|
-
background: var(--linear-bg-subtle, #eef2f5);
|
|
5854
|
-
}
|
|
5855
|
-
|
|
5856
|
-
.auth-modal-help,
|
|
5857
5804
|
.auth-login-description,
|
|
5858
5805
|
.auth-login-note {
|
|
5859
5806
|
color: var(--linear-text-muted, #63727f);
|
|
5860
5807
|
line-height: 1.6;
|
|
5861
5808
|
}
|
|
5862
5809
|
|
|
5863
|
-
.auth-modal-help {
|
|
5864
|
-
margin: 1rem 0 1.25rem;
|
|
5865
|
-
font-size: 0.875rem;
|
|
5866
|
-
}
|
|
5867
|
-
|
|
5868
5810
|
.auth-form {
|
|
5869
5811
|
display: grid;
|
|
5870
5812
|
gap: 0.9rem;
|
|
@@ -5904,15 +5846,7 @@ body[data-theme="dark"] .dir-listing-count {
|
|
|
5904
5846
|
font-size: 0.875rem;
|
|
5905
5847
|
}
|
|
5906
5848
|
|
|
5907
|
-
.auth-
|
|
5908
|
-
display: flex;
|
|
5909
|
-
justify-content: flex-end;
|
|
5910
|
-
gap: 0.6rem;
|
|
5911
|
-
margin-top: 0.25rem;
|
|
5912
|
-
}
|
|
5913
|
-
|
|
5914
|
-
.auth-primary-btn,
|
|
5915
|
-
.auth-secondary-btn {
|
|
5849
|
+
.auth-primary-btn {
|
|
5916
5850
|
min-height: 2.5rem;
|
|
5917
5851
|
padding: 0.55rem 0.95rem;
|
|
5918
5852
|
border-radius: 8px;
|
|
@@ -5927,12 +5861,6 @@ body[data-theme="dark"] .dir-listing-count {
|
|
|
5927
5861
|
background: var(--linear-accent, #267c83);
|
|
5928
5862
|
}
|
|
5929
5863
|
|
|
5930
|
-
.auth-secondary-btn {
|
|
5931
|
-
border: 1px solid var(--linear-border, #d7dee5);
|
|
5932
|
-
color: var(--linear-text, #18212a);
|
|
5933
|
-
background: var(--linear-bg-subtle, #eef2f5);
|
|
5934
|
-
}
|
|
5935
|
-
|
|
5936
5864
|
.auth-page {
|
|
5937
5865
|
min-height: 100vh;
|
|
5938
5866
|
display: grid;
|
|
@@ -5994,16 +5922,3 @@ body[data-theme="dark"] .dir-listing-count {
|
|
|
5994
5922
|
font-size: 0.75rem;
|
|
5995
5923
|
text-align: center;
|
|
5996
5924
|
}
|
|
5997
|
-
|
|
5998
|
-
@media (max-width: 768px) {
|
|
5999
|
-
.auth-actions {
|
|
6000
|
-
gap: 0.25rem;
|
|
6001
|
-
}
|
|
6002
|
-
|
|
6003
|
-
.auth-change-password-btn,
|
|
6004
|
-
.auth-logout-btn {
|
|
6005
|
-
min-width: 44px;
|
|
6006
|
-
padding-inline: 0.5rem;
|
|
6007
|
-
font-size: 0.75rem;
|
|
6008
|
-
}
|
|
6009
|
-
}
|
|
@@ -1,61 +1,152 @@
|
|
|
1
1
|
(function () {
|
|
2
|
-
var
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
var
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
openButton.addEventListener('click', function () {
|
|
23
|
-
modal.hidden = false;
|
|
24
|
-
var firstInput = form.querySelector('input');
|
|
25
|
-
if (firstInput) firstInput.focus();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
closeButtons.forEach(function (button) {
|
|
29
|
-
button.addEventListener('click', closeModal);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
form.addEventListener('submit', async function (event) {
|
|
33
|
-
event.preventDefault();
|
|
34
|
-
setError('');
|
|
35
|
-
var formData = new FormData(form);
|
|
36
|
-
var nextPassphrase = String(formData.get('nextPassphrase') || '');
|
|
37
|
-
var confirmPassphrase = String(formData.get('confirmPassphrase') || '');
|
|
38
|
-
if (nextPassphrase !== confirmPassphrase) {
|
|
39
|
-
setError('两次输入的新口令不一致。');
|
|
40
|
-
return;
|
|
2
|
+
var config = window.DOCNEST_CONFIG || {};
|
|
3
|
+
if (config.authEnabled !== true) return;
|
|
4
|
+
|
|
5
|
+
var storageKey = config.authStorageKey || 'docnest:auth-passphrase';
|
|
6
|
+
var verificationPromise = null;
|
|
7
|
+
|
|
8
|
+
function readPassphrase() {
|
|
9
|
+
try {
|
|
10
|
+
return window.localStorage.getItem(storageKey) || '';
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writePassphrase(passphrase) {
|
|
17
|
+
try {
|
|
18
|
+
window.localStorage.setItem(storageKey, passphrase);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return false;
|
|
41
22
|
}
|
|
23
|
+
}
|
|
42
24
|
|
|
25
|
+
function clearPassphrase() {
|
|
43
26
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
27
|
+
window.localStorage.removeItem(storageKey);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
// The browser may block storage; the next request will simply ask again.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function currentPageUrl() {
|
|
34
|
+
return window.location.pathname + window.location.search + window.location.hash;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function safeNextPath(value) {
|
|
38
|
+
if (
|
|
39
|
+
typeof value !== 'string' ||
|
|
40
|
+
value.charAt(0) !== '/' ||
|
|
41
|
+
value.indexOf('//') === 0 ||
|
|
42
|
+
value.indexOf('\\') !== -1 ||
|
|
43
|
+
/[\r\n]/.test(value)
|
|
44
|
+
) return '/';
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redirectToLogin() {
|
|
49
|
+
var next = encodeURIComponent(currentPageUrl() || '/');
|
|
50
|
+
window.location.replace('/login?next=' + next);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function verifyWithBackend(passphrase) {
|
|
54
|
+
return fetch('/auth/verify', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
credentials: 'same-origin',
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
Accept: 'application/json',
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({ passphrase: passphrase }),
|
|
62
|
+
}).then(function (response) {
|
|
63
|
+
return response.json().catch(function () { return {}; }).then(function (result) {
|
|
64
|
+
return { ok: response.ok && result.ok === true, error: result.error || '', unavailable: false };
|
|
51
65
|
});
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
}).catch(function () {
|
|
67
|
+
return { ok: false, error: '无法连接授权服务,请稍后重试。', unavailable: true };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ensureAuthorized() {
|
|
72
|
+
if (verificationPromise) return verificationPromise;
|
|
73
|
+
var passphrase = readPassphrase();
|
|
74
|
+
if (!passphrase) return Promise.resolve(false);
|
|
75
|
+
|
|
76
|
+
verificationPromise = verifyWithBackend(passphrase).then(function (result) {
|
|
77
|
+
if (!result.ok) {
|
|
78
|
+
clearPassphrase();
|
|
79
|
+
return false;
|
|
55
80
|
}
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
81
|
+
return true;
|
|
82
|
+
}).finally(function () {
|
|
83
|
+
verificationPromise = null;
|
|
84
|
+
});
|
|
85
|
+
return verificationPromise;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
window.docNestEnsureAuthorized = ensureAuthorized;
|
|
89
|
+
window.docNestRedirectToLogin = redirectToLogin;
|
|
90
|
+
|
|
91
|
+
function removePendingState() {
|
|
92
|
+
document.documentElement.classList.remove('docnest-auth-pending');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function initLoginPage() {
|
|
96
|
+
var form = document.querySelector('.auth-login-form');
|
|
97
|
+
var error = document.querySelector('.auth-login-form .auth-form-error');
|
|
98
|
+
var passphraseInput = form ? form.querySelector('input[name="passphrase"]') : null;
|
|
99
|
+
var nextInput = form ? form.querySelector('input[name="next"]') : null;
|
|
100
|
+
if (!form || !passphraseInput) return;
|
|
101
|
+
|
|
102
|
+
function setError(message) {
|
|
103
|
+
if (!error) return;
|
|
104
|
+
error.textContent = message || '';
|
|
105
|
+
error.hidden = !message;
|
|
59
106
|
}
|
|
60
|
-
|
|
107
|
+
|
|
108
|
+
async function submitPassphrase(passphrase, auto) {
|
|
109
|
+
if (!passphrase) return false;
|
|
110
|
+
if (!auto) setError('');
|
|
111
|
+
var result = await verifyWithBackend(passphrase);
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
if (auto && !result.unavailable) clearPassphrase();
|
|
114
|
+
setError(auto && !result.unavailable ? '本地保存的口令已失效,请输入新口令。' : (result.error || '口令不正确,请重试。'));
|
|
115
|
+
if (!auto) passphraseInput.focus();
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (!writePassphrase(passphrase)) {
|
|
119
|
+
setError('浏览器拒绝保存口令,请检查本地存储权限。');
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
window.location.replace(safeNextPath(nextInput ? nextInput.value : '/'));
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
form.addEventListener('submit', function (event) {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
submitPassphrase(String(passphraseInput.value || ''), false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
var cachedPassphrase = readPassphrase();
|
|
132
|
+
if (cachedPassphrase) submitPassphrase(cachedPassphrase, true);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function initProtectedPage() {
|
|
136
|
+
ensureAuthorized().then(function (authorized) {
|
|
137
|
+
if (!authorized) {
|
|
138
|
+
redirectToLogin();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
removePendingState();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (document.body && document.body.classList.contains('auth-page')) {
|
|
146
|
+
initLoginPage();
|
|
147
|
+
} else if (document.readyState === 'loading') {
|
|
148
|
+
document.addEventListener('DOMContentLoaded', initProtectedPage);
|
|
149
|
+
} else {
|
|
150
|
+
initProtectedPage();
|
|
151
|
+
}
|
|
61
152
|
})();
|
|
@@ -155,7 +155,24 @@
|
|
|
155
155
|
article.style.opacity = '0.6';
|
|
156
156
|
article.style.pointerEvents = 'none';
|
|
157
157
|
|
|
158
|
-
|
|
158
|
+
var authCheck = typeof window.docNestEnsureAuthorized === 'function'
|
|
159
|
+
? window.docNestEnsureAuthorized()
|
|
160
|
+
: Promise.resolve(true);
|
|
161
|
+
|
|
162
|
+
authCheck.then(function(authorized) {
|
|
163
|
+
if (!authorized) {
|
|
164
|
+
if (typeof window.docNestRedirectToLogin === 'function') {
|
|
165
|
+
window.docNestRedirectToLogin();
|
|
166
|
+
}
|
|
167
|
+
var redirectError = new Error('AUTH_REDIRECT');
|
|
168
|
+
redirectError.redirected = true;
|
|
169
|
+
throw redirectError;
|
|
170
|
+
}
|
|
171
|
+
return fetch(absoluteUrl, {
|
|
172
|
+
credentials: 'same-origin',
|
|
173
|
+
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
|
174
|
+
});
|
|
175
|
+
})
|
|
159
176
|
.then(function(res) {
|
|
160
177
|
if (!res.ok) {
|
|
161
178
|
throw new Error('HTTP ' + res.status);
|
|
@@ -198,6 +215,7 @@
|
|
|
198
215
|
}
|
|
199
216
|
})
|
|
200
217
|
.catch(function(err) {
|
|
218
|
+
if (err && err.redirected) return;
|
|
201
219
|
showNavError('文档加载失败,请稍后重试');
|
|
202
220
|
console.warn('SPA 加载失败,保持当前页面:', err);
|
|
203
221
|
})
|
package/server/views/login.ejs
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
<link rel="icon" href="/favicon.ico?v=<%= assetVersion %>" sizes="any">
|
|
8
8
|
<link rel="stylesheet" href="/static/css/styles.css?v=<%= assetVersion %>">
|
|
9
9
|
<link rel="stylesheet" href="/static/css/themes.css?v=<%= assetVersion %>">
|
|
10
|
+
<script>
|
|
11
|
+
window.DOCNEST_CONFIG = {
|
|
12
|
+
authEnabled: true,
|
|
13
|
+
authStorageKey: <%- JSON.stringify(docsAuthStorageKey) %>
|
|
14
|
+
};
|
|
15
|
+
</script>
|
|
10
16
|
</head>
|
|
11
17
|
<body class="auth-page">
|
|
12
18
|
<main class="auth-login-card" aria-labelledby="auth-login-title">
|
|
@@ -30,12 +36,11 @@
|
|
|
30
36
|
授权口令
|
|
31
37
|
<input type="password" name="passphrase" autocomplete="current-password" autofocus required>
|
|
32
38
|
</label>
|
|
33
|
-
|
|
34
|
-
<p class="auth-form-error" role="alert"><%= error %></p>
|
|
35
|
-
<% } %>
|
|
39
|
+
<p class="auth-form-error" role="alert" id="auth-login-error" <%= error ? '' : 'hidden' %>><%= error %></p>
|
|
36
40
|
<button type="submit" class="auth-primary-btn">进入文档中心</button>
|
|
37
41
|
</form>
|
|
38
42
|
<p class="auth-login-note">没有账号体系,也没有角色区分;口令是唯一的入口。</p>
|
|
39
43
|
</main>
|
|
44
|
+
<script src="/static/js/core/auth.js?v=<%= assetVersion %>"></script>
|
|
40
45
|
</body>
|
|
41
46
|
</html>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
window.DOCNEST_CONFIG = {
|
|
3
|
+
authEnabled: <%= docsAuthEnabled ? 'true' : 'false' %>,
|
|
4
|
+
authStorageKey: <%- JSON.stringify(docsAuthStorageKey) %>,
|
|
3
5
|
watermarkEnabled: <%= docsWatermarkEnabled ? 'true' : 'false' %>,
|
|
4
6
|
watermarkText: <%- JSON.stringify(docsWatermarkText) %>,
|
|
5
7
|
storageKeyPrefix: <%- JSON.stringify(docsStorageKeyPrefix) %>,
|
|
@@ -9,4 +11,7 @@
|
|
|
9
11
|
enabledThemes: <%- JSON.stringify(docsThemeEnabled) %>
|
|
10
12
|
}
|
|
11
13
|
};
|
|
14
|
+
if (window.DOCNEST_CONFIG.authEnabled) {
|
|
15
|
+
document.documentElement.classList.add('docnest-auth-pending');
|
|
16
|
+
}
|
|
12
17
|
</script>
|
|
@@ -54,52 +54,10 @@
|
|
|
54
54
|
<span class="theme-icon" aria-hidden="true">A</span>
|
|
55
55
|
<span class="theme-text">跟随系统</span>
|
|
56
56
|
</button>
|
|
57
|
-
<% if (docsAuthEnabled) { %>
|
|
58
|
-
<div class="auth-actions" aria-label="文档授权">
|
|
59
|
-
<button id="auth-change-password-btn" class="header-action-btn auth-change-password-btn" type="button">
|
|
60
|
-
修改口令
|
|
61
|
-
</button>
|
|
62
|
-
<form action="/auth/logout" method="post" class="auth-logout-form">
|
|
63
|
-
<button class="header-action-btn auth-logout-btn" type="submit">退出</button>
|
|
64
|
-
</form>
|
|
65
|
-
</div>
|
|
66
|
-
<% } %>
|
|
67
57
|
</div>
|
|
68
58
|
</div>
|
|
69
59
|
</div>
|
|
70
60
|
</header>
|
|
71
61
|
<% if (docsAuthEnabled) { %>
|
|
72
|
-
<div class="auth-modal" id="auth-change-password-modal" hidden>
|
|
73
|
-
<div class="auth-modal-backdrop" data-auth-close></div>
|
|
74
|
-
<section class="auth-modal-card" role="dialog" aria-modal="true" aria-labelledby="auth-change-password-title">
|
|
75
|
-
<div class="auth-modal-header">
|
|
76
|
-
<div>
|
|
77
|
-
<p class="auth-modal-eyebrow">DOCUMENT ACCESS</p>
|
|
78
|
-
<h2 id="auth-change-password-title">修改授权口令</h2>
|
|
79
|
-
</div>
|
|
80
|
-
<button type="button" class="auth-modal-close" data-auth-close aria-label="关闭">×</button>
|
|
81
|
-
</div>
|
|
82
|
-
<p class="auth-modal-help">修改后所有已打开的文档会退出授权,需要使用新口令重新进入。</p>
|
|
83
|
-
<form id="auth-change-password-form" class="auth-form">
|
|
84
|
-
<label>
|
|
85
|
-
当前口令
|
|
86
|
-
<input type="password" name="currentPassphrase" autocomplete="current-password" required>
|
|
87
|
-
</label>
|
|
88
|
-
<label>
|
|
89
|
-
新口令
|
|
90
|
-
<input type="password" name="nextPassphrase" autocomplete="new-password" required>
|
|
91
|
-
</label>
|
|
92
|
-
<label>
|
|
93
|
-
确认新口令
|
|
94
|
-
<input type="password" name="confirmPassphrase" autocomplete="new-password" required>
|
|
95
|
-
</label>
|
|
96
|
-
<p class="auth-form-error" id="auth-change-password-error" role="alert" hidden></p>
|
|
97
|
-
<div class="auth-modal-actions">
|
|
98
|
-
<button type="button" class="auth-secondary-btn" data-auth-close>取消</button>
|
|
99
|
-
<button type="submit" class="auth-primary-btn">保存新口令</button>
|
|
100
|
-
</div>
|
|
101
|
-
</form>
|
|
102
|
-
</section>
|
|
103
|
-
</div>
|
|
104
62
|
<script src="/static/js/core/auth.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : 'dev' %>"></script>
|
|
105
63
|
<% } %>
|