@musnows/scriverse 0.7.1 → 0.7.3

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.
Files changed (49) hide show
  1. package/README.md +5 -0
  2. package/dist/ai.js +248 -250
  3. package/dist/ai.js.map +1 -1
  4. package/dist/app.js +194 -40
  5. package/dist/app.js.map +1 -1
  6. package/dist/attachment-storage.js +28 -12
  7. package/dist/attachment-storage.js.map +1 -1
  8. package/dist/backup-encryption.js +129 -0
  9. package/dist/backup-encryption.js.map +1 -0
  10. package/dist/collaboration-presence.js +200 -18
  11. package/dist/collaboration-presence.js.map +1 -1
  12. package/dist/database.js +215 -4
  13. package/dist/database.js.map +1 -1
  14. package/dist/docx-export.js +1 -1
  15. package/dist/docx-export.js.map +1 -1
  16. package/dist/domain.js +18 -0
  17. package/dist/domain.js.map +1 -1
  18. package/dist/image-metadata.js +17 -2
  19. package/dist/image-metadata.js.map +1 -1
  20. package/dist/presence-store.js +160 -0
  21. package/dist/presence-store.js.map +1 -0
  22. package/dist/public/ai-context-meter.js +27 -0
  23. package/dist/public/ai-mentions.js +14 -0
  24. package/dist/public/app.js +1018 -292
  25. package/dist/public/background-task-center.js +1 -1
  26. package/dist/public/chapter-editor-virtualization.js +52 -0
  27. package/dist/public/index.html +44 -22
  28. package/dist/public/presence-client-id.d.ts +10 -0
  29. package/dist/public/presence-client-id.js +31 -0
  30. package/dist/public/relationship-graph.js +24 -4
  31. package/dist/public/s3-backup-ui.d.ts +10 -0
  32. package/dist/public/s3-backup-ui.js +29 -0
  33. package/dist/public/setting-filters.d.ts +16 -0
  34. package/dist/public/setting-filters.js +19 -0
  35. package/dist/public/styles.css +39 -9
  36. package/dist/s3-backup.js +197 -12
  37. package/dist/s3-backup.js.map +1 -1
  38. package/dist/security.js +82 -20
  39. package/dist/security.js.map +1 -1
  40. package/dist/server-runtime.js +36 -18
  41. package/dist/server-runtime.js.map +1 -1
  42. package/dist/store.js +77 -18
  43. package/dist/store.js.map +1 -1
  44. package/dist/upload-limits.js +35 -0
  45. package/dist/upload-limits.js.map +1 -0
  46. package/dist/user-auth.js +11 -1
  47. package/dist/user-auth.js.map +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +3 -2
@@ -65,7 +65,7 @@
65
65
  --font-cjk: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC";
66
66
  --ui-font-size: 14px;
67
67
  --ui-font-scale: 1;
68
- --ai-font-size: 14px;
68
+ --ai-font-size: 12px;
69
69
  --ai-font-scale: 1;
70
70
  --editor-font-size: 17px;
71
71
  --editor-line-height: 1.55;
@@ -796,6 +796,17 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
796
796
  }
797
797
  .dialog.s3-backup-dialog { width: min(900px, 94vw); }
798
798
  .s3-backup-body { display: grid; gap: 18px; max-height: calc(88vh - 86px); overflow-y: auto; padding: 18px 24px 24px; }
799
+ .s3-backup-encryption { display: grid; gap: 12px; min-width: 0; margin: 0; padding: 15px 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
800
+ .s3-backup-encryption legend { padding: 0 6px; color: var(--ink); font-size: 13px; font-weight: 600; }
801
+ .s3-backup-encryption-control { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
802
+ .s3-backup-encryption-control > label { display: flex; align-items: flex-start; gap: 9px; min-width: 0; color: var(--ink); cursor: pointer; }
803
+ .s3-backup-encryption-control input { flex: 0 0 auto; width: 17px; height: 17px; margin-top: 1px; }
804
+ .s3-backup-encryption-control label > span { display: grid; gap: 4px; min-width: 0; }
805
+ .s3-backup-encryption-control strong { font-size: 12px; font-weight: 600; }
806
+ .s3-backup-encryption-control small { color: var(--muted); font-size: 10px; line-height: 1.55; }
807
+ .s3-backup-encryption-warning { padding: 10px 12px; border: 1px solid color-mix(in srgb, #bf6a35 48%, var(--line)); border-radius: 5px; background: color-mix(in srgb, #bf6a35 10%, var(--surface)); }
808
+ .s3-backup-encryption-warning strong { color: var(--ink); font-size: 11px; }
809
+ .s3-backup-encryption-warning p { margin: 3px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
799
810
  .s3-backup-overview { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 15px 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
800
811
  .s3-backup-overview h3, .s3-backup-history h3 { margin: 0; font-size: 15px; font-weight: 600; }
801
812
  .s3-backup-overview p, .s3-backup-history header p { margin: 4px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
@@ -841,6 +852,12 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
841
852
  .s3-backup-options span { display: grid; gap: 3px; }
842
853
  .s3-backup-options strong { font-size: 10px; font-weight: 600; }
843
854
  .s3-backup-options small { color: var(--muted); font-size: 9px; line-height: 1.45; }
855
+ .dialog.s3-backup-key-dialog { width: min(620px, 94vw); }
856
+ .s3-backup-key-body { display: grid; gap: 14px; padding: 18px 24px 4px; }
857
+ .s3-backup-key-body > label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
858
+ .s3-backup-key-body textarea { width: 100%; min-height: 82px; resize: none; padding: 10px 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); color: var(--ink); font: 12px/1.6 var(--font-latin), monospace; overflow-wrap: anywhere; }
859
+ .s3-backup-key-actions { display: flex; flex-wrap: wrap; gap: 8px; }
860
+ .s3-backup-key-actions button { min-height: 34px; }
844
861
  .member-permission-form { display: grid; gap: 12px; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
845
862
  .member-person-field { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
846
863
  .member-person-field small, .member-permission-help { color: var(--muted); font-size: 10px; line-height: 1.5; }
@@ -1415,6 +1432,10 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1415
1432
  .draft-type-filter-field { display: grid; min-width: 0; }
1416
1433
  .draft-type-filter-field > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1417
1434
  .draft-type-filter-field select { width: 100%; min-width: 0; min-height: 38px; padding: 8px 10px; background: var(--surface); color: var(--ink); font-size: 11px; }
1435
+ .setting-filter-toolbar { grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(0, .8fr)) auto; }
1436
+ .setting-filter-field { display: grid; min-width: 0; }
1437
+ .setting-filter-field > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
1438
+ .setting-filter-field input, .setting-filter-field select { width: 100%; min-width: 0; min-height: 38px; padding: 8px 10px; background: var(--surface); color: var(--ink); font-size: 11px; }
1418
1439
  .draft-binding-field { min-width: 0; }
1419
1440
  .draft-binding-field > label { display: grid; gap: 7px; }
1420
1441
  .character-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@@ -1615,13 +1636,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1615
1636
  }
1616
1637
  .task-status-badge.is-running .task-status-indicator::after { animation: task-status-running-ripple 1.8s ease-out infinite; }
1617
1638
  .task-status-badge.is-review, .task-status-badge.is-completed { --task-status-color: var(--green); }
1618
- .task-status-badge.is-partial { --task-status-color: var(--accent); }
1639
+ .task-status-badge.is-partial, .task-status-badge.is-failed { --task-status-color: var(--accent); }
1619
1640
  .task-status-badge.is-expired { --task-status-color: color-mix(in srgb, var(--muted) 72%, var(--accent)); border-style: dashed; }
1620
1641
  .task-status-badge.is-cancelled, .task-status-badge.is-unknown { --task-status-color: var(--muted); }
1621
1642
  .task-status-badge.is-cancelled .task-status-indicator, .task-status-badge.is-unknown .task-status-indicator { background: transparent; border: 1px solid currentColor; box-shadow: none; }
1622
1643
  .task-status-badge.is-state-change { animation: task-status-enter .42s cubic-bezier(.22, .8, .32, 1) both; }
1623
1644
  .task-status-badge.is-running.is-state-change { animation: task-status-enter .42s cubic-bezier(.22, .8, .32, 1) both, task-status-running-breathe 1.8s ease-in-out .42s infinite; }
1624
- .task-status-badge.is-partial.is-state-change { animation: task-status-attention .46s ease-out both; }
1645
+ .task-status-badge.is-partial.is-state-change, .task-status-badge.is-failed.is-state-change { animation: task-status-attention .46s ease-out both; }
1625
1646
  .task-progress-cell { width: 128px; }
1626
1647
  .task-progress { display: grid; grid-template-columns: 36px minmax(58px, 1fr); align-items: center; gap: 8px; min-width: 102px; }
1627
1648
  .task-progress-value { color: var(--ink); font-family: var(--font-latin), monospace; font-size: 11px; font-variant-numeric: tabular-nums; font-weight: 600; }
@@ -1629,7 +1650,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1629
1650
  .task-progress-fill { position: relative; display: block; width: var(--task-progress); height: 100%; overflow: hidden; border-radius: inherit; background: var(--muted); transition: width .35s ease; }
1630
1651
  .task-progress.is-running .task-progress-fill { background: var(--task-running); }
1631
1652
  .task-progress.is-review .task-progress-fill, .task-progress.is-completed .task-progress-fill { background: var(--green); }
1632
- .task-progress.is-partial .task-progress-fill { background: var(--accent); }
1653
+ .task-progress.is-partial .task-progress-fill, .task-progress.is-failed .task-progress-fill { background: var(--accent); }
1633
1654
  .task-progress.is-running .task-progress-fill::after {
1634
1655
  content: "";
1635
1656
  position: absolute;
@@ -1963,7 +1984,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1963
1984
  .mind-edge-detail { position: absolute; z-index: 5; left: 16px; right: 16px; bottom: 14px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 10px 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-strong); box-shadow: var(--shadow); font-size: 11px; }
1964
1985
  .mind-edge-detail b { white-space: nowrap; }.mind-edge-detail span { color: var(--muted); line-height: 1.55; }
1965
1986
  .relationship-empty-note { margin-top: -12px; padding: 12px 14px; border: 1px dashed var(--line); color: var(--muted); font-size: 11px; }
1966
- .relationship-map-dialog { position: relative; width: min(1400px, 96vw); max-width: none; height: min(900px, 92vh); max-height: none; padding: 0; border: 1px solid var(--line); border-radius: 7px; background: var(--panel); box-shadow: var(--shadow); }
1987
+ .relationship-map-dialog { position: fixed; inset: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; border-radius: 0; background: var(--panel); box-shadow: none; }
1967
1988
  .relationship-map-dialog::backdrop { background: rgba(34,30,25,.52); backdrop-filter: blur(3px); }
1968
1989
  .relationship-map-floating-close { position: absolute; z-index: 12; top: 25px; right: 25px; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 50%; background: var(--surface-strong); color: var(--ink); font-size: 20px; }
1969
1990
  .relationship-map-expanded-host { height: 100%; padding: 18px; }
@@ -2194,7 +2215,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2194
2215
  .galaxy-graph { z-index: 2; }.galaxy-node-layer { z-index: 3; pointer-events: none; }
2195
2216
  .galaxy-node { --node-size: 12px; position: absolute; left: 0; top: 0; display: grid; grid-template-rows: var(--node-size) auto; justify-items: center; align-items: start; gap: 5px; padding: 8px; border: 0; background: transparent; color: #edf7ff; cursor: grab; touch-action: none; pointer-events: auto; transform-origin: center; opacity: var(--depth-opacity, 1); }
2196
2217
  .galaxy-node.is-dragging, .galaxy-node.is-selected, .galaxy-node.is-edge-endpoint { will-change: transform, opacity; }
2197
- .galaxy-node i { position: relative; isolation: isolate; width: var(--node-size); height: var(--node-size); border-radius: 50%; background: radial-gradient(circle at 32% 27%, var(--node-core, #fff) 0 7%, color-mix(in srgb, var(--node-color) 62%, #fff) 18%, var(--node-color) 52%, var(--node-rim, #07101f) 100%); box-shadow: inset -3px -4px 7px color-mix(in srgb, var(--node-rim, #07101f) 74%, transparent), 0 0 4px rgba(255,255,255,.82), 0 0 calc(7px + var(--node-glow) * 11px) var(--node-color), 0 0 calc(15px + var(--node-glow) * 25px) var(--node-atmosphere, color-mix(in srgb, var(--node-color) 72%, transparent)); filter: brightness(var(--node-brightness)); transition: filter .18s ease, box-shadow .18s ease; }
2218
+ .galaxy-node i { position: relative; isolation: isolate; width: var(--node-size); height: var(--node-size); border-radius: 50%; background: radial-gradient(circle at 32% 27%, var(--node-core, #fff) 0 7%, color-mix(in srgb, var(--node-color) 62%, #fff) 18%, var(--node-color) 52%, var(--node-rim, #07101f) 100%); box-shadow: inset -3px -4px 7px color-mix(in srgb, var(--node-rim, #07101f) 74%, transparent), 0 0 4px rgba(255,255,255,.82), 0 0 calc(7px + var(--node-glow) * 11px) var(--node-color), 0 0 calc(15px + var(--node-glow) * 25px) var(--node-atmosphere, color-mix(in srgb, var(--node-color) 72%, transparent)); filter: brightness(var(--node-brightness)); transform: scale(var(--node-marker-scale, 1)); transform-origin: center; transition: filter .18s ease, box-shadow .18s ease; }
2198
2219
  .galaxy-node i::before, .galaxy-node i::after { content: ""; position: absolute; pointer-events: none; }
2199
2220
  .galaxy-node i::before { z-index: -1; inset: -34%; border-radius: 50%; background: radial-gradient(circle, var(--node-atmosphere) 0, transparent 68%); opacity: .64; }
2200
2221
  .galaxy-node[data-celestial-type="star"] i { background: radial-gradient(circle at 38% 34%, #fff 0 10%, var(--node-core) 22%, var(--node-color) 53%, var(--node-rim) 100%); box-shadow: 0 0 5px #fff, 0 0 calc(10px + var(--node-glow) * 15px) var(--node-color), 0 0 calc(24px + var(--node-glow) * 34px) var(--node-atmosphere); }
@@ -2206,7 +2227,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2206
2227
  .galaxy-node[data-celestial-type="volcanic"] i { background: radial-gradient(circle at 66% 61%, #ffd37a 0 3%, #ff7438 5%, transparent 9%), radial-gradient(circle at 37% 72%, #ff9b48 0 3%, transparent 8%), radial-gradient(circle at 30% 25%, var(--node-core) 0 5%, var(--node-color) 38%, var(--node-rim) 100%); }
2207
2228
  .galaxy-node[data-celestial-type="rocky"] i { background: radial-gradient(circle at 67% 31%, color-mix(in srgb, var(--node-rim) 74%, transparent) 0 8%, transparent 10%), radial-gradient(circle at 34% 68%, color-mix(in srgb, var(--node-rim) 58%, transparent) 0 11%, transparent 13%), radial-gradient(circle at 30% 26%, var(--node-core) 0 5%, var(--node-color) 45%, var(--node-rim) 100%); }
2208
2229
  .galaxy-node[data-celestial-type="dwarf"] i { box-shadow: inset -2px -3px 5px var(--node-rim), 0 0 3px rgba(255,255,255,.65), 0 0 calc(5px + var(--node-glow) * 8px) var(--node-atmosphere); }
2209
- .galaxy-node span { opacity: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 6px; border-radius: 4px; background: rgba(5,7,13,.68); font-size: 10px; text-align: center; text-shadow: 0 1px 3px #000; transition: opacity .14s ease; }
2230
+ .galaxy-node span { opacity: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 6px; border-radius: 4px; background: rgba(5,7,13,.68); font-size: 10px; text-align: center; text-shadow: 0 1px 3px #000; transform: translate3d(0, var(--node-label-offset, 0px), 0); transition: opacity .14s ease; }
2210
2231
  .galaxy-node.show-label span, .galaxy-node:hover span, .galaxy-node:focus-visible span, .galaxy-node.is-selected span, .galaxy-node.is-related span, .galaxy-node.is-edge-endpoint span { opacity: 1; }.galaxy-node.is-selected i { background: #fff4bd; box-shadow: 0 0 5px #fff, 0 0 18px #ffc86b, 0 0 42px rgba(255,173,66,.82); }.galaxy-node.is-related i { background: #d8f4ff; box-shadow: 0 0 4px #fff, 0 0 15px #438cff, 0 0 28px rgba(67,140,255,.56); }.galaxy-node.is-edge-endpoint i { background: #fff; box-shadow: 0 0 6px #fff, 0 0 22px #6fcaff, 0 0 48px rgba(67,140,255,.92); }.galaxy-node.is-dimmed { opacity: .16; }.galaxy-node.is-selected, .galaxy-node.is-related, .galaxy-node.is-edge-endpoint { z-index: 2; }
2211
2232
  .galaxy-node.is-dragging { cursor: grabbing; z-index: 6; }
2212
2233
  .galaxy-close { position: fixed; z-index: 8; top: 22px; right: 25px; width: 42px; height: 42px; border: 1px solid rgba(255,255,255,.22); border-radius: 50%; background: rgba(5,7,13,.62); color: #fff; font-size: 25px; }
@@ -2437,7 +2458,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2437
2458
  .prompt-composer { position: relative; }
2438
2459
  .ai-mention-menu { position: absolute; z-index: 20; left: 0; right: 0; bottom: calc(100% + 6px); max-height: 230px; overflow-y: auto; padding: 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); box-shadow: 0 12px 34px rgba(49,42,32,.18); }
2439
2460
  .ai-mention-option { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 8px; width: 100%; padding: 7px 8px; border: 0; border-radius: 4px; background: transparent; text-align: left; }
2440
- .ai-mention-option:hover, .ai-mention-option:focus-visible { background: var(--paper-deep); }
2461
+ .ai-mention-option:hover, .ai-mention-option:focus-visible, .ai-mention-option.is-active { background: var(--paper-deep); }
2441
2462
  .ai-mention-option small { color: var(--accent); font-size: 8px; }
2442
2463
  .ai-mention-option strong { overflow: hidden; font-size: 10px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
2443
2464
  .ai-mention-empty { margin: 0; padding: 10px; color: var(--muted); font-size: 10px; text-align: center; }
@@ -2487,6 +2508,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2487
2508
  .ai-citation-card pre { display: none; grid-column: 1 / -1; max-height: 160px; overflow: auto; margin: 0; padding: 9px; border-top: 1px solid var(--line); background: var(--surface); font: 10px/1.55 var(--font-cjk), sans-serif; white-space: pre-wrap; }
2488
2509
  .ai-citation-card.is-expanded pre { display: block; }
2489
2510
  .message-citations { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }.message-citations span { padding: 3px 6px; border: 1px solid currentColor; border-radius: 10px; font-size: 8px; opacity: .75; }
2511
+ .user-message-mentions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; margin-top: 8px; padding-top: 7px; border-top: 1px solid color-mix(in srgb, currentColor 28%, transparent); font-size: 8px; line-height: 1.4; }
2512
+ .user-message-mentions-label { flex: 0 0 auto; opacity: .72; letter-spacing: .08em; }
2513
+ .user-message-mention { min-width: 0; max-width: 100%; padding: 2px 6px; overflow-wrap: anywhere; border: 1px solid color-mix(in srgb, currentColor 55%, transparent); border-radius: 999px; background: color-mix(in srgb, currentColor 12%, transparent); }
2490
2514
 
2491
2515
  /* Toast 使用 Popover Top Layer;必须覆盖 UA 默认居中/系统边框/Canvas 背景 */
2492
2516
  .toast-region,
@@ -2531,6 +2555,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2531
2555
  background: var(--toast-error-bg);
2532
2556
  color: var(--toast-error-fg);
2533
2557
  }
2558
+ .toast.warning { border-color: color-mix(in srgb, #f0bd78 42%, transparent); background: #65451f; color: #fff7e8; }
2534
2559
  .toast-confirmation {
2535
2560
  display: grid;
2536
2561
  gap: 8px;
@@ -2860,6 +2885,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
2860
2885
  .character-filter-toolbar { grid-template-columns: minmax(0, 1fr); }
2861
2886
  .character-filter-toolbar-actions { justify-content: flex-start; }
2862
2887
  .draft-filter-toolbar { grid-template-columns: minmax(0, 1fr); }
2888
+ .setting-filter-toolbar { grid-template-columns: minmax(0, 1fr); }
2863
2889
  }
2864
2890
  .entity-editor-open { overflow: hidden; }
2865
2891
  .preview-record-card { cursor: pointer; }
@@ -3527,7 +3553,7 @@ body { font-size: var(--ui-font-size); }
3527
3553
  .ai-context-distribution-label small, .ai-context-distribution-label strong { font-size: calc(9px * var(--ai-font-scale)); }
3528
3554
  .ai-send-button { font-size: calc(10px * var(--ai-font-scale)); }
3529
3555
  .ai-citation-main strong { font-size: calc(10px * var(--ai-font-scale)); }
3530
- .ai-citation-main small, .ai-citation-main span, .message-citations span { font-size: calc(9px * var(--ai-font-scale)); }
3556
+ .ai-citation-main small, .ai-citation-main span, .message-citations span, .user-message-mentions { font-size: calc(9px * var(--ai-font-scale)); }
3531
3557
  .ai-history-item strong { font-size: calc(14px * var(--ai-font-scale)); }
3532
3558
  .ai-history-item small { font-size: calc(10px * var(--ai-font-scale)); }
3533
3559
  .ai-tool-call-info dt, .ai-tool-call-detail h3 { font-size: calc(9px * var(--ai-font-scale)); }
@@ -3541,6 +3567,8 @@ body { font-size: var(--ui-font-size); }
3541
3567
 
3542
3568
  @media (max-width: 640px) {
3543
3569
  .s3-backup-body { gap: 14px; padding: 14px 16px 18px; }
3570
+ .s3-backup-encryption-control { align-items: stretch; flex-direction: column; }
3571
+ .s3-backup-encryption-control > .s3-backup-status { width: fit-content; }
3544
3572
  .s3-backup-overview, .s3-backup-history > header { align-items: stretch; flex-direction: column; }
3545
3573
  .s3-backup-overview-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
3546
3574
  .s3-backup-overview-actions button { width: 100%; }
@@ -3553,4 +3581,6 @@ body { font-size: var(--ui-font-size); }
3553
3581
  .s3-backup-run-card details { grid-column: 1 / -1; }
3554
3582
  .s3-backup-target-fields { grid-template-columns: minmax(0, 1fr) !important; padding-inline: 16px !important; }
3555
3583
  .s3-backup-target-fields > * { grid-column: 1 !important; }
3584
+ .s3-backup-key-body { padding-inline: 16px; }
3585
+ .s3-backup-key-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
3556
3586
  }
package/dist/s3-backup.js CHANGED
@@ -1,5 +1,10 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { Agent as HttpAgent } from "node:http";
3
+ import { Agent as HttpsAgent } from "node:https";
4
+ import { isIP } from "node:net";
2
5
  import { DeleteObjectsCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
6
+ import { NodeHttpHandler } from "@smithy/node-http-handler";
7
+ import { encryptObject, generateKek } from "./backup-encryption.js";
3
8
  import { PLATFORM_AI_WORK_ID } from "./database.js";
4
9
  import { AppError } from "./errors.js";
5
10
  import { logger, sanitizeError } from "./logger.js";
@@ -65,6 +70,12 @@ export function s3FailureServerResponse(error, secrets = []) {
65
70
  class AwsS3ObjectClient {
66
71
  client;
67
72
  constructor(connection) {
73
+ const requestHandler = connection.resolvedAddresses?.length
74
+ ? new NodeHttpHandler({
75
+ httpAgent: new HttpAgent({ keepAlive: true, maxSockets: 10, lookup: pinnedS3Lookup(connection) }),
76
+ httpsAgent: new HttpsAgent({ keepAlive: true, maxSockets: 10, lookup: pinnedS3Lookup(connection) })
77
+ })
78
+ : undefined;
68
79
  this.client = new S3Client({
69
80
  endpoint: connection.endpoint,
70
81
  region: connection.region,
@@ -75,7 +86,8 @@ class AwsS3ObjectClient {
75
86
  forcePathStyle: connection.forcePathStyle,
76
87
  maxAttempts: 3,
77
88
  requestChecksumCalculation: "WHEN_REQUIRED",
78
- responseChecksumValidation: "WHEN_REQUIRED"
89
+ responseChecksumValidation: "WHEN_REQUIRED",
90
+ ...(requestHandler ? { requestHandler } : {})
79
91
  });
80
92
  }
81
93
  async objectExists(bucket, key) {
@@ -138,6 +150,35 @@ class AwsS3ObjectClient {
138
150
  this.client.destroy();
139
151
  }
140
152
  }
153
+ function normalizedHostname(value) {
154
+ return value.replace(/^\[|\]$/gu, "").replace(/\.$/u, "").toLocaleLowerCase("en-US");
155
+ }
156
+ function pinnedS3Lookup(connection) {
157
+ const endpointHostname = normalizedHostname(new URL(connection.endpoint).hostname);
158
+ const addresses = (connection.resolvedAddresses ?? []).filter(({ address, family }) => isIP(address) === family);
159
+ return (hostname, options, callback) => {
160
+ const requestedHostname = normalizedHostname(hostname);
161
+ const expectedHostname = requestedHostname === endpointHostname
162
+ || (!connection.forcePathStyle && requestedHostname.endsWith(`.${endpointHostname}`));
163
+ const requestedFamily = options.family === 4 || options.family === 6 ? options.family : 0;
164
+ const candidates = expectedHostname
165
+ ? addresses.filter(({ family }) => requestedFamily === 0 || family === requestedFamily)
166
+ : [];
167
+ if (candidates.length === 0) {
168
+ const error = Object.assign(new Error("S3 endpoint hostname or address was not approved by the SSRF validator"), {
169
+ code: "ENOTFOUND"
170
+ });
171
+ callback(error, "", 0);
172
+ return;
173
+ }
174
+ if (options.all) {
175
+ callback(null, candidates.map(({ address, family }) => ({ address, family })));
176
+ return;
177
+ }
178
+ const selected = candidates[0];
179
+ callback(null, selected.address, selected.family);
180
+ };
181
+ }
141
182
  function requiredString(row, key) {
142
183
  const value = row[key];
143
184
  if (typeof value !== "string")
@@ -179,9 +220,11 @@ export class S3BackupManager {
179
220
  validateEndpoint;
180
221
  now;
181
222
  log;
223
+ encryptionConfirmationTtlMs;
182
224
  queuedTargetIds = new Set();
183
225
  executionChain = Promise.resolve();
184
226
  schedulerTimer = null;
227
+ pendingBackupEncryption = null;
185
228
  disposed = false;
186
229
  constructor(database, vault, store, attachmentStorage, options = {}) {
187
230
  this.database = database;
@@ -198,6 +241,10 @@ export class S3BackupManager {
198
241
  this.validateEndpoint = options.validateEndpoint;
199
242
  this.now = options.now ?? (() => new Date());
200
243
  this.log = options.logger ?? logger;
244
+ const encryptionConfirmationTtlMs = Number(options.encryptionConfirmationTtlMs ?? 10 * 60_000);
245
+ this.encryptionConfirmationTtlMs = Number.isFinite(encryptionConfirmationTtlMs) && encryptionConfirmationTtlMs > 0
246
+ ? Math.floor(encryptionConfirmationTtlMs)
247
+ : 10 * 60_000;
201
248
  }
202
249
  listTargets() {
203
250
  return this.database.all("SELECT * FROM s3_backup_targets ORDER BY sort_order, created_at, id").map((row) => this.mapTarget(row));
@@ -205,6 +252,91 @@ export class S3BackupManager {
205
252
  getTarget(targetId) {
206
253
  return this.mapTarget(this.requireTargetRow(targetId));
207
254
  }
255
+ getEncryptionState() {
256
+ const row = this.database.get("SELECT * FROM s3_backup_encryption WHERE id = 1");
257
+ if (!row)
258
+ return { enabled: false, keyConfiguredAt: null };
259
+ const keyConfigured = this.hasBackupEncryptionKey(row);
260
+ if (Number(row.enabled) === 1 && !keyConfigured) {
261
+ throw new AppError(500, "BACKUP_ENCRYPTION_STATE_INVALID", "S3 备份加密配置缺少密钥");
262
+ }
263
+ return {
264
+ enabled: Number(row.enabled) === 1,
265
+ keyConfiguredAt: keyConfigured ? requiredString(row, "created_at") : null
266
+ };
267
+ }
268
+ setEncryptionEnabled(enabled) {
269
+ if (!enabled)
270
+ this.pendingBackupEncryption = null;
271
+ return this.database.transaction(() => {
272
+ const row = this.database.get("SELECT * FROM s3_backup_encryption WHERE id = 1");
273
+ const currentEnabled = Number(row?.enabled ?? 0) === 1;
274
+ const keyConfigured = row ? this.hasBackupEncryptionKey(row) : false;
275
+ if (currentEnabled && !keyConfigured) {
276
+ throw new AppError(500, "BACKUP_ENCRYPTION_STATE_INVALID", "S3 备份加密配置缺少密钥");
277
+ }
278
+ if (currentEnabled === enabled) {
279
+ return {
280
+ enabled,
281
+ keyConfiguredAt: keyConfigured && row ? requiredString(row, "created_at") : null
282
+ };
283
+ }
284
+ const timestamp = this.now().toISOString();
285
+ if (!enabled) {
286
+ this.database.run("UPDATE s3_backup_encryption SET enabled = 0, updated_at = ? WHERE id = 1", timestamp);
287
+ this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-encryption.disabled", "s3-backup-encryption", "s3-backup-encryption");
288
+ return {
289
+ enabled: false,
290
+ keyConfiguredAt: keyConfigured && row ? requiredString(row, "created_at") : null
291
+ };
292
+ }
293
+ if (row && keyConfigured) {
294
+ this.database.run("UPDATE s3_backup_encryption SET enabled = 1, updated_at = ? WHERE id = 1", timestamp);
295
+ this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-encryption.enabled", "s3-backup-encryption", "s3-backup-encryption", { keyGenerated: false });
296
+ return { enabled: true, keyConfiguredAt: requiredString(row, "created_at") };
297
+ }
298
+ const key = generateKek();
299
+ const confirmationToken = randomBytes(32).toString("base64url");
300
+ this.pendingBackupEncryption = {
301
+ key,
302
+ confirmationTokenHash: createHash("sha256").update(confirmationToken).digest(),
303
+ expiresAtMs: this.now().getTime() + this.encryptionConfirmationTtlMs
304
+ };
305
+ return { enabled: false, keyConfiguredAt: null, key, confirmationToken };
306
+ });
307
+ }
308
+ confirmEncryptionEnabled(confirmationToken) {
309
+ const pending = this.pendingBackupEncryption;
310
+ const tokenHash = createHash("sha256").update(confirmationToken).digest();
311
+ if (!pending || pending.expiresAtMs <= this.now().getTime() || !timingSafeEqual(pending.confirmationTokenHash, tokenHash)) {
312
+ if (pending && pending.expiresAtMs <= this.now().getTime())
313
+ this.pendingBackupEncryption = null;
314
+ throw new AppError(409, "BACKUP_ENCRYPTION_CONFIRMATION_INVALID", "备份加密确认已失效,请重新开启并保存新密钥");
315
+ }
316
+ const state = this.database.transaction(() => {
317
+ const row = this.database.get("SELECT * FROM s3_backup_encryption WHERE id = 1");
318
+ const currentEnabled = Number(row?.enabled ?? 0) === 1;
319
+ const keyConfigured = row ? this.hasBackupEncryptionKey(row) : false;
320
+ if (currentEnabled || keyConfigured) {
321
+ throw new AppError(409, "BACKUP_ENCRYPTION_CONFIRMATION_INVALID", "备份加密状态已变化,请刷新后重试");
322
+ }
323
+ const timestamp = this.now().toISOString();
324
+ const encrypted = this.vault.encrypt(pending.key);
325
+ if (row) {
326
+ this.database.run(`UPDATE s3_backup_encryption SET enabled = 1, kek_encrypted = ?, kek_iv = ?, kek_tag = ?,
327
+ created_at = ?, updated_at = ? WHERE id = 1`, encrypted.encrypted, encrypted.iv, encrypted.tag, timestamp, timestamp);
328
+ }
329
+ else {
330
+ this.database.run(`INSERT INTO s3_backup_encryption (
331
+ id, enabled, kek_encrypted, kek_iv, kek_tag, created_at, updated_at
332
+ ) VALUES (1, 1, ?, ?, ?, ?, ?)`, encrypted.encrypted, encrypted.iv, encrypted.tag, timestamp, timestamp);
333
+ }
334
+ this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-encryption.enabled", "s3-backup-encryption", "s3-backup-encryption", { keyGenerated: true });
335
+ return { enabled: true, keyConfiguredAt: timestamp };
336
+ });
337
+ this.pendingBackupEncryption = null;
338
+ return state;
339
+ }
208
340
  createTarget(input) {
209
341
  const targetId = randomUUID();
210
342
  const timestamp = this.now().toISOString();
@@ -345,11 +477,31 @@ export class S3BackupManager {
345
477
  this.schedulerTimer = setInterval(poll, 30_000);
346
478
  this.schedulerTimer.unref();
347
479
  }
348
- async waitForIdle() {
349
- await this.executionChain;
480
+ async waitForIdle(timeoutMs) {
481
+ const executionChain = this.executionChain;
482
+ if (timeoutMs === undefined) {
483
+ await executionChain;
484
+ return;
485
+ }
486
+ const normalizedTimeoutMs = Math.floor(timeoutMs);
487
+ if (!Number.isFinite(timeoutMs) || normalizedTimeoutMs < 1) {
488
+ throw new RangeError("S3 backup idle timeout must be a positive finite number");
489
+ }
490
+ let timer = null;
491
+ const timeout = new Promise((_, reject) => {
492
+ timer = setTimeout(() => reject(new AppError(504, "BACKUP_IDLE_TIMEOUT", `等待 S3 备份执行链结束超时(${normalizedTimeoutMs} 毫秒)`)), normalizedTimeoutMs);
493
+ });
494
+ try {
495
+ await Promise.race([executionChain, timeout]);
496
+ }
497
+ finally {
498
+ if (timer)
499
+ clearTimeout(timer);
500
+ }
350
501
  }
351
502
  dispose() {
352
503
  this.disposed = true;
504
+ this.pendingBackupEncryption = null;
353
505
  if (this.schedulerTimer)
354
506
  clearInterval(this.schedulerTimer);
355
507
  this.schedulerTimer = null;
@@ -377,30 +529,35 @@ export class S3BackupManager {
377
529
  let imagesUploaded = 0;
378
530
  let imagesSkipped = 0;
379
531
  let databasesDeleted = 0;
532
+ let encryptionKey = null;
380
533
  try {
381
- await this.validateEndpoint?.(target.endpoint);
534
+ encryptionKey = this.activeBackupEncryptionKey();
535
+ const resolvedAddresses = await this.validateEndpoint?.(target.endpoint);
382
536
  client = this.clientFactory({
383
537
  endpoint: target.endpoint,
384
538
  region: target.region,
385
539
  accessKeyId: target.accessKeyId,
386
540
  secretAccessKey: target.secretAccessKey,
387
- forcePathStyle: target.forcePathStyle
541
+ forcePathStyle: target.forcePathStyle,
542
+ ...(resolvedAddresses?.length ? { resolvedAddresses } : {})
388
543
  });
389
544
  const databaseSnapshot = await this.snapshotDatabase();
390
545
  databaseKey = `${target.rootPrefix}/db/scriverse-${this.snapshotTimestamp(started)}-${runId.replaceAll("-", "").slice(0, 8)}.db`;
391
546
  if (!this.masterKey)
392
547
  throw new AppError(500, "BACKUP_MASTER_KEY_UNAVAILABLE", "S3 备份缺少 CredentialVault 恢复密钥");
548
+ const masterKeyBody = encryptionKey ? encryptObject(this.masterKey, encryptionKey) : this.masterKey;
393
549
  await this.withRequestTimeout(client.putObject({
394
550
  bucket: target.bucket,
395
551
  key: `${target.rootPrefix}/master.key`,
396
- body: this.masterKey,
552
+ body: masterKeyBody,
397
553
  contentType: "application/octet-stream"
398
554
  }), "上传 CredentialVault 恢复密钥");
555
+ const databaseBody = encryptionKey ? encryptObject(databaseSnapshot, encryptionKey) : databaseSnapshot;
399
556
  await this.withRequestTimeout(client.putObject({
400
557
  bucket: target.bucket,
401
558
  key: databaseKey,
402
- body: databaseSnapshot,
403
- contentType: "application/vnd.sqlite3"
559
+ body: databaseBody,
560
+ contentType: encryptionKey ? "application/octet-stream" : "application/vnd.sqlite3"
404
561
  }), "上传数据库快照");
405
562
  if (target.backupImages) {
406
563
  for (const source of this.backupImageSources(target.rootPrefix)) {
@@ -409,11 +566,12 @@ export class S3BackupManager {
409
566
  continue;
410
567
  }
411
568
  const body = await source.read();
569
+ const uploadBody = encryptionKey ? encryptObject(body, encryptionKey) : body;
412
570
  await this.withRequestTimeout(client.putObject({
413
571
  bucket: target.bucket,
414
572
  key: source.objectKey,
415
- body,
416
- contentType: source.contentType,
573
+ body: uploadBody,
574
+ contentType: encryptionKey ? "application/octet-stream" : source.contentType,
417
575
  metadata: { sha256: source.sha256 }
418
576
  }), "上传图片");
419
577
  imagesUploaded += 1;
@@ -445,7 +603,11 @@ export class S3BackupManager {
445
603
  });
446
604
  }
447
605
  catch (error) {
448
- const serverResponse = s3FailureServerResponse(error, [target.accessKeyId, target.secretAccessKey]);
606
+ const serverResponse = s3FailureServerResponse(error, [
607
+ target.accessKeyId,
608
+ target.secretAccessKey,
609
+ ...(encryptionKey ? [encryptionKey] : [])
610
+ ]);
449
611
  const errorMessage = typeof serverResponse.message === "string" ? serverResponse.message : "S3 备份失败";
450
612
  const finishedAt = this.now().toISOString();
451
613
  this.database.transaction(() => {
@@ -488,6 +650,29 @@ export class S3BackupManager {
488
650
  throw new AppError(404, "BACKUP_TARGET_NOT_FOUND", "S3 备份目标不存在");
489
651
  return row;
490
652
  }
653
+ hasBackupEncryptionKey(row) {
654
+ return typeof row.kek_encrypted === "string"
655
+ && typeof row.kek_iv === "string"
656
+ && typeof row.kek_tag === "string";
657
+ }
658
+ activeBackupEncryptionKey() {
659
+ const row = this.database.get("SELECT * FROM s3_backup_encryption WHERE id = 1");
660
+ if (!row || Number(row.enabled) !== 1)
661
+ return null;
662
+ if (!this.hasBackupEncryptionKey(row)) {
663
+ throw new AppError(500, "BACKUP_ENCRYPTION_STATE_INVALID", "S3 备份加密配置缺少密钥");
664
+ }
665
+ try {
666
+ return this.vault.decrypt({
667
+ encrypted: requiredString(row, "kek_encrypted"),
668
+ iv: requiredString(row, "kek_iv"),
669
+ tag: requiredString(row, "kek_tag")
670
+ });
671
+ }
672
+ catch {
673
+ throw new AppError(500, "BACKUP_ENCRYPTION_KEY_UNAVAILABLE", "S3 备份加密密钥无法读取");
674
+ }
675
+ }
491
676
  runtimeTarget(row) {
492
677
  return {
493
678
  ...this.mapTarget(row),