docnest 0.1.7 → 0.1.9
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 +24 -2
- package/dist/attachments.js +2 -0
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +190 -0
- package/dist/cli.js +2 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +11 -0
- package/dist/server.js +127 -1
- package/docnest.config.example.mjs +48 -0
- package/package.json +3 -2
- package/server/static/css/styles.css +247 -0
- package/server/static/js/core/auth.js +61 -0
- package/server/views/login.ejs +41 -0
- package/server/views/partials/header.ejs +45 -0
package/README.md
CHANGED
|
@@ -58,6 +58,28 @@ 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
|
+
sessionTtlMinutes: 24 * 60,
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
授权只有一道关口,没有账号、密码组合或角色体系。首次启动时,`passphrase` 会初始化到由
|
|
77
|
+
`stateFile` 指定的本地授权状态文件;状态文件只保存带随机盐的口令哈希,不保存明文口令。
|
|
78
|
+
进入文档中心后,右上角的“修改口令”可以动态更新口令。修改后所有现有会话立即失效,必须使用新口令重新进入。
|
|
79
|
+
建议把 `.docnest/` 加入宿主项目的 `.gitignore`,并为口令状态文件设置仅当前用户可读写的权限。
|
|
80
|
+
|
|
81
|
+
授权开启但既没有状态文件,也没有初始 `passphrase` 时,DocNest 会拒绝启动,避免意外以空口令开放文档。
|
|
82
|
+
|
|
61
83
|
如需显式指定端口、文档目录或监听地址:
|
|
62
84
|
|
|
63
85
|
```bash
|
|
@@ -70,7 +92,7 @@ npx docnest serve --port 3000 --docs-dir docs --host 127.0.0.1
|
|
|
70
92
|
|
|
71
93
|
## 嵌入现有 Node 服务
|
|
72
94
|
|
|
73
|
-
需要接入项目自己的鉴权或文档来源时,在加载 `docnest/server` 前注入中间件和文档存储适配器,然后使用导出的 Express/Socket.IO
|
|
95
|
+
需要接入项目自己的鉴权或文档来源时,在加载 `docnest/server` 前注入中间件和文档存储适配器,然后使用导出的 Express/Socket.IO 实例。项目若不使用上面的内置单口令授权,仍可在这里接入自己的访问控制:
|
|
74
96
|
|
|
75
97
|
```js
|
|
76
98
|
globalThis.__DOCNEST_BEFORE_DOCUMENT_ROUTES__ = [projectAuthMiddleware]
|
|
@@ -82,7 +104,7 @@ io.use(projectSocketAuth)
|
|
|
82
104
|
startServer(3000)
|
|
83
105
|
```
|
|
84
106
|
|
|
85
|
-
存储适配器实现 `name`、`readDir`、`fileExists`、`readFile`、`readAsset`,可选实现 `ready`;这样文档内容仍由宿主项目提供,DocNest
|
|
107
|
+
存储适配器实现 `name`、`readDir`、`fileExists`、`readFile`、`readAsset`,可选实现 `ready`;这样文档内容仍由宿主项目提供,DocNest 负责展示、交互和可选的单口令授权。
|
|
86
108
|
|
|
87
109
|
## 静态构建中的文档附件
|
|
88
110
|
|
package/dist/attachments.js
CHANGED
|
@@ -86,6 +86,8 @@ function resolveAttachmentPath(docsRoot, realDocsRoot, markdownPath, rawTarget,
|
|
|
86
86
|
}
|
|
87
87
|
if (!decodedTarget)
|
|
88
88
|
return null;
|
|
89
|
+
if (decodedTarget.endsWith('/'))
|
|
90
|
+
return null;
|
|
89
91
|
const extension = node_path_1.default.extname(decodedTarget).toLowerCase();
|
|
90
92
|
if (MARKDOWN_SOURCE_EXTENSIONS.has(extension))
|
|
91
93
|
return null;
|
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,12 @@ 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
|
+
sessionTtlMinutes?: number;
|
|
33
|
+
};
|
|
28
34
|
export?: {
|
|
29
35
|
watermark?: {
|
|
30
36
|
enabled?: boolean;
|
|
@@ -40,6 +46,12 @@ export type ResolvedDocNestConfig = Required<DocNestConfig> & {
|
|
|
40
46
|
navigation: Required<NonNullable<DocNestConfig['navigation']>>;
|
|
41
47
|
appearance: Required<NonNullable<DocNestConfig['appearance']>>;
|
|
42
48
|
server: Required<NonNullable<DocNestConfig['server']>>;
|
|
49
|
+
auth: {
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
passphrase: string;
|
|
52
|
+
stateFile: string;
|
|
53
|
+
sessionTtlMinutes: number;
|
|
54
|
+
};
|
|
43
55
|
export: {
|
|
44
56
|
watermark: Required<NonNullable<NonNullable<DocNestConfig['export']>['watermark']>>;
|
|
45
57
|
};
|
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,6 +86,7 @@ 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}`;
|
|
87
92
|
return {
|
|
@@ -105,6 +110,12 @@ async function loadConfig(projectRoot = process.cwd()) {
|
|
|
105
110
|
watch: server.watch ?? true,
|
|
106
111
|
openBrowser: server.openBrowser ?? true,
|
|
107
112
|
},
|
|
113
|
+
auth: {
|
|
114
|
+
enabled: auth.enabled ?? false,
|
|
115
|
+
passphrase: typeof auth.passphrase === 'string' ? auth.passphrase : '',
|
|
116
|
+
stateFile: node_path_1.default.resolve(projectRoot, auth.stateFile?.trim() || '.docnest/auth.json'),
|
|
117
|
+
sessionTtlMinutes: normalizePositiveNumber(auth.sessionTtlMinutes, 24 * 60),
|
|
118
|
+
},
|
|
108
119
|
export: {
|
|
109
120
|
watermark: {
|
|
110
121
|
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,15 @@ 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
|
+
sessionTtlMinutes: 24 * 60,
|
|
96
|
+
};
|
|
97
|
+
const AUTH_MANAGER = createAuthManager(AUTH_CONFIG);
|
|
98
|
+
const BUILT_IN_AUTH_ENABLED = AUTH_MANAGER.enabled;
|
|
99
|
+
const AUTH_ENABLED = BUILT_IN_AUTH_ENABLED || globalThis.__DOCNEST_AUTH_ENABLED__ === true;
|
|
91
100
|
// 配置 EJS 模板引擎
|
|
92
101
|
app.set('view engine', 'ejs');
|
|
93
102
|
app.set('views', path.join(SERVER_DIR, 'views'));
|
|
@@ -100,6 +109,7 @@ app.locals.docsThemeModeDefault = DOCS_THEME_MODE_DEFAULT;
|
|
|
100
109
|
app.locals.docsThemeEnabled = DOCS_THEME_ENABLED;
|
|
101
110
|
app.locals.docsWatermarkEnabled = DOCS_WATERMARK_ENABLED;
|
|
102
111
|
app.locals.docsWatermarkText = DOCS_WATERMARK_TEXT;
|
|
112
|
+
app.locals.docsAuthEnabled = BUILT_IN_AUTH_ENABLED;
|
|
103
113
|
function normalizeDocRelativePath(value) {
|
|
104
114
|
const raw = String(value || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
|
105
115
|
const normalized = path.posix.normalize(raw);
|
|
@@ -232,6 +242,122 @@ app.use('/vendor/html2canvas', express.static(HTML2CANVAS_DIR));
|
|
|
232
242
|
app.use('/vendor/dompurify', express.static(DOMPURIFY_DIR));
|
|
233
243
|
// jsPDF(本地,用于 Mermaid 图导出 PDF)
|
|
234
244
|
app.use('/vendor/jspdf', express.static(JSPDF_DIR));
|
|
245
|
+
app.use(express.json({ limit: '16kb' }));
|
|
246
|
+
app.use(express.urlencoded({ extended: false, limit: '16kb' }));
|
|
247
|
+
function readCookie(cookieHeader, name) {
|
|
248
|
+
if (!cookieHeader)
|
|
249
|
+
return undefined;
|
|
250
|
+
for (const item of String(cookieHeader).split(';')) {
|
|
251
|
+
const separator = item.indexOf('=');
|
|
252
|
+
if (separator === -1)
|
|
253
|
+
continue;
|
|
254
|
+
const key = item.slice(0, separator).trim();
|
|
255
|
+
if (key !== name)
|
|
256
|
+
continue;
|
|
257
|
+
try {
|
|
258
|
+
return decodeURIComponent(item.slice(separator + 1).trim());
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
function readAuthToken(req) {
|
|
267
|
+
return readCookie(req.headers.cookie, AUTH_MANAGER.sessionCookieName);
|
|
268
|
+
}
|
|
269
|
+
function safeNextPath(value) {
|
|
270
|
+
if (typeof value !== 'string' ||
|
|
271
|
+
!value.startsWith('/') ||
|
|
272
|
+
value.startsWith('//') ||
|
|
273
|
+
value.includes('\\') ||
|
|
274
|
+
/[\r\n]/.test(value))
|
|
275
|
+
return '/';
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
function setAuthCookie(res, token) {
|
|
279
|
+
const maxAge = AUTH_MANAGER.sessionMaxAgeSeconds;
|
|
280
|
+
res.setHeader('Set-Cookie', `${AUTH_MANAGER.sessionCookieName}=${encodeURIComponent(token)}; Max-Age=${maxAge}; Path=/; HttpOnly; SameSite=Lax`);
|
|
281
|
+
}
|
|
282
|
+
function clearAuthCookie(res) {
|
|
283
|
+
res.setHeader('Set-Cookie', `${AUTH_MANAGER.sessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`);
|
|
284
|
+
}
|
|
285
|
+
function wantsJson(req) {
|
|
286
|
+
return req.path.startsWith('/auth/') || req.get('accept')?.includes('application/json');
|
|
287
|
+
}
|
|
288
|
+
function renderLoginPage(res, nextPath, error, status = 200) {
|
|
289
|
+
return res.status(status).render('login', {
|
|
290
|
+
nextPath: safeNextPath(nextPath),
|
|
291
|
+
error: error || '',
|
|
292
|
+
assetVersion: ASSET_VERSION,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
app.get('/login', (req, res) => {
|
|
296
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
297
|
+
return res.redirect('/');
|
|
298
|
+
if (AUTH_MANAGER.authenticateSession(readAuthToken(req))) {
|
|
299
|
+
return res.redirect(safeNextPath(req.query.next));
|
|
300
|
+
}
|
|
301
|
+
const message = req.query.changed
|
|
302
|
+
? '口令已更新,请使用新口令重新进入。'
|
|
303
|
+
: '';
|
|
304
|
+
return renderLoginPage(res, req.query.next, message);
|
|
305
|
+
});
|
|
306
|
+
app.post('/auth/login', (req, res) => {
|
|
307
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
308
|
+
return res.redirect('/');
|
|
309
|
+
const nextPath = safeNextPath(req.body?.next);
|
|
310
|
+
const token = AUTH_MANAGER.createSession(String(req.body?.passphrase || ''));
|
|
311
|
+
if (!token)
|
|
312
|
+
return renderLoginPage(res, nextPath, '口令不正确,请重试。', 401);
|
|
313
|
+
setAuthCookie(res, token);
|
|
314
|
+
return res.redirect(nextPath);
|
|
315
|
+
});
|
|
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
|
+
function documentAuthMiddleware(req, res, next) {
|
|
340
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
341
|
+
return next();
|
|
342
|
+
if (req.path === '/health' || req.path === '/ready')
|
|
343
|
+
return next();
|
|
344
|
+
if (AUTH_MANAGER.authenticateSession(readAuthToken(req))) {
|
|
345
|
+
res.locals.authenticated = true;
|
|
346
|
+
return next();
|
|
347
|
+
}
|
|
348
|
+
if (wantsJson(req))
|
|
349
|
+
return res.status(401).json({ ok: false, error: '需要授权口令。' });
|
|
350
|
+
return res.redirect(`/login?next=${encodeURIComponent(req.originalUrl || '/')}`);
|
|
351
|
+
}
|
|
352
|
+
app.use(documentAuthMiddleware);
|
|
353
|
+
io.use((socket, next) => {
|
|
354
|
+
if (!BUILT_IN_AUTH_ENABLED)
|
|
355
|
+
return next();
|
|
356
|
+
const token = readCookie(socket.handshake.headers.cookie, AUTH_MANAGER.sessionCookieName);
|
|
357
|
+
if (AUTH_MANAGER.authenticateSession(token))
|
|
358
|
+
return next();
|
|
359
|
+
return next(new Error('需要授权口令。'));
|
|
360
|
+
});
|
|
235
361
|
// 文档中的本地图片、附件等静态资源,只允许从文档根目录读取。
|
|
236
362
|
app.get('/doc-asset/*', async (req, res) => {
|
|
237
363
|
let requestedPath = req.path.slice('/doc-asset/'.length);
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
sessionTtlMinutes: 24 * 60,
|
|
41
|
+
},
|
|
42
|
+
export: {
|
|
43
|
+
watermark: {
|
|
44
|
+
enabled: false,
|
|
45
|
+
text: '我的项目文档',
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docnest",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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,250 @@ body[data-theme="dark"] .dir-listing-count {
|
|
|
5760
5760
|
font-size: 1.1rem;
|
|
5761
5761
|
}
|
|
5762
5762
|
}
|
|
5763
|
+
|
|
5764
|
+
/* 极简单口令授权 */
|
|
5765
|
+
.auth-actions {
|
|
5766
|
+
display: inline-flex;
|
|
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;
|
|
5783
|
+
}
|
|
5784
|
+
|
|
5785
|
+
.auth-modal {
|
|
5786
|
+
position: fixed;
|
|
5787
|
+
inset: 0;
|
|
5788
|
+
z-index: 1000;
|
|
5789
|
+
display: grid;
|
|
5790
|
+
place-items: center;
|
|
5791
|
+
padding: 1.25rem;
|
|
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);
|
|
5799
|
+
}
|
|
5800
|
+
|
|
5801
|
+
.auth-modal-card,
|
|
5802
|
+
.auth-login-card {
|
|
5803
|
+
position: relative;
|
|
5804
|
+
z-index: 1;
|
|
5805
|
+
color: var(--linear-text, #18212a);
|
|
5806
|
+
background: var(--linear-bg, #ffffff);
|
|
5807
|
+
border: 1px solid var(--linear-border, #d7dee5);
|
|
5808
|
+
border-radius: 14px;
|
|
5809
|
+
box-shadow: 0 24px 70px rgba(17, 29, 40, 0.2);
|
|
5810
|
+
}
|
|
5811
|
+
|
|
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
|
+
.auth-login-card h1 {
|
|
5826
|
+
margin: 0;
|
|
5827
|
+
color: var(--linear-text, #18212a);
|
|
5828
|
+
}
|
|
5829
|
+
|
|
5830
|
+
.auth-modal-eyebrow,
|
|
5831
|
+
.auth-login-eyebrow {
|
|
5832
|
+
margin: 0 0 0.35rem;
|
|
5833
|
+
color: var(--linear-accent, #267c83);
|
|
5834
|
+
font-size: 0.7rem;
|
|
5835
|
+
font-weight: 700;
|
|
5836
|
+
letter-spacing: 0.12em;
|
|
5837
|
+
}
|
|
5838
|
+
|
|
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
|
+
.auth-login-description,
|
|
5858
|
+
.auth-login-note {
|
|
5859
|
+
color: var(--linear-text-muted, #63727f);
|
|
5860
|
+
line-height: 1.6;
|
|
5861
|
+
}
|
|
5862
|
+
|
|
5863
|
+
.auth-modal-help {
|
|
5864
|
+
margin: 1rem 0 1.25rem;
|
|
5865
|
+
font-size: 0.875rem;
|
|
5866
|
+
}
|
|
5867
|
+
|
|
5868
|
+
.auth-form {
|
|
5869
|
+
display: grid;
|
|
5870
|
+
gap: 0.9rem;
|
|
5871
|
+
}
|
|
5872
|
+
|
|
5873
|
+
.auth-form label {
|
|
5874
|
+
display: grid;
|
|
5875
|
+
gap: 0.4rem;
|
|
5876
|
+
color: var(--linear-text, #18212a);
|
|
5877
|
+
font-size: 0.875rem;
|
|
5878
|
+
font-weight: 600;
|
|
5879
|
+
}
|
|
5880
|
+
|
|
5881
|
+
.auth-form input {
|
|
5882
|
+
width: 100%;
|
|
5883
|
+
box-sizing: border-box;
|
|
5884
|
+
padding: 0.7rem 0.8rem;
|
|
5885
|
+
border: 1px solid var(--linear-border, #d7dee5);
|
|
5886
|
+
border-radius: 8px;
|
|
5887
|
+
color: var(--linear-text, #18212a);
|
|
5888
|
+
background: var(--linear-bg, #ffffff);
|
|
5889
|
+
font: inherit;
|
|
5890
|
+
}
|
|
5891
|
+
|
|
5892
|
+
.auth-form input:focus {
|
|
5893
|
+
outline: 2px solid var(--linear-accent, #267c83);
|
|
5894
|
+
outline-offset: 1px;
|
|
5895
|
+
}
|
|
5896
|
+
|
|
5897
|
+
.auth-form-error {
|
|
5898
|
+
margin: 0;
|
|
5899
|
+
padding: 0.65rem 0.75rem;
|
|
5900
|
+
border: 1px solid #e8b7b3;
|
|
5901
|
+
border-radius: 8px;
|
|
5902
|
+
color: #a32e26;
|
|
5903
|
+
background: #fff4f2;
|
|
5904
|
+
font-size: 0.875rem;
|
|
5905
|
+
}
|
|
5906
|
+
|
|
5907
|
+
.auth-modal-actions {
|
|
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 {
|
|
5916
|
+
min-height: 2.5rem;
|
|
5917
|
+
padding: 0.55rem 0.95rem;
|
|
5918
|
+
border-radius: 8px;
|
|
5919
|
+
font: inherit;
|
|
5920
|
+
font-weight: 650;
|
|
5921
|
+
cursor: pointer;
|
|
5922
|
+
}
|
|
5923
|
+
|
|
5924
|
+
.auth-primary-btn {
|
|
5925
|
+
border: 1px solid var(--linear-accent, #267c83);
|
|
5926
|
+
color: #fff;
|
|
5927
|
+
background: var(--linear-accent, #267c83);
|
|
5928
|
+
}
|
|
5929
|
+
|
|
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
|
+
.auth-page {
|
|
5937
|
+
min-height: 100vh;
|
|
5938
|
+
display: grid;
|
|
5939
|
+
place-items: center;
|
|
5940
|
+
box-sizing: border-box;
|
|
5941
|
+
padding: 1.5rem;
|
|
5942
|
+
background: linear-gradient(135deg, #eef3f4 0%, #f8fafb 48%, #e5eeee 100%);
|
|
5943
|
+
}
|
|
5944
|
+
|
|
5945
|
+
.auth-login-card {
|
|
5946
|
+
width: min(100%, 450px);
|
|
5947
|
+
padding: clamp(1.5rem, 4vw, 2.5rem);
|
|
5948
|
+
}
|
|
5949
|
+
|
|
5950
|
+
.auth-login-brand {
|
|
5951
|
+
display: flex;
|
|
5952
|
+
align-items: center;
|
|
5953
|
+
gap: 0.9rem;
|
|
5954
|
+
}
|
|
5955
|
+
|
|
5956
|
+
.auth-login-logo,
|
|
5957
|
+
.auth-login-logo-fallback {
|
|
5958
|
+
width: 3rem;
|
|
5959
|
+
height: 3rem;
|
|
5960
|
+
flex: 0 0 3rem;
|
|
5961
|
+
display: grid;
|
|
5962
|
+
place-items: center;
|
|
5963
|
+
border-radius: 10px;
|
|
5964
|
+
object-fit: contain;
|
|
5965
|
+
font-size: 1.8rem;
|
|
5966
|
+
}
|
|
5967
|
+
|
|
5968
|
+
.auth-login-logo-fallback {
|
|
5969
|
+
background: var(--linear-bg-subtle, #eef2f5);
|
|
5970
|
+
}
|
|
5971
|
+
|
|
5972
|
+
.auth-login-card h1 {
|
|
5973
|
+
font-size: clamp(1.35rem, 4vw, 1.8rem);
|
|
5974
|
+
}
|
|
5975
|
+
|
|
5976
|
+
.auth-login-brand p:last-child {
|
|
5977
|
+
margin: 0.3rem 0 0;
|
|
5978
|
+
color: var(--linear-text-muted, #63727f);
|
|
5979
|
+
font-size: 0.875rem;
|
|
5980
|
+
}
|
|
5981
|
+
|
|
5982
|
+
.auth-login-divider {
|
|
5983
|
+
height: 1px;
|
|
5984
|
+
margin: 1.6rem 0 1.25rem;
|
|
5985
|
+
background: var(--linear-border, #d7dee5);
|
|
5986
|
+
}
|
|
5987
|
+
|
|
5988
|
+
.auth-login-description {
|
|
5989
|
+
margin: 0 0 1.25rem;
|
|
5990
|
+
}
|
|
5991
|
+
|
|
5992
|
+
.auth-login-note {
|
|
5993
|
+
margin: 1rem 0 0;
|
|
5994
|
+
font-size: 0.75rem;
|
|
5995
|
+
text-align: center;
|
|
5996
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
var modal = document.getElementById('auth-change-password-modal');
|
|
3
|
+
var openButton = document.getElementById('auth-change-password-btn');
|
|
4
|
+
var form = document.getElementById('auth-change-password-form');
|
|
5
|
+
var error = document.getElementById('auth-change-password-error');
|
|
6
|
+
if (!modal || !openButton || !form) return;
|
|
7
|
+
|
|
8
|
+
var closeButtons = modal.querySelectorAll('[data-auth-close]');
|
|
9
|
+
|
|
10
|
+
function setError(message) {
|
|
11
|
+
if (!error) return;
|
|
12
|
+
error.textContent = message || '';
|
|
13
|
+
error.hidden = !message;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function closeModal() {
|
|
17
|
+
modal.hidden = true;
|
|
18
|
+
setError('');
|
|
19
|
+
form.reset();
|
|
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;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
var response = await fetch('/auth/change-password', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
currentPassphrase: String(formData.get('currentPassphrase') || ''),
|
|
49
|
+
nextPassphrase: nextPassphrase,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
var result = await response.json().catch(function () { return {}; });
|
|
53
|
+
if (!response.ok || !result.ok) {
|
|
54
|
+
throw new Error(result.error || '修改口令失败,请重试。');
|
|
55
|
+
}
|
|
56
|
+
window.location.assign('/login?changed=1');
|
|
57
|
+
} catch (requestError) {
|
|
58
|
+
setError(requestError instanceof Error ? requestError.message : '修改口令失败,请重试。');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
})();
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
</head>
|
|
11
|
+
<body class="auth-page">
|
|
12
|
+
<main class="auth-login-card" aria-labelledby="auth-login-title">
|
|
13
|
+
<div class="auth-login-brand">
|
|
14
|
+
<% if (docsLogo) { %>
|
|
15
|
+
<img class="auth-login-logo" src="<%= docsLogo %>" alt="">
|
|
16
|
+
<% } else { %>
|
|
17
|
+
<span class="auth-login-logo-fallback" aria-hidden="true">📚</span>
|
|
18
|
+
<% } %>
|
|
19
|
+
<div>
|
|
20
|
+
<p class="auth-login-eyebrow">LOCAL DOCUMENT CENTER</p>
|
|
21
|
+
<h1 id="auth-login-title"><%= docsTitle %></h1>
|
|
22
|
+
<p><%= docsSubtitle %></p>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="auth-login-divider"></div>
|
|
26
|
+
<p class="auth-login-description">此文档中心需要授权口令。知道口令即可查看全部文档。</p>
|
|
27
|
+
<form action="/auth/login" method="post" class="auth-form auth-login-form">
|
|
28
|
+
<input type="hidden" name="next" value="<%= nextPath %>">
|
|
29
|
+
<label>
|
|
30
|
+
授权口令
|
|
31
|
+
<input type="password" name="passphrase" autocomplete="current-password" autofocus required>
|
|
32
|
+
</label>
|
|
33
|
+
<% if (error) { %>
|
|
34
|
+
<p class="auth-form-error" role="alert"><%= error %></p>
|
|
35
|
+
<% } %>
|
|
36
|
+
<button type="submit" class="auth-primary-btn">进入文档中心</button>
|
|
37
|
+
</form>
|
|
38
|
+
<p class="auth-login-note">没有账号体系,也没有角色区分;口令是唯一的入口。</p>
|
|
39
|
+
</main>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
@@ -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
|
+
<% } %>
|