docnest 0.1.8 → 0.1.10
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 +26 -2
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +190 -0
- package/dist/cli.js +2 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +13 -0
- package/dist/server.js +137 -1
- package/docnest.config.example.mjs +50 -0
- package/package.json +3 -2
- package/server/static/css/styles.css +263 -0
- package/server/static/js/core/auth.js +226 -0
- package/server/static/js/core/doc-client-nav.js +19 -1
- package/server/views/login.ejs +46 -0
- package/server/views/partials/appearance-head.ejs +5 -0
- package/server/views/partials/header.ejs +45 -0
package/README.md
CHANGED
|
@@ -58,6 +58,30 @@ export default defineConfig({
|
|
|
58
58
|
|
|
59
59
|
水印默认关闭。开启后,PDF 导出弹窗会读取配置文字,并允许本次导出临时修改;临时修改不会写回文件或浏览器存储。
|
|
60
60
|
|
|
61
|
+
### 极简单口令授权
|
|
62
|
+
|
|
63
|
+
需要让文档中心只对知道口令的人开放时,可以启用单口令授权:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
export default defineConfig({
|
|
67
|
+
auth: {
|
|
68
|
+
enabled: true,
|
|
69
|
+
passphrase: '请替换为项目自己的长口令',
|
|
70
|
+
stateFile: '.docnest/auth.json',
|
|
71
|
+
localStorageKey: 'my-project:auth-passphrase',
|
|
72
|
+
sessionTtlMinutes: 24 * 60,
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
授权只有一道关口,没有账号、密码组合或角色体系。首次启动时,`passphrase` 会初始化到由
|
|
78
|
+
`stateFile` 指定的本地授权状态文件;状态文件只保存带随机盐的口令哈希,不保存明文口令。
|
|
79
|
+
按极简模式约定,前端会把可回填的口令永久保存到浏览器 `localStorage`(不写入 URL);每次打开或刷新文档页面,前端都会把当前缓存的口令交给后台重新校验。校验失败会回到口令页,校验成功才会继续打开当前页面;输入页会保留跳转前的目标地址,错误口令会停留在输入页。由于 `localStorage` 是浏览器本地明文存储,请只在受信任的浏览器环境使用此模式。
|
|
80
|
+
`localStorageKey` 可选,用于为不同宿主项目隔离浏览器存储键。右上角的“修改口令”可以动态更新口令,修改后会清除本地缓存并使所有现有会话立即失效,必须使用新口令重新进入。
|
|
81
|
+
建议把 `.docnest/` 加入宿主项目的 `.gitignore`,并为口令状态文件设置仅当前用户可读写的权限。
|
|
82
|
+
|
|
83
|
+
授权开启但既没有状态文件,也没有初始 `passphrase` 时,DocNest 会拒绝启动,避免意外以空口令开放文档。
|
|
84
|
+
|
|
61
85
|
如需显式指定端口、文档目录或监听地址:
|
|
62
86
|
|
|
63
87
|
```bash
|
|
@@ -70,7 +94,7 @@ npx docnest serve --port 3000 --docs-dir docs --host 127.0.0.1
|
|
|
70
94
|
|
|
71
95
|
## 嵌入现有 Node 服务
|
|
72
96
|
|
|
73
|
-
需要接入项目自己的鉴权或文档来源时,在加载 `docnest/server` 前注入中间件和文档存储适配器,然后使用导出的 Express/Socket.IO
|
|
97
|
+
需要接入项目自己的鉴权或文档来源时,在加载 `docnest/server` 前注入中间件和文档存储适配器,然后使用导出的 Express/Socket.IO 实例。项目若不使用上面的内置单口令授权,仍可在这里接入自己的访问控制:
|
|
74
98
|
|
|
75
99
|
```js
|
|
76
100
|
globalThis.__DOCNEST_BEFORE_DOCUMENT_ROUTES__ = [projectAuthMiddleware]
|
|
@@ -82,7 +106,7 @@ io.use(projectSocketAuth)
|
|
|
82
106
|
startServer(3000)
|
|
83
107
|
```
|
|
84
108
|
|
|
85
|
-
存储适配器实现 `name`、`readDir`、`fileExists`、`readFile`、`readAsset`,可选实现 `ready`;这样文档内容仍由宿主项目提供,DocNest
|
|
109
|
+
存储适配器实现 `name`、`readDir`、`fileExists`、`readFile`、`readAsset`,可选实现 `ready`;这样文档内容仍由宿主项目提供,DocNest 负责展示、交互和可选的单口令授权。
|
|
86
110
|
|
|
87
111
|
## 静态构建中的文档附件
|
|
88
112
|
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type DocNestAuthConfig = {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
passphrase: string;
|
|
4
|
+
stateFile: string;
|
|
5
|
+
sessionTtlMinutes: number;
|
|
6
|
+
};
|
|
7
|
+
export type DocNestAuthManager = {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
stateFile: string;
|
|
10
|
+
sessionCookieName: string;
|
|
11
|
+
sessionMaxAgeSeconds: number;
|
|
12
|
+
verifyPassphrase: (passphrase: string) => boolean;
|
|
13
|
+
createSession: (passphrase: string) => string | null;
|
|
14
|
+
authenticateSession: (token: string | undefined) => boolean;
|
|
15
|
+
revokeSession: (token: string | undefined) => void;
|
|
16
|
+
changePassphrase: (currentPassphrase: string, nextPassphrase: string) => boolean;
|
|
17
|
+
};
|
|
18
|
+
export declare function createAuthManager(config: DocNestAuthConfig): DocNestAuthManager;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createAuthManager = createAuthManager;
|
|
7
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const AUTH_STATE_VERSION = 1;
|
|
11
|
+
const HASH_LENGTH = 32;
|
|
12
|
+
const SALT_LENGTH = 16;
|
|
13
|
+
const MAX_PASSPHRASE_LENGTH = 1024;
|
|
14
|
+
const DEFAULT_SESSION_TTL_SECONDS = 24 * 60 * 60;
|
|
15
|
+
const SCRYPT_OPTIONS = { N: 16_384, r: 8, p: 1 };
|
|
16
|
+
function assertPassphrase(passphrase, label) {
|
|
17
|
+
if (typeof passphrase !== 'string' || passphrase.length === 0) {
|
|
18
|
+
throw new Error(`${label}不能为空`);
|
|
19
|
+
}
|
|
20
|
+
if (passphrase.length > MAX_PASSPHRASE_LENGTH) {
|
|
21
|
+
throw new Error(`${label}长度不能超过 ${MAX_PASSPHRASE_LENGTH} 个字符`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function hashPassphrase(passphrase, salt) {
|
|
25
|
+
return node_crypto_1.default.scryptSync(passphrase, salt, HASH_LENGTH, SCRYPT_OPTIONS);
|
|
26
|
+
}
|
|
27
|
+
function createState(passphrase) {
|
|
28
|
+
assertPassphrase(passphrase, '授权口令');
|
|
29
|
+
const salt = node_crypto_1.default.randomBytes(SALT_LENGTH);
|
|
30
|
+
return {
|
|
31
|
+
version: AUTH_STATE_VERSION,
|
|
32
|
+
salt: salt.toString('base64url'),
|
|
33
|
+
hash: hashPassphrase(passphrase, salt).toString('base64url'),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function parseState(raw, stateFile) {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(raw);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`授权状态文件无法解析:${stateFile}`);
|
|
43
|
+
}
|
|
44
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
45
|
+
throw new Error(`授权状态文件格式无效:${stateFile}`);
|
|
46
|
+
}
|
|
47
|
+
const state = parsed;
|
|
48
|
+
if (state.version !== AUTH_STATE_VERSION ||
|
|
49
|
+
typeof state.salt !== 'string' ||
|
|
50
|
+
typeof state.hash !== 'string') {
|
|
51
|
+
throw new Error(`授权状态文件格式无效:${stateFile}`);
|
|
52
|
+
}
|
|
53
|
+
let salt;
|
|
54
|
+
let hash;
|
|
55
|
+
try {
|
|
56
|
+
salt = Buffer.from(state.salt, 'base64url');
|
|
57
|
+
hash = Buffer.from(state.hash, 'base64url');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new Error(`授权状态文件格式无效:${stateFile}`);
|
|
61
|
+
}
|
|
62
|
+
if (salt.length !== SALT_LENGTH || hash.length !== HASH_LENGTH) {
|
|
63
|
+
throw new Error(`授权状态文件格式无效:${stateFile}`);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
version: AUTH_STATE_VERSION,
|
|
67
|
+
salt: salt.toString('base64url'),
|
|
68
|
+
hash: hash.toString('base64url'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function readOrCreateState(config) {
|
|
72
|
+
try {
|
|
73
|
+
return parseState(node_fs_1.default.readFileSync(config.stateFile, 'utf8'), config.stateFile);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'ENOENT') {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!config.passphrase) {
|
|
81
|
+
throw new Error(`授权已启用,但未配置初始口令,也找不到授权状态文件:${config.stateFile}`);
|
|
82
|
+
}
|
|
83
|
+
const state = createState(config.passphrase);
|
|
84
|
+
writeState(config.stateFile, state);
|
|
85
|
+
return state;
|
|
86
|
+
}
|
|
87
|
+
function writeState(stateFile, state) {
|
|
88
|
+
const directory = node_path_1.default.dirname(stateFile);
|
|
89
|
+
node_fs_1.default.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
90
|
+
const temporaryFile = `${stateFile}.${process.pid}.${node_crypto_1.default.randomBytes(6).toString('hex')}.tmp`;
|
|
91
|
+
try {
|
|
92
|
+
node_fs_1.default.writeFileSync(temporaryFile, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
93
|
+
node_fs_1.default.chmodSync(temporaryFile, 0o600);
|
|
94
|
+
node_fs_1.default.renameSync(temporaryFile, stateFile);
|
|
95
|
+
node_fs_1.default.chmodSync(stateFile, 0o600);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
try {
|
|
99
|
+
node_fs_1.default.unlinkSync(temporaryFile);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// The temporary file was renamed successfully, or cleanup is unnecessary.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function matchesHash(passphrase, state) {
|
|
107
|
+
if (typeof passphrase !== 'string' || passphrase.length > MAX_PASSPHRASE_LENGTH)
|
|
108
|
+
return false;
|
|
109
|
+
const salt = Buffer.from(state.salt, 'base64url');
|
|
110
|
+
const expected = Buffer.from(state.hash, 'base64url');
|
|
111
|
+
const actual = hashPassphrase(passphrase, salt);
|
|
112
|
+
return expected.length === actual.length && node_crypto_1.default.timingSafeEqual(expected, actual);
|
|
113
|
+
}
|
|
114
|
+
function normalizeSessionTtl(minutes) {
|
|
115
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
116
|
+
return DEFAULT_SESSION_TTL_SECONDS;
|
|
117
|
+
return Math.max(5 * 60, Math.min(Math.floor(minutes * 60), 30 * 24 * 60 * 60));
|
|
118
|
+
}
|
|
119
|
+
function createAuthManager(config) {
|
|
120
|
+
const enabled = config.enabled === true;
|
|
121
|
+
const sessionMaxAgeSeconds = normalizeSessionTtl(config.sessionTtlMinutes);
|
|
122
|
+
const sessionCookieName = 'docnest_session';
|
|
123
|
+
const sessions = new Map();
|
|
124
|
+
if (!enabled) {
|
|
125
|
+
return {
|
|
126
|
+
enabled: false,
|
|
127
|
+
stateFile: config.stateFile,
|
|
128
|
+
sessionCookieName,
|
|
129
|
+
sessionMaxAgeSeconds,
|
|
130
|
+
verifyPassphrase: () => true,
|
|
131
|
+
createSession: () => null,
|
|
132
|
+
authenticateSession: () => true,
|
|
133
|
+
revokeSession: () => undefined,
|
|
134
|
+
changePassphrase: () => false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
let state = readOrCreateState(config);
|
|
138
|
+
function verifyPassphrase(passphrase) {
|
|
139
|
+
return matchesHash(passphrase, state);
|
|
140
|
+
}
|
|
141
|
+
function purgeExpiredSessions() {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
for (const [token, session] of sessions) {
|
|
144
|
+
if (session.expiresAt <= now)
|
|
145
|
+
sessions.delete(token);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function createSession(passphrase) {
|
|
149
|
+
if (!verifyPassphrase(passphrase))
|
|
150
|
+
return null;
|
|
151
|
+
purgeExpiredSessions();
|
|
152
|
+
const token = node_crypto_1.default.randomBytes(32).toString('base64url');
|
|
153
|
+
sessions.set(token, { expiresAt: Date.now() + sessionMaxAgeSeconds * 1000 });
|
|
154
|
+
return token;
|
|
155
|
+
}
|
|
156
|
+
function authenticateSession(token) {
|
|
157
|
+
if (!token)
|
|
158
|
+
return false;
|
|
159
|
+
purgeExpiredSessions();
|
|
160
|
+
const session = sessions.get(token);
|
|
161
|
+
if (!session)
|
|
162
|
+
return false;
|
|
163
|
+
session.expiresAt = Date.now() + sessionMaxAgeSeconds * 1000;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
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
|
+
return {
|
|
180
|
+
enabled,
|
|
181
|
+
stateFile: config.stateFile,
|
|
182
|
+
sessionCookieName,
|
|
183
|
+
sessionMaxAgeSeconds,
|
|
184
|
+
verifyPassphrase,
|
|
185
|
+
createSession,
|
|
186
|
+
authenticateSession,
|
|
187
|
+
revokeSession,
|
|
188
|
+
changePassphrase,
|
|
189
|
+
};
|
|
190
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -68,6 +68,8 @@ function exposeConfig(config) {
|
|
|
68
68
|
process.env.DOCS_PORT_START = String(config.server.port);
|
|
69
69
|
process.env.DOCS_WATCH_ENABLED = String(config.server.watch);
|
|
70
70
|
process.env.DOCS_OPEN_BROWSER = String(config.server.openBrowser);
|
|
71
|
+
const runtime = globalThis;
|
|
72
|
+
runtime.__DOCNEST_AUTH_CONFIG__ = config.auth;
|
|
71
73
|
}
|
|
72
74
|
const dynamicImport = new Function('specifier', 'return import(specifier)');
|
|
73
75
|
async function main() {
|
package/dist/config.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ export type DocNestConfig = {
|
|
|
25
25
|
watch?: boolean;
|
|
26
26
|
openBrowser?: boolean;
|
|
27
27
|
};
|
|
28
|
+
auth?: {
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
passphrase?: string;
|
|
31
|
+
stateFile?: string;
|
|
32
|
+
localStorageKey?: string;
|
|
33
|
+
sessionTtlMinutes?: number;
|
|
34
|
+
};
|
|
28
35
|
export?: {
|
|
29
36
|
watermark?: {
|
|
30
37
|
enabled?: boolean;
|
|
@@ -40,6 +47,13 @@ export type ResolvedDocNestConfig = Required<DocNestConfig> & {
|
|
|
40
47
|
navigation: Required<NonNullable<DocNestConfig['navigation']>>;
|
|
41
48
|
appearance: Required<NonNullable<DocNestConfig['appearance']>>;
|
|
42
49
|
server: Required<NonNullable<DocNestConfig['server']>>;
|
|
50
|
+
auth: {
|
|
51
|
+
enabled: boolean;
|
|
52
|
+
passphrase: string;
|
|
53
|
+
stateFile: string;
|
|
54
|
+
localStorageKey: string;
|
|
55
|
+
sessionTtlMinutes: number;
|
|
56
|
+
};
|
|
43
57
|
export: {
|
|
44
58
|
watermark: Required<NonNullable<NonNullable<DocNestConfig['export']>['watermark']>>;
|
|
45
59
|
};
|
package/dist/config.js
CHANGED
|
@@ -41,6 +41,10 @@ function normalizePort(value, fallback) {
|
|
|
41
41
|
return fallback;
|
|
42
42
|
return port;
|
|
43
43
|
}
|
|
44
|
+
function normalizePositiveNumber(value, fallback) {
|
|
45
|
+
const normalized = Number(value);
|
|
46
|
+
return Number.isFinite(normalized) && normalized > 0 ? normalized : fallback;
|
|
47
|
+
}
|
|
44
48
|
function normalizeThemeName(value) {
|
|
45
49
|
if (value === 'current-docs')
|
|
46
50
|
return 'slate-modern';
|
|
@@ -82,8 +86,10 @@ async function loadConfig(projectRoot = process.cwd()) {
|
|
|
82
86
|
const navigation = userConfig.navigation ?? {};
|
|
83
87
|
const appearance = userConfig.appearance ?? {};
|
|
84
88
|
const server = userConfig.server ?? {};
|
|
89
|
+
const auth = userConfig.auth ?? {};
|
|
85
90
|
const watermark = userConfig.export?.watermark ?? {};
|
|
86
91
|
const storageKeyPrefix = site.storageKeyPrefix?.trim() || `docnest:${projectName}`;
|
|
92
|
+
const authLocalStorageKey = auth.localStorageKey?.trim() || `${storageKeyPrefix}:auth-passphrase`;
|
|
87
93
|
return {
|
|
88
94
|
projectRoot,
|
|
89
95
|
docsDir: node_path_1.default.resolve(projectRoot, userConfig.docsDir?.trim() || 'docs'),
|
|
@@ -105,6 +111,13 @@ async function loadConfig(projectRoot = process.cwd()) {
|
|
|
105
111
|
watch: server.watch ?? true,
|
|
106
112
|
openBrowser: server.openBrowser ?? true,
|
|
107
113
|
},
|
|
114
|
+
auth: {
|
|
115
|
+
enabled: auth.enabled ?? false,
|
|
116
|
+
passphrase: typeof auth.passphrase === 'string' ? auth.passphrase : '',
|
|
117
|
+
stateFile: node_path_1.default.resolve(projectRoot, auth.stateFile?.trim() || '.docnest/auth.json'),
|
|
118
|
+
localStorageKey: authLocalStorageKey,
|
|
119
|
+
sessionTtlMinutes: normalizePositiveNumber(auth.sessionTtlMinutes, 24 * 60),
|
|
120
|
+
},
|
|
108
121
|
export: {
|
|
109
122
|
watermark: {
|
|
110
123
|
enabled: watermark.enabled ?? false,
|
package/dist/server.js
CHANGED
|
@@ -14,6 +14,7 @@ const { Server } = require('socket.io');
|
|
|
14
14
|
const chokidar = require('chokidar');
|
|
15
15
|
const MarkdownIt = require('markdown-it');
|
|
16
16
|
const hljs = require('highlight.js');
|
|
17
|
+
const { createAuthManager } = require('./auth');
|
|
17
18
|
const app = express();
|
|
18
19
|
exports.app = app;
|
|
19
20
|
const server = createServer(app);
|
|
@@ -87,7 +88,16 @@ const JSPDF_DIR = dependencyDir('jspdf');
|
|
|
87
88
|
const injectedDocumentStore = globalThis.__DOCNEST_DOCUMENT_STORE__ || null;
|
|
88
89
|
const STORAGE_DRIVER = injectedDocumentStore?.name || 'local';
|
|
89
90
|
const DOCS_ROOT_LABEL = injectedDocumentStore?.rootLabel || DOCS_DIR;
|
|
90
|
-
const
|
|
91
|
+
const AUTH_CONFIG = globalThis.__DOCNEST_AUTH_CONFIG__ || {
|
|
92
|
+
enabled: false,
|
|
93
|
+
passphrase: '',
|
|
94
|
+
stateFile: path.join(process.cwd(), '.docnest', 'auth.json'),
|
|
95
|
+
localStorageKey: `${DOCS_STORAGE_KEY_PREFIX}:auth-passphrase`,
|
|
96
|
+
sessionTtlMinutes: 24 * 60,
|
|
97
|
+
};
|
|
98
|
+
const AUTH_MANAGER = createAuthManager(AUTH_CONFIG);
|
|
99
|
+
const BUILT_IN_AUTH_ENABLED = AUTH_MANAGER.enabled;
|
|
100
|
+
const AUTH_ENABLED = BUILT_IN_AUTH_ENABLED || globalThis.__DOCNEST_AUTH_ENABLED__ === true;
|
|
91
101
|
// 配置 EJS 模板引擎
|
|
92
102
|
app.set('view engine', 'ejs');
|
|
93
103
|
app.set('views', path.join(SERVER_DIR, 'views'));
|
|
@@ -100,6 +110,8 @@ app.locals.docsThemeModeDefault = DOCS_THEME_MODE_DEFAULT;
|
|
|
100
110
|
app.locals.docsThemeEnabled = DOCS_THEME_ENABLED;
|
|
101
111
|
app.locals.docsWatermarkEnabled = DOCS_WATERMARK_ENABLED;
|
|
102
112
|
app.locals.docsWatermarkText = DOCS_WATERMARK_TEXT;
|
|
113
|
+
app.locals.docsAuthEnabled = BUILT_IN_AUTH_ENABLED;
|
|
114
|
+
app.locals.docsAuthStorageKey = AUTH_CONFIG.localStorageKey || `${DOCS_STORAGE_KEY_PREFIX}:auth-passphrase`;
|
|
103
115
|
function normalizeDocRelativePath(value) {
|
|
104
116
|
const raw = String(value || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
105
117
|
const normalized = path.posix.normalize(raw);
|
|
@@ -232,6 +244,130 @@ app.use('/vendor/html2canvas', express.static(HTML2CANVAS_DIR));
|
|
|
232
244
|
app.use('/vendor/dompurify', express.static(DOMPURIFY_DIR));
|
|
233
245
|
// jsPDF(本地,用于 Mermaid 图导出 PDF)
|
|
234
246
|
app.use('/vendor/jspdf', express.static(JSPDF_DIR));
|
|
247
|
+
app.use(express.json({ limit: '16kb' }));
|
|
248
|
+
app.use(express.urlencoded({ extended: false, limit: '16kb' }));
|
|
249
|
+
function readCookie(cookieHeader, name) {
|
|
250
|
+
if (!cookieHeader)
|
|
251
|
+
return undefined;
|
|
252
|
+
for (const item of String(cookieHeader).split(';')) {
|
|
253
|
+
const separator = item.indexOf('=');
|
|
254
|
+
if (separator === -1)
|
|
255
|
+
continue;
|
|
256
|
+
const key = item.slice(0, separator).trim();
|
|
257
|
+
if (key !== name)
|
|
258
|
+
continue;
|
|
259
|
+
try {
|
|
260
|
+
return decodeURIComponent(item.slice(separator + 1).trim());
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
function readAuthToken(req) {
|
|
269
|
+
return readCookie(req.headers.cookie, AUTH_MANAGER.sessionCookieName);
|
|
270
|
+
}
|
|
271
|
+
function safeNextPath(value) {
|
|
272
|
+
if (typeof value !== 'string' ||
|
|
273
|
+
!value.startsWith('/') ||
|
|
274
|
+
value.startsWith('//') ||
|
|
275
|
+
value.includes('\\') ||
|
|
276
|
+
/[\r\n]/.test(value))
|
|
277
|
+
return '/';
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
function setAuthCookie(res, token) {
|
|
281
|
+
const maxAge = AUTH_MANAGER.sessionMaxAgeSeconds;
|
|
282
|
+
res.setHeader('Set-Cookie', `${AUTH_MANAGER.sessionCookieName}=${encodeURIComponent(token)}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Lax`);
|
|
283
|
+
}
|
|
284
|
+
function clearAuthCookie(res) {
|
|
285
|
+
res.setHeader('Set-Cookie', `${AUTH_MANAGER.sessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`);
|
|
286
|
+
}
|
|
287
|
+
function wantsJson(req) {
|
|
288
|
+
return req.path.startsWith('/auth/') || req.get('accept')?.includes('application/json');
|
|
289
|
+
}
|
|
290
|
+
function renderLoginPage(res, nextPath, error, status = 200) {
|
|
291
|
+
return res.status(status).render('login', {
|
|
292
|
+
nextPath: safeNextPath(nextPath),
|
|
293
|
+
error: error || '',
|
|
294
|
+
assetVersion: ASSET_VERSION,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
app.get('/login', (req, res) => {
|
|
298
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
299
|
+
return res.redirect('/');
|
|
300
|
+
const message = req.query.changed
|
|
301
|
+
? '口令已更新,请使用新口令重新进入。'
|
|
302
|
+
: '';
|
|
303
|
+
return renderLoginPage(res, req.query.next, message);
|
|
304
|
+
});
|
|
305
|
+
app.post('/auth/verify', (req, res) => {
|
|
306
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
307
|
+
return res.json({ ok: true });
|
|
308
|
+
const token = AUTH_MANAGER.createSession(String(req.body?.passphrase || ''));
|
|
309
|
+
if (!token) {
|
|
310
|
+
clearAuthCookie(res);
|
|
311
|
+
return res.status(401).json({ ok: false, error: '口令不正确,请重试。' });
|
|
312
|
+
}
|
|
313
|
+
setAuthCookie(res, token);
|
|
314
|
+
return res.json({ ok: true });
|
|
315
|
+
});
|
|
316
|
+
app.post('/auth/login', (req, res) => {
|
|
317
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
318
|
+
return res.redirect('/');
|
|
319
|
+
const nextPath = safeNextPath(req.body?.next);
|
|
320
|
+
const token = AUTH_MANAGER.createSession(String(req.body?.passphrase || ''));
|
|
321
|
+
if (!token)
|
|
322
|
+
return renderLoginPage(res, nextPath, '口令不正确,请重试。', 401);
|
|
323
|
+
setAuthCookie(res, token);
|
|
324
|
+
return res.redirect(nextPath);
|
|
325
|
+
});
|
|
326
|
+
app.post('/auth/logout', (req, res) => {
|
|
327
|
+
AUTH_MANAGER.revokeSession(readAuthToken(req));
|
|
328
|
+
clearAuthCookie(res);
|
|
329
|
+
return res.redirect('/login');
|
|
330
|
+
});
|
|
331
|
+
app.post('/auth/change-password', (req, res) => {
|
|
332
|
+
if (!BUILT_IN_AUTH_ENABLED || !AUTH_MANAGER.authenticateSession(readAuthToken(req))) {
|
|
333
|
+
return res.status(401).json({ ok: false, error: '当前会话未授权。' });
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const changed = AUTH_MANAGER.changePassphrase(String(req.body?.currentPassphrase || ''), String(req.body?.nextPassphrase || ''));
|
|
337
|
+
if (!changed)
|
|
338
|
+
return res.status(401).json({ ok: false, error: '当前口令不正确。' });
|
|
339
|
+
clearAuthCookie(res);
|
|
340
|
+
return res.json({ ok: true });
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
return res.status(400).json({
|
|
344
|
+
ok: false,
|
|
345
|
+
error: error instanceof Error ? error.message : '新口令无效。',
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
function documentAuthMiddleware(req, res, next) {
|
|
350
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
351
|
+
return next();
|
|
352
|
+
if (req.path === '/health' || req.path === '/ready')
|
|
353
|
+
return next();
|
|
354
|
+
if (AUTH_MANAGER.authenticateSession(readAuthToken(req))) {
|
|
355
|
+
res.locals.authenticated = true;
|
|
356
|
+
return next();
|
|
357
|
+
}
|
|
358
|
+
if (wantsJson(req))
|
|
359
|
+
return res.status(401).json({ ok: false, error: '需要授权口令。' });
|
|
360
|
+
return res.redirect(`/login?next=${encodeURIComponent(req.originalUrl || '/')}`);
|
|
361
|
+
}
|
|
362
|
+
app.use(documentAuthMiddleware);
|
|
363
|
+
io.use((socket, next) => {
|
|
364
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
365
|
+
return next();
|
|
366
|
+
const token = readCookie(socket.handshake.headers.cookie, AUTH_MANAGER.sessionCookieName);
|
|
367
|
+
if (AUTH_MANAGER.authenticateSession(token))
|
|
368
|
+
return next();
|
|
369
|
+
return next(new Error('需要授权口令。'));
|
|
370
|
+
});
|
|
235
371
|
// 文档中的本地图片、附件等静态资源,只允许从文档根目录读取。
|
|
236
372
|
app.get('/doc-asset/*', async (req, res) => {
|
|
237
373
|
let requestedPath = req.path.slice('/doc-asset/'.length);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defineConfig } from 'docnest/config'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
// 默认读取项目根目录 docs/
|
|
5
|
+
docsDir: 'docs',
|
|
6
|
+
site: {
|
|
7
|
+
title: '我的项目文档',
|
|
8
|
+
subtitle: '本地 Markdown 文档服务',
|
|
9
|
+
logo: '',
|
|
10
|
+
storageKeyPrefix: 'docnest:my-project',
|
|
11
|
+
},
|
|
12
|
+
navigation: {
|
|
13
|
+
rootDirectoryOrder: [],
|
|
14
|
+
},
|
|
15
|
+
appearance: {
|
|
16
|
+
// slate-modern | editorial-atlas | precision-index | archive-room | swiss-manual
|
|
17
|
+
defaultTheme: 'slate-modern',
|
|
18
|
+
// auto | light | dark
|
|
19
|
+
defaultMode: 'auto',
|
|
20
|
+
enabledThemes: [
|
|
21
|
+
'slate-modern',
|
|
22
|
+
'editorial-atlas',
|
|
23
|
+
'precision-index',
|
|
24
|
+
'archive-room',
|
|
25
|
+
'swiss-manual',
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
server: {
|
|
29
|
+
host: '127.0.0.1',
|
|
30
|
+
port: 3000,
|
|
31
|
+
watch: true,
|
|
32
|
+
openBrowser: true,
|
|
33
|
+
},
|
|
34
|
+
auth: {
|
|
35
|
+
// 极简单口令授权;关闭时文档中心保持本地开放。
|
|
36
|
+
enabled: false,
|
|
37
|
+
// 仅首次启动且授权状态文件不存在时使用,之后可在页面内修改。
|
|
38
|
+
passphrase: '',
|
|
39
|
+
stateFile: '.docnest/auth.json',
|
|
40
|
+
// 前端 localStorage 的键;口令会永久保存在浏览器本地,直到用户退出或修改口令。
|
|
41
|
+
localStorageKey: 'my-project:auth-passphrase',
|
|
42
|
+
sessionTtlMinutes: 24 * 60,
|
|
43
|
+
},
|
|
44
|
+
export: {
|
|
45
|
+
watermark: {
|
|
46
|
+
enabled: false,
|
|
47
|
+
text: '我的项目文档',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docnest",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "可复用的本地 Markdown 文档中心",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"server/static/img/404-deep-space-light.webp",
|
|
29
29
|
"server/static/img/404-deep-space.webp",
|
|
30
30
|
"server/static/js",
|
|
31
|
-
"server/views"
|
|
31
|
+
"server/views",
|
|
32
|
+
"docnest.config.example.mjs"
|
|
32
33
|
],
|
|
33
34
|
"engines": {
|
|
34
35
|
"node": ">=20"
|
|
@@ -5760,3 +5760,266 @@ body[data-theme="dark"] .dir-listing-count {
|
|
|
5760
5760
|
font-size: 1.1rem;
|
|
5761
5761
|
}
|
|
5762
5762
|
}
|
|
5763
|
+
|
|
5764
|
+
/* 极简单口令授权 */
|
|
5765
|
+
html.docnest-auth-pending body > * {
|
|
5766
|
+
visibility: hidden;
|
|
5767
|
+
}
|
|
5768
|
+
|
|
5769
|
+
html.docnest-auth-pending::before {
|
|
5770
|
+
content: '正在校验授权…';
|
|
5771
|
+
position: fixed;
|
|
5772
|
+
inset: 0;
|
|
5773
|
+
z-index: 2000;
|
|
5774
|
+
display: grid;
|
|
5775
|
+
place-items: center;
|
|
5776
|
+
color: var(--linear-text-muted, #63727f);
|
|
5777
|
+
background: var(--linear-bg, #ffffff);
|
|
5778
|
+
font-size: 0.9rem;
|
|
5779
|
+
}
|
|
5780
|
+
|
|
5781
|
+
.auth-actions {
|
|
5782
|
+
display: inline-flex;
|
|
5783
|
+
align-items: center;
|
|
5784
|
+
gap: 0.4rem;
|
|
5785
|
+
}
|
|
5786
|
+
|
|
5787
|
+
.auth-logout-form {
|
|
5788
|
+
display: inline-flex;
|
|
5789
|
+
margin: 0;
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5792
|
+
.auth-change-password-btn,
|
|
5793
|
+
.auth-logout-btn {
|
|
5794
|
+
white-space: nowrap;
|
|
5795
|
+
}
|
|
5796
|
+
|
|
5797
|
+
.auth-modal[hidden] {
|
|
5798
|
+
display: none;
|
|
5799
|
+
}
|
|
5800
|
+
|
|
5801
|
+
.auth-modal {
|
|
5802
|
+
position: fixed;
|
|
5803
|
+
inset: 0;
|
|
5804
|
+
z-index: 1000;
|
|
5805
|
+
display: grid;
|
|
5806
|
+
place-items: center;
|
|
5807
|
+
padding: 1.25rem;
|
|
5808
|
+
}
|
|
5809
|
+
|
|
5810
|
+
.auth-modal-backdrop {
|
|
5811
|
+
position: absolute;
|
|
5812
|
+
inset: 0;
|
|
5813
|
+
background: rgba(10, 16, 24, 0.42);
|
|
5814
|
+
backdrop-filter: blur(4px);
|
|
5815
|
+
}
|
|
5816
|
+
|
|
5817
|
+
.auth-modal-card,
|
|
5818
|
+
.auth-login-card {
|
|
5819
|
+
position: relative;
|
|
5820
|
+
z-index: 1;
|
|
5821
|
+
color: var(--linear-text, #18212a);
|
|
5822
|
+
background: var(--linear-bg, #ffffff);
|
|
5823
|
+
border: 1px solid var(--linear-border, #d7dee5);
|
|
5824
|
+
border-radius: 14px;
|
|
5825
|
+
box-shadow: 0 24px 70px rgba(17, 29, 40, 0.2);
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5828
|
+
.auth-modal-card {
|
|
5829
|
+
width: min(100%, 440px);
|
|
5830
|
+
padding: 1.5rem;
|
|
5831
|
+
}
|
|
5832
|
+
|
|
5833
|
+
.auth-modal-header {
|
|
5834
|
+
display: flex;
|
|
5835
|
+
align-items: flex-start;
|
|
5836
|
+
justify-content: space-between;
|
|
5837
|
+
gap: 1rem;
|
|
5838
|
+
}
|
|
5839
|
+
|
|
5840
|
+
.auth-modal-header h2,
|
|
5841
|
+
.auth-login-card h1 {
|
|
5842
|
+
margin: 0;
|
|
5843
|
+
color: var(--linear-text, #18212a);
|
|
5844
|
+
}
|
|
5845
|
+
|
|
5846
|
+
.auth-modal-eyebrow,
|
|
5847
|
+
.auth-login-eyebrow {
|
|
5848
|
+
margin: 0 0 0.35rem;
|
|
5849
|
+
color: var(--linear-accent, #267c83);
|
|
5850
|
+
font-size: 0.7rem;
|
|
5851
|
+
font-weight: 700;
|
|
5852
|
+
letter-spacing: 0.12em;
|
|
5853
|
+
}
|
|
5854
|
+
|
|
5855
|
+
.auth-modal-close {
|
|
5856
|
+
width: 2rem;
|
|
5857
|
+
height: 2rem;
|
|
5858
|
+
padding: 0;
|
|
5859
|
+
border: 0;
|
|
5860
|
+
border-radius: 999px;
|
|
5861
|
+
color: var(--linear-text-muted, #63727f);
|
|
5862
|
+
background: transparent;
|
|
5863
|
+
font-size: 1.5rem;
|
|
5864
|
+
line-height: 1;
|
|
5865
|
+
cursor: pointer;
|
|
5866
|
+
}
|
|
5867
|
+
|
|
5868
|
+
.auth-modal-close:hover {
|
|
5869
|
+
background: var(--linear-bg-subtle, #eef2f5);
|
|
5870
|
+
}
|
|
5871
|
+
|
|
5872
|
+
.auth-modal-help,
|
|
5873
|
+
.auth-login-description,
|
|
5874
|
+
.auth-login-note {
|
|
5875
|
+
color: var(--linear-text-muted, #63727f);
|
|
5876
|
+
line-height: 1.6;
|
|
5877
|
+
}
|
|
5878
|
+
|
|
5879
|
+
.auth-modal-help {
|
|
5880
|
+
margin: 1rem 0 1.25rem;
|
|
5881
|
+
font-size: 0.875rem;
|
|
5882
|
+
}
|
|
5883
|
+
|
|
5884
|
+
.auth-form {
|
|
5885
|
+
display: grid;
|
|
5886
|
+
gap: 0.9rem;
|
|
5887
|
+
}
|
|
5888
|
+
|
|
5889
|
+
.auth-form label {
|
|
5890
|
+
display: grid;
|
|
5891
|
+
gap: 0.4rem;
|
|
5892
|
+
color: var(--linear-text, #18212a);
|
|
5893
|
+
font-size: 0.875rem;
|
|
5894
|
+
font-weight: 600;
|
|
5895
|
+
}
|
|
5896
|
+
|
|
5897
|
+
.auth-form input {
|
|
5898
|
+
width: 100%;
|
|
5899
|
+
box-sizing: border-box;
|
|
5900
|
+
padding: 0.7rem 0.8rem;
|
|
5901
|
+
border: 1px solid var(--linear-border, #d7dee5);
|
|
5902
|
+
border-radius: 8px;
|
|
5903
|
+
color: var(--linear-text, #18212a);
|
|
5904
|
+
background: var(--linear-bg, #ffffff);
|
|
5905
|
+
font: inherit;
|
|
5906
|
+
}
|
|
5907
|
+
|
|
5908
|
+
.auth-form input:focus {
|
|
5909
|
+
outline: 2px solid var(--linear-accent, #267c83);
|
|
5910
|
+
outline-offset: 1px;
|
|
5911
|
+
}
|
|
5912
|
+
|
|
5913
|
+
.auth-form-error {
|
|
5914
|
+
margin: 0;
|
|
5915
|
+
padding: 0.65rem 0.75rem;
|
|
5916
|
+
border: 1px solid #e8b7b3;
|
|
5917
|
+
border-radius: 8px;
|
|
5918
|
+
color: #a32e26;
|
|
5919
|
+
background: #fff4f2;
|
|
5920
|
+
font-size: 0.875rem;
|
|
5921
|
+
}
|
|
5922
|
+
|
|
5923
|
+
.auth-modal-actions {
|
|
5924
|
+
display: flex;
|
|
5925
|
+
justify-content: flex-end;
|
|
5926
|
+
gap: 0.6rem;
|
|
5927
|
+
margin-top: 0.25rem;
|
|
5928
|
+
}
|
|
5929
|
+
|
|
5930
|
+
.auth-primary-btn,
|
|
5931
|
+
.auth-secondary-btn {
|
|
5932
|
+
min-height: 2.5rem;
|
|
5933
|
+
padding: 0.55rem 0.95rem;
|
|
5934
|
+
border-radius: 8px;
|
|
5935
|
+
font: inherit;
|
|
5936
|
+
font-weight: 650;
|
|
5937
|
+
cursor: pointer;
|
|
5938
|
+
}
|
|
5939
|
+
|
|
5940
|
+
.auth-primary-btn {
|
|
5941
|
+
border: 1px solid var(--linear-accent, #267c83);
|
|
5942
|
+
color: #fff;
|
|
5943
|
+
background: var(--linear-accent, #267c83);
|
|
5944
|
+
}
|
|
5945
|
+
|
|
5946
|
+
.auth-secondary-btn {
|
|
5947
|
+
border: 1px solid var(--linear-border, #d7dee5);
|
|
5948
|
+
color: var(--linear-text, #18212a);
|
|
5949
|
+
background: var(--linear-bg-subtle, #eef2f5);
|
|
5950
|
+
}
|
|
5951
|
+
|
|
5952
|
+
.auth-page {
|
|
5953
|
+
min-height: 100vh;
|
|
5954
|
+
display: grid;
|
|
5955
|
+
place-items: center;
|
|
5956
|
+
box-sizing: border-box;
|
|
5957
|
+
padding: 1.5rem;
|
|
5958
|
+
background: linear-gradient(135deg, #eef3f4 0%, #f8fafb 48%, #e5eeee 100%);
|
|
5959
|
+
}
|
|
5960
|
+
|
|
5961
|
+
.auth-login-card {
|
|
5962
|
+
width: min(100%, 450px);
|
|
5963
|
+
padding: clamp(1.5rem, 4vw, 2.5rem);
|
|
5964
|
+
}
|
|
5965
|
+
|
|
5966
|
+
.auth-login-brand {
|
|
5967
|
+
display: flex;
|
|
5968
|
+
align-items: center;
|
|
5969
|
+
gap: 0.9rem;
|
|
5970
|
+
}
|
|
5971
|
+
|
|
5972
|
+
.auth-login-logo,
|
|
5973
|
+
.auth-login-logo-fallback {
|
|
5974
|
+
width: 3rem;
|
|
5975
|
+
height: 3rem;
|
|
5976
|
+
flex: 0 0 3rem;
|
|
5977
|
+
display: grid;
|
|
5978
|
+
place-items: center;
|
|
5979
|
+
border-radius: 10px;
|
|
5980
|
+
object-fit: contain;
|
|
5981
|
+
font-size: 1.8rem;
|
|
5982
|
+
}
|
|
5983
|
+
|
|
5984
|
+
.auth-login-logo-fallback {
|
|
5985
|
+
background: var(--linear-bg-subtle, #eef2f5);
|
|
5986
|
+
}
|
|
5987
|
+
|
|
5988
|
+
.auth-login-card h1 {
|
|
5989
|
+
font-size: clamp(1.35rem, 4vw, 1.8rem);
|
|
5990
|
+
}
|
|
5991
|
+
|
|
5992
|
+
.auth-login-brand p:last-child {
|
|
5993
|
+
margin: 0.3rem 0 0;
|
|
5994
|
+
color: var(--linear-text-muted, #63727f);
|
|
5995
|
+
font-size: 0.875rem;
|
|
5996
|
+
}
|
|
5997
|
+
|
|
5998
|
+
.auth-login-divider {
|
|
5999
|
+
height: 1px;
|
|
6000
|
+
margin: 1.6rem 0 1.25rem;
|
|
6001
|
+
background: var(--linear-border, #d7dee5);
|
|
6002
|
+
}
|
|
6003
|
+
|
|
6004
|
+
.auth-login-description {
|
|
6005
|
+
margin: 0 0 1.25rem;
|
|
6006
|
+
}
|
|
6007
|
+
|
|
6008
|
+
.auth-login-note {
|
|
6009
|
+
margin: 1rem 0 0;
|
|
6010
|
+
font-size: 0.75rem;
|
|
6011
|
+
text-align: center;
|
|
6012
|
+
}
|
|
6013
|
+
|
|
6014
|
+
@media (max-width: 768px) {
|
|
6015
|
+
.auth-actions {
|
|
6016
|
+
gap: 0.25rem;
|
|
6017
|
+
}
|
|
6018
|
+
|
|
6019
|
+
.auth-change-password-btn,
|
|
6020
|
+
.auth-logout-btn {
|
|
6021
|
+
min-width: 44px;
|
|
6022
|
+
padding-inline: 0.5rem;
|
|
6023
|
+
font-size: 0.75rem;
|
|
6024
|
+
}
|
|
6025
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
(function () {
|
|
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;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clearPassphrase() {
|
|
26
|
+
try {
|
|
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 };
|
|
65
|
+
});
|
|
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;
|
|
80
|
+
}
|
|
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 initChangePassword() {
|
|
96
|
+
var modal = document.getElementById('auth-change-password-modal');
|
|
97
|
+
var openButton = document.getElementById('auth-change-password-btn');
|
|
98
|
+
var form = document.getElementById('auth-change-password-form');
|
|
99
|
+
var error = document.getElementById('auth-change-password-error');
|
|
100
|
+
if (!modal || !openButton || !form) return;
|
|
101
|
+
|
|
102
|
+
var closeButtons = modal.querySelectorAll('[data-auth-close]');
|
|
103
|
+
|
|
104
|
+
function setError(message) {
|
|
105
|
+
if (!error) return;
|
|
106
|
+
error.textContent = message || '';
|
|
107
|
+
error.hidden = !message;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function closeModal() {
|
|
111
|
+
modal.hidden = true;
|
|
112
|
+
setError('');
|
|
113
|
+
form.reset();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
openButton.addEventListener('click', function () {
|
|
117
|
+
modal.hidden = false;
|
|
118
|
+
var firstInput = form.querySelector('input');
|
|
119
|
+
if (firstInput) firstInput.focus();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
closeButtons.forEach(function (button) {
|
|
123
|
+
button.addEventListener('click', closeModal);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
form.addEventListener('submit', async function (event) {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
setError('');
|
|
129
|
+
var formData = new FormData(form);
|
|
130
|
+
var nextPassphrase = String(formData.get('nextPassphrase') || '');
|
|
131
|
+
var confirmPassphrase = String(formData.get('confirmPassphrase') || '');
|
|
132
|
+
if (nextPassphrase !== confirmPassphrase) {
|
|
133
|
+
setError('两次输入的新口令不一致。');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
var response = await fetch('/auth/change-password', {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
credentials: 'same-origin',
|
|
141
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
currentPassphrase: String(formData.get('currentPassphrase') || ''),
|
|
144
|
+
nextPassphrase: nextPassphrase,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
var result = await response.json().catch(function () { return {}; });
|
|
148
|
+
if (!response.ok || !result.ok) {
|
|
149
|
+
throw new Error(result.error || '修改口令失败,请重试。');
|
|
150
|
+
}
|
|
151
|
+
clearPassphrase();
|
|
152
|
+
window.location.assign('/login?changed=1');
|
|
153
|
+
} catch (requestError) {
|
|
154
|
+
setError(requestError instanceof Error ? requestError.message : '修改口令失败,请重试。');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function initLogout() {
|
|
160
|
+
document.querySelectorAll('.auth-logout-form').forEach(function (form) {
|
|
161
|
+
form.addEventListener('submit', function () {
|
|
162
|
+
clearPassphrase();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function initLoginPage() {
|
|
168
|
+
var form = document.querySelector('.auth-login-form');
|
|
169
|
+
var error = document.querySelector('.auth-login-form .auth-form-error');
|
|
170
|
+
var passphraseInput = form ? form.querySelector('input[name="passphrase"]') : null;
|
|
171
|
+
var nextInput = form ? form.querySelector('input[name="next"]') : null;
|
|
172
|
+
if (!form || !passphraseInput) return;
|
|
173
|
+
|
|
174
|
+
function setError(message) {
|
|
175
|
+
if (!error) return;
|
|
176
|
+
error.textContent = message || '';
|
|
177
|
+
error.hidden = !message;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function submitPassphrase(passphrase, auto) {
|
|
181
|
+
if (!passphrase) return false;
|
|
182
|
+
if (!auto) setError('');
|
|
183
|
+
var result = await verifyWithBackend(passphrase);
|
|
184
|
+
if (!result.ok) {
|
|
185
|
+
if (auto && !result.unavailable) clearPassphrase();
|
|
186
|
+
setError(auto && !result.unavailable ? '本地保存的口令已失效,请输入新口令。' : (result.error || '口令不正确,请重试。'));
|
|
187
|
+
if (!auto) passphraseInput.focus();
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
if (!writePassphrase(passphrase)) {
|
|
191
|
+
setError('浏览器拒绝保存口令,请检查本地存储权限。');
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
window.location.replace(safeNextPath(nextInput ? nextInput.value : '/'));
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
form.addEventListener('submit', function (event) {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
submitPassphrase(String(passphraseInput.value || ''), false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
var cachedPassphrase = readPassphrase();
|
|
204
|
+
if (cachedPassphrase) submitPassphrase(cachedPassphrase, true);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function initProtectedPage() {
|
|
208
|
+
ensureAuthorized().then(function (authorized) {
|
|
209
|
+
if (!authorized) {
|
|
210
|
+
redirectToLogin();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
removePendingState();
|
|
214
|
+
initChangePassword();
|
|
215
|
+
initLogout();
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (document.body && document.body.classList.contains('auth-page')) {
|
|
220
|
+
initLoginPage();
|
|
221
|
+
} else if (document.readyState === 'loading') {
|
|
222
|
+
document.addEventListener('DOMContentLoaded', initProtectedPage);
|
|
223
|
+
} else {
|
|
224
|
+
initProtectedPage();
|
|
225
|
+
}
|
|
226
|
+
})();
|
|
@@ -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
|
})
|
|
@@ -0,0 +1,46 @@
|
|
|
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.0, viewport-fit=cover">
|
|
6
|
+
<title>授权进入 - <%= docsTitle %></title>
|
|
7
|
+
<link rel="icon" href="/favicon.ico?v=<%= assetVersion %>" sizes="any">
|
|
8
|
+
<link rel="stylesheet" href="/static/css/styles.css?v=<%= assetVersion %>">
|
|
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>
|
|
16
|
+
</head>
|
|
17
|
+
<body class="auth-page">
|
|
18
|
+
<main class="auth-login-card" aria-labelledby="auth-login-title">
|
|
19
|
+
<div class="auth-login-brand">
|
|
20
|
+
<% if (docsLogo) { %>
|
|
21
|
+
<img class="auth-login-logo" src="<%= docsLogo %>" alt="">
|
|
22
|
+
<% } else { %>
|
|
23
|
+
<span class="auth-login-logo-fallback" aria-hidden="true">📚</span>
|
|
24
|
+
<% } %>
|
|
25
|
+
<div>
|
|
26
|
+
<p class="auth-login-eyebrow">LOCAL DOCUMENT CENTER</p>
|
|
27
|
+
<h1 id="auth-login-title"><%= docsTitle %></h1>
|
|
28
|
+
<p><%= docsSubtitle %></p>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="auth-login-divider"></div>
|
|
32
|
+
<p class="auth-login-description">此文档中心需要授权口令。知道口令即可查看全部文档。</p>
|
|
33
|
+
<form action="/auth/login" method="post" class="auth-form auth-login-form">
|
|
34
|
+
<input type="hidden" name="next" value="<%= nextPath %>">
|
|
35
|
+
<label>
|
|
36
|
+
授权口令
|
|
37
|
+
<input type="password" name="passphrase" autocomplete="current-password" autofocus required>
|
|
38
|
+
</label>
|
|
39
|
+
<p class="auth-form-error" role="alert" id="auth-login-error" <%= error ? '' : 'hidden' %>><%= error %></p>
|
|
40
|
+
<button type="submit" class="auth-primary-btn">进入文档中心</button>
|
|
41
|
+
</form>
|
|
42
|
+
<p class="auth-login-note">没有账号体系,也没有角色区分;口令是唯一的入口。</p>
|
|
43
|
+
</main>
|
|
44
|
+
<script src="/static/js/core/auth.js?v=<%= assetVersion %>"></script>
|
|
45
|
+
</body>
|
|
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,7 +54,52 @@
|
|
|
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
|
+
<% } %>
|
|
57
67
|
</div>
|
|
58
68
|
</div>
|
|
59
69
|
</div>
|
|
60
70
|
</header>
|
|
71
|
+
<% 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
|
+
<script src="/static/js/core/auth.js?v=<%= typeof assetVersion !== 'undefined' ? assetVersion : 'dev' %>"></script>
|
|
105
|
+
<% } %>
|