@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/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: !auth.hasUsers(), registrationOpen });
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
- const removableStorageKeys = store.deleteWork(request.params.workId, input.expectedVersionNo);
949
- await Promise.all(removableStorageKeys.map((storageKey) => attachmentStorage.remove(storageKey)));
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
- const isPng = bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
965
- const isJpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
966
- const isWebp = bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP";
967
- const mimeType = isPng ? "image/png" : isJpeg ? "image/jpeg" : isWebp ? "image/webp" : null;
968
- if (!mimeType)
969
- throw new AppError(415, "INVALID_COVER", "封面文件内容不是有效的 PNG、JPEG 或 WebP 图片");
970
- const expectedVersionNo = parse(expectedVersionNoSchema, request.body.expectedVersionNo);
971
- data(response, store.setWorkCover(String(request.params.workId), mimeType, bytes, expectedVersionNo));
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
- data(response, pagination ? store.listAttachmentsPage(request.params.workId, pagination) : store.listAttachments(request.params.workId));
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 deleted = store.deleteAttachment(request.params.attachmentId);
1343
- if (deleted.removeStoredFile)
1344
- await attachmentStorage.remove(deleted.storageKey);
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
- data(response, pagination ? ai.listCallsPage(request.params.workId, pagination) : ai.listCalls(request.params.workId));
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();