@musnows/scriverse 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +3 -2
- package/README.md +3 -2
- package/dist/ai.js +105 -17
- package/dist/ai.js.map +1 -1
- package/dist/app.js +105 -25
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +9 -1
- package/dist/attachment-storage.js.map +1 -1
- package/dist/cli-contract.js +4 -0
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli-core.js +6 -0
- package/dist/cli-core.js.map +1 -1
- package/dist/credential-vault.js +7 -5
- package/dist/credential-vault.js.map +1 -1
- package/dist/database.js +121 -2
- package/dist/database.js.map +1 -1
- package/dist/domain.js +9 -0
- package/dist/domain.js.map +1 -1
- package/dist/public/app.js +105 -21
- package/dist/public/index.html +4 -3
- package/dist/public/markdown.js +1 -2
- package/dist/public/styles.css +10 -10
- package/dist/security.js +45 -10
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +56 -1
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +178 -19
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +15 -66
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/app.js
CHANGED
|
@@ -14,14 +14,14 @@ import { AiManager } from "./ai.js";
|
|
|
14
14
|
import { CredentialVault } from "./credential-vault.js";
|
|
15
15
|
import { Database } from "./database.js";
|
|
16
16
|
import { assertSafeDocxArchive } from "./docx-security.js";
|
|
17
|
-
import { TASK_TYPES } from "./domain.js";
|
|
17
|
+
import { DRAFT_SETTING_MODULES, TASK_TYPES } from "./domain.js";
|
|
18
18
|
import { AppError } from "./errors.js";
|
|
19
19
|
import { HYBRID_SEARCH_TYPES } from "./hybrid-search.js";
|
|
20
20
|
import { applyImportFileHints, parseNovelText } from "./parser.js";
|
|
21
|
-
import { Store, versionedEntityTypes } from "./store.js";
|
|
22
|
-
import { parsePagination } from "./pagination.js";
|
|
21
|
+
import { attachmentPermissionModules, Store, versionedEntityTypes } from "./store.js";
|
|
22
|
+
import { paginated, parsePagination } from "./pagination.js";
|
|
23
23
|
import { normalizeUploadFileName } from "./utils.js";
|
|
24
|
-
import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware } from "./security.js";
|
|
24
|
+
import { assertSafeAiEndpoint, createApiRateLimitMiddleware, createAuthenticationRateLimitMiddleware, createBasicAuthMiddleware, createSameOriginMiddleware, createSecurityHeadersMiddleware, createUploadRateLimitMiddleware, verifySetupToken } from "./security.js";
|
|
25
25
|
import { ImageCaptchaService } from "./image-captcha.js";
|
|
26
26
|
import { assertSafeImportedPlainText, decodeUtf8ImportedText } from "./import-security.js";
|
|
27
27
|
import { InvalidRasterImageError, readRasterImageMetadata } from "./image-metadata.js";
|
|
@@ -29,7 +29,7 @@ import { createRequestLoggingMiddleware, sanitizeRequestPath } from "./http-logg
|
|
|
29
29
|
import { accountReference, logger, sanitizeError } from "./logger.js";
|
|
30
30
|
import { currentRequestActor, runWithRequestActor } from "./request-context.js";
|
|
31
31
|
import { APP_VERSION } from "./version.js";
|
|
32
|
-
import { fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
|
|
32
|
+
import { canReadWorkModule, canWriteWorkModule, fullWorkModulePermissions, proseReplacementPermissionModules } from "./work-permissions.js";
|
|
33
33
|
import { CollaborationPresence, entityEditorPageKey, presencePageKinds } from "./collaboration-presence.js";
|
|
34
34
|
import { analysisTaskReadModules, clearSessionCookie, createCliApiScopeMiddleware, createUserSessionMiddleware, createWorkAuthorizationMiddleware, relationshipAnalysisReadModules, setSessionCookie, UserAuthService } from "./user-auth.js";
|
|
35
35
|
const nonEmpty = z.string().trim().min(1);
|
|
@@ -38,6 +38,7 @@ const optionalStrings = z.array(z.string()).optional();
|
|
|
38
38
|
const jsonObject = z.record(z.string(), z.unknown());
|
|
39
39
|
const chapterTypeSchema = z.enum(["正文", "设定", "作者的话", "其他"]);
|
|
40
40
|
const versionedEntityTypeSchema = z.enum(versionedEntityTypes);
|
|
41
|
+
const attachmentPermissionModuleSchema = z.enum(attachmentPermissionModules);
|
|
41
42
|
const maximumImportedTextLength = 20_000_000;
|
|
42
43
|
const maximumKnowledgeSectionsLength = 4_000_000;
|
|
43
44
|
const captchaFields = {
|
|
@@ -50,6 +51,7 @@ const registrationSchema = z.object({
|
|
|
50
51
|
username: usernameSchema,
|
|
51
52
|
password: passwordSchema,
|
|
52
53
|
passwordConfirmation: passwordSchema,
|
|
54
|
+
setupToken: z.string().max(500).optional(),
|
|
53
55
|
...captchaFields
|
|
54
56
|
}).strict().refine((input) => input.password === input.passwordConfirmation, {
|
|
55
57
|
path: ["passwordConfirmation"],
|
|
@@ -144,6 +146,8 @@ const settingSchema = z.object({
|
|
|
144
146
|
});
|
|
145
147
|
const draftSchema = z.object({
|
|
146
148
|
draftType: z.enum(["prose", "setting"]),
|
|
149
|
+
volumeId: identifier.nullable().optional(),
|
|
150
|
+
settingModule: z.enum(DRAFT_SETTING_MODULES).nullable().optional(),
|
|
147
151
|
title: nonEmpty.max(200),
|
|
148
152
|
content: z.string().max(200_000)
|
|
149
153
|
}).strict();
|
|
@@ -596,6 +600,32 @@ function redactTaskCharacterNames(record, permissions) {
|
|
|
596
600
|
}
|
|
597
601
|
return result;
|
|
598
602
|
}
|
|
603
|
+
function redactAiCallContext(record, permissions) {
|
|
604
|
+
const result = { ...record };
|
|
605
|
+
const scope = recordValue(result.contextScope);
|
|
606
|
+
if (!scope)
|
|
607
|
+
return result;
|
|
608
|
+
const redactedScope = { ...scope };
|
|
609
|
+
let restricted = false;
|
|
610
|
+
if (permissions.prose === "none") {
|
|
611
|
+
for (const field of ["selection", "chapterId", "volumeId", "chapterIds", "includeBookSummary"]) {
|
|
612
|
+
if (field in redactedScope) {
|
|
613
|
+
delete redactedScope[field];
|
|
614
|
+
restricted = true;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (permissions.characters === "none" && "characterIds" in redactedScope) {
|
|
619
|
+
delete redactedScope.characterIds;
|
|
620
|
+
restricted = true;
|
|
621
|
+
}
|
|
622
|
+
if (permissions.settings === "none" && "settingIds" in redactedScope) {
|
|
623
|
+
delete redactedScope.settingIds;
|
|
624
|
+
restricted = true;
|
|
625
|
+
}
|
|
626
|
+
result.contextScope = restricted ? { ...redactedScope, restricted: true } : redactedScope;
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
599
629
|
function redactMergeRecords(value, mapper) {
|
|
600
630
|
const record = recordValue(value);
|
|
601
631
|
if (!record)
|
|
@@ -642,6 +672,32 @@ export function createRuntime(options) {
|
|
|
642
672
|
? auth.listUsers().find((user) => user.status === "active") ?? null
|
|
643
673
|
: null;
|
|
644
674
|
const store = new Store(database);
|
|
675
|
+
let attachmentCleanupChain = Promise.resolve();
|
|
676
|
+
const cleanupAttachments = () => {
|
|
677
|
+
const cleanup = attachmentCleanupChain.then(async () => {
|
|
678
|
+
store.queueUnreferencedAttachments();
|
|
679
|
+
for (const queued of store.listAttachmentCleanupQueue()) {
|
|
680
|
+
if (!store.attachmentCleanupStillRequired(queued.storageKey)) {
|
|
681
|
+
store.completeAttachmentCleanup(queued.storageKey);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
await attachmentStorage.remove(queued.storageKey);
|
|
686
|
+
store.completeAttachmentCleanup(queued.storageKey);
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
store.failAttachmentCleanup(queued.storageKey, error instanceof Error ? error.message : "Attachment cleanup failed");
|
|
690
|
+
logger.warn("attachment.cleanup.failed", {
|
|
691
|
+
storageKey: queued.storageKey,
|
|
692
|
+
attempts: queued.attempts + 1,
|
|
693
|
+
error: sanitizeError(error)
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
attachmentCleanupChain = cleanup.catch(() => undefined);
|
|
699
|
+
return cleanup;
|
|
700
|
+
};
|
|
645
701
|
const requestPermissions = (request, workId) => {
|
|
646
702
|
if (!request.authUser)
|
|
647
703
|
return fullWorkModulePermissions();
|
|
@@ -709,14 +765,16 @@ export function createRuntime(options) {
|
|
|
709
765
|
app.get("/api/auth/session", (request, response) => {
|
|
710
766
|
const session = auth.authenticate(request);
|
|
711
767
|
const registrationOpen = options.security?.allowRegistration === true;
|
|
768
|
+
const setupRequired = !auth.hasUsers();
|
|
769
|
+
const setupTokenRequired = setupRequired && Boolean(options.security?.setupToken);
|
|
712
770
|
const developmentUser = getDevelopmentUser();
|
|
713
771
|
if (!session && developmentUser) {
|
|
714
|
-
data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, registrationOpen });
|
|
772
|
+
data(response, { authenticated: true, user: developmentUser, csrfToken: null, setupRequired: false, setupTokenRequired: false, registrationOpen });
|
|
715
773
|
return;
|
|
716
774
|
}
|
|
717
775
|
data(response, session
|
|
718
|
-
? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, registrationOpen }
|
|
719
|
-
: { authenticated: false, user: null, csrfToken: null, setupRequired
|
|
776
|
+
? { authenticated: true, user: session.user, csrfToken: session.csrfToken, setupRequired: false, setupTokenRequired: false, registrationOpen }
|
|
777
|
+
: { authenticated: false, user: null, csrfToken: null, setupRequired, setupTokenRequired, registrationOpen });
|
|
720
778
|
});
|
|
721
779
|
app.get("/api/auth/captcha", (_request, response) => {
|
|
722
780
|
data(response, captcha.create());
|
|
@@ -727,6 +785,9 @@ export function createRuntime(options) {
|
|
|
727
785
|
}
|
|
728
786
|
const input = parse(registrationSchema, request.body);
|
|
729
787
|
captcha.consume(input.captchaId, input.captchaAnswer);
|
|
788
|
+
if (!auth.hasUsers() && !verifySetupToken(options.security?.setupToken, input.setupToken)) {
|
|
789
|
+
throw new AppError(403, "SETUP_TOKEN_INVALID", "初始化令牌无效或未配置");
|
|
790
|
+
}
|
|
730
791
|
const result = auth.register({ username: input.username, password: input.password });
|
|
731
792
|
setSessionCookie(response, result.token, request.secure);
|
|
732
793
|
runWithRequestActor(result.session.user, () => store.audit(null, "user.registered", "user", result.session.user.userId, { role: result.session.user.role }));
|
|
@@ -746,6 +807,7 @@ export function createRuntime(options) {
|
|
|
746
807
|
disabled: options.disableUserAuth === true,
|
|
747
808
|
resolveBypassUser: getDevelopmentUser
|
|
748
809
|
}));
|
|
810
|
+
app.use(createUploadRateLimitMiddleware());
|
|
749
811
|
app.use(createCliApiScopeMiddleware(options.disableUserAuth));
|
|
750
812
|
app.use(createWorkAuthorizationMiddleware(auth, options.disableUserAuth));
|
|
751
813
|
app.get("/api/cli/session", (request, response) => {
|
|
@@ -945,8 +1007,8 @@ export function createRuntime(options) {
|
|
|
945
1007
|
});
|
|
946
1008
|
app.delete("/api/works/:workId", async (request, response) => {
|
|
947
1009
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
948
|
-
|
|
949
|
-
await
|
|
1010
|
+
store.deleteWork(request.params.workId, input.expectedVersionNo);
|
|
1011
|
+
await cleanupAttachments();
|
|
950
1012
|
noContent(response);
|
|
951
1013
|
});
|
|
952
1014
|
app.get("/api/works/:workId/cover", (request, response) => {
|
|
@@ -961,14 +1023,16 @@ export function createRuntime(options) {
|
|
|
961
1023
|
if (!request.file)
|
|
962
1024
|
throw new AppError(400, "FILE_REQUIRED", "请选择 PNG、JPEG 或 WebP 封面");
|
|
963
1025
|
const bytes = request.file.buffer;
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1026
|
+
try {
|
|
1027
|
+
const metadata = readRasterImageMetadata(bytes);
|
|
1028
|
+
const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
|
|
1029
|
+
data(response, store.setWorkCover(String(request.params.workId), metadata.mimeType, bytes, expectedVersionNo));
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
if (error instanceof InvalidRasterImageError)
|
|
1033
|
+
throw new AppError(415, "INVALID_COVER", error.message);
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
972
1036
|
});
|
|
973
1037
|
app.delete("/api/works/:workId/cover", (request, response) => {
|
|
974
1038
|
const input = parse(z.object({ expectedVersionNo: expectedVersionNoSchema }).strict(), request.body ?? {});
|
|
@@ -1301,11 +1365,16 @@ export function createRuntime(options) {
|
|
|
1301
1365
|
});
|
|
1302
1366
|
app.get("/api/works/:workId/attachments", (request, response) => {
|
|
1303
1367
|
const pagination = parsePagination(request.query);
|
|
1304
|
-
|
|
1368
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
1369
|
+
const readable = store.listAttachments(request.params.workId).filter((attachment) => (store.attachmentModules(String(attachment.id)).some((module) => canReadWorkModule(permissions, module))));
|
|
1370
|
+
data(response, pagination
|
|
1371
|
+
? paginated(readable.slice(pagination.offset, pagination.offset + pagination.limit + 1), pagination, readable.length)
|
|
1372
|
+
: readable);
|
|
1305
1373
|
});
|
|
1306
1374
|
app.post("/api/works/:workId/attachments", attachmentUpload.single("file"), async (request, response) => {
|
|
1307
1375
|
if (!request.file)
|
|
1308
1376
|
throw new AppError(400, "FILE_REQUIRED", "请选择要上传的图片附件");
|
|
1377
|
+
const accessModule = parse(attachmentPermissionModuleSchema, request.query.module ?? "settings");
|
|
1309
1378
|
let storageKey = null;
|
|
1310
1379
|
try {
|
|
1311
1380
|
const stored = await attachmentStorage.ingest(request.file.path);
|
|
@@ -1313,7 +1382,7 @@ export function createRuntime(options) {
|
|
|
1313
1382
|
const result = store.createAttachment(String(request.params.workId), {
|
|
1314
1383
|
originalName: normalizeUploadFileName(request.file.originalname),
|
|
1315
1384
|
...stored
|
|
1316
|
-
});
|
|
1385
|
+
}, accessModule);
|
|
1317
1386
|
data(response, { ...result.attachment, deduplicated: !result.created }, result.created ? 201 : 200);
|
|
1318
1387
|
}
|
|
1319
1388
|
catch (error) {
|
|
@@ -1330,6 +1399,10 @@ export function createRuntime(options) {
|
|
|
1330
1399
|
});
|
|
1331
1400
|
app.get("/api/attachments/:attachmentId/content", async (request, response) => {
|
|
1332
1401
|
const attachment = store.getAttachment(request.params.attachmentId);
|
|
1402
|
+
const permissions = requestPermissions(request, String(attachment.workId));
|
|
1403
|
+
if (!store.attachmentModules(request.params.attachmentId).some((module) => canReadWorkModule(permissions, module))) {
|
|
1404
|
+
throw new AppError(403, "WORK_MODULE_READ_DENIED", "你没有读取该附件所属资料模块的权限");
|
|
1405
|
+
}
|
|
1333
1406
|
const content = await attachmentStorage.read(String(attachment.storageKey));
|
|
1334
1407
|
response.setHeader("Content-Type", String(attachment.storedMimeType));
|
|
1335
1408
|
response.setHeader("Content-Length", String(attachment.storedByteLength));
|
|
@@ -1339,9 +1412,13 @@ export function createRuntime(options) {
|
|
|
1339
1412
|
response.send(content);
|
|
1340
1413
|
});
|
|
1341
1414
|
app.delete("/api/attachments/:attachmentId", async (request, response) => {
|
|
1342
|
-
const
|
|
1343
|
-
|
|
1344
|
-
|
|
1415
|
+
const attachment = store.getAttachment(request.params.attachmentId);
|
|
1416
|
+
const permissions = requestPermissions(request, String(attachment.workId));
|
|
1417
|
+
if (!store.attachmentModules(request.params.attachmentId).some((module) => canWriteWorkModule(permissions, module))) {
|
|
1418
|
+
throw new AppError(403, "WORK_MODULE_WRITE_DENIED", "你没有编辑该附件所属资料模块的权限");
|
|
1419
|
+
}
|
|
1420
|
+
store.deleteAttachment(request.params.attachmentId);
|
|
1421
|
+
await cleanupAttachments();
|
|
1345
1422
|
noContent(response);
|
|
1346
1423
|
});
|
|
1347
1424
|
app.get("/api/works/:workId/races", (request, response) => {
|
|
@@ -1981,7 +2058,10 @@ export function createRuntime(options) {
|
|
|
1981
2058
|
app.post("/api/suggestions/:suggestionId/reject", (request, response) => data(response, ai.rejectSuggestion(request.params.suggestionId)));
|
|
1982
2059
|
app.get("/api/works/:workId/ai-calls", (request, response) => {
|
|
1983
2060
|
const pagination = parsePagination(request.query);
|
|
1984
|
-
|
|
2061
|
+
const permissions = requestPermissions(request, request.params.workId);
|
|
2062
|
+
data(response, pagination
|
|
2063
|
+
? mapRecords(ai.listCallsPage(request.params.workId, pagination), (call) => redactAiCallContext(call, permissions))
|
|
2064
|
+
: ai.listCalls(request.params.workId).map((call) => redactAiCallContext(call, permissions)));
|
|
1985
2065
|
});
|
|
1986
2066
|
app.get("/api/works/:workId/search", async (request, response) => {
|
|
1987
2067
|
const query = parse(z.object({
|
|
@@ -2135,7 +2215,7 @@ export function createRuntime(options) {
|
|
|
2135
2215
|
response.status(500).json({ error: { code: "INTERNAL_ERROR", message: "服务器内部错误" } });
|
|
2136
2216
|
});
|
|
2137
2217
|
logger.info("runtime.ready", { serveUi: options.serveUi ?? true });
|
|
2138
|
-
return { app, database, store, ai, auth, attachmentStorage, close: () => {
|
|
2218
|
+
return { app, database, store, ai, auth, attachmentStorage, cleanupAttachments, close: () => {
|
|
2139
2219
|
logger.info("runtime.closing");
|
|
2140
2220
|
ai.dispose();
|
|
2141
2221
|
database.close();
|