w-web-perm 1.1.11 → 1.1.13

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 (122) hide show
  1. package/README.md +158 -117
  2. package/dist/css/app.f09c7433.css +1 -0
  3. package/dist/index.tmp +1 -1
  4. package/dist/js/app-legacy.8af87052.js +1 -0
  5. package/dist/js/app.8af87052.js +1 -0
  6. package/dist/js/{chunk-vendors.3e9e3ef1.js → chunk-vendors-legacy.ece5a361.js} +1 -1
  7. package/dist/js/{chunk-vendors-legacy.3e9e3ef1.js → chunk-vendors.ece5a361.js} +1 -1
  8. package/dist/w-web-perm.umd.js +2 -2
  9. package/dist/w-web-perm.umd.js.map +1 -1
  10. package/docs/WWebPerm.html +84 -4
  11. package/docs/global.html +24747 -832
  12. package/docs/index.html +2 -2
  13. package/docs/server_WWebPerm.mjs.html +245 -32
  14. package/docs/server_cascadeWrite.mjs.html +225 -0
  15. package/docs/server_dataChannel.mjs.html +2 -2
  16. package/docs/server_keyCheck.mjs.html +353 -0
  17. package/docs/server_lockSave.mjs.html +2 -2
  18. package/docs/server_maskLog.mjs.html +2 -2
  19. package/docs/server_procCore.mjs.html +180 -41
  20. package/docs/server_staLogs_resolveShellWorker.mjs.html +2 -2
  21. package/docs/server_syncKeep.mjs.html +311 -0
  22. package/docs/server_tools_authRefsAudit.mjs.html +247 -0
  23. package/docs/server_tools_authRefsClean.mjs.html +335 -0
  24. package/docs/server_tools_fromTools.mjs.html +797 -0
  25. package/docs/server_tools_isMain.mjs.html +103 -0
  26. package/docs/server_tools_nameDupReport.mjs.html +280 -0
  27. package/docs/server_validateRows.mjs.html +15 -4
  28. package/docs/server_wrapInjected.mjs.html +3 -3
  29. package/docs/src_fetchJson.mjs.html +2 -2
  30. package/docs/src_js_cvKpToArr.mjs.html +125 -0
  31. package/docs/src_js_cvKpToObj.mjs.html +210 -0
  32. package/docs/src_js_draftSync.mjs.html +142 -0
  33. package/docs/src_js_eachOwn.mjs.html +3 -3
  34. package/docs/src_js_editorRefRows.mjs.html +356 -0
  35. package/docs/src_js_externalRows.mjs.html +338 -0
  36. package/docs/src_js_externalUi.mjs.html +230 -0
  37. package/docs/src_js_findRefRows.mjs.html +107 -0
  38. package/docs/src_js_findTouchedDup.mjs.html +150 -0
  39. package/docs/src_js_fmtLossyMsg.mjs.html +95 -0
  40. package/docs/src_js_fromAudit.mjs.html +489 -0
  41. package/docs/src_js_getAddImpact.mjs.html +269 -0
  42. package/docs/src_js_getAuthWarn.mjs.html +463 -0
  43. package/docs/src_js_getCascadePreview.mjs.html +258 -0
  44. package/docs/src_js_getDelRefCount.mjs.html +210 -0
  45. package/docs/src_js_getEffMode.mjs.html +92 -0
  46. package/docs/src_js_getKpLossy.mjs.html +155 -0
  47. package/docs/src_js_getNameErrs.mjs.html +266 -0
  48. package/docs/src_js_getOwn.mjs.html +3 -3
  49. package/docs/src_js_getPropKey.mjs.html +109 -0
  50. package/docs/src_js_getRuleEnable.mjs.html +2 -2
  51. package/docs/src_js_isAuthMode.mjs.html +92 -0
  52. package/docs/src_js_joinNames.mjs.html +96 -0
  53. package/docs/src_js_legendFit.mjs.html +2 -2
  54. package/docs/src_js_pairRowsToSnapshot.mjs.html +156 -0
  55. package/docs/src_js_refAudit.mjs.html +359 -0
  56. package/docs/src_js_refCascade.mjs.html +572 -0
  57. package/docs/src_js_relDialogSend.mjs.html +199 -0
  58. package/docs/src_js_rowDigest.mjs.html +265 -0
  59. package/docs/src_plugins_dataReloadGuard.mjs.html +109 -0
  60. package/docs/src_plugins_reuseDeletedIds.mjs.html +2 -2
  61. package/docs/src_plugins_submitGuard.mjs.html +2 -2
  62. package/g_authRefsClean.mjs +79 -0
  63. package/g_fixFromType.mjs +92 -0
  64. package/g_initialData.mjs +3 -3
  65. package/g_purgeFrom.mjs +127 -0
  66. package/package.json +1 -1
  67. package/server/WWebPerm.mjs +243 -30
  68. package/server/cascadeWrite.mjs +153 -0
  69. package/server/keyCheck.mjs +281 -0
  70. package/server/procCore.mjs +178 -39
  71. package/server/procLang.mjs +271 -0
  72. package/server/syncKeep.mjs +239 -0
  73. package/server/tools/authRefsAudit.mjs +175 -0
  74. package/server/tools/authRefsClean.mjs +263 -0
  75. package/server/tools/fromTools.mjs +725 -0
  76. package/server/tools/isMain.mjs +31 -0
  77. package/server/tools/nameDupReport.mjs +208 -0
  78. package/server/validateRows.mjs +13 -2
  79. package/server/wrapInjected.mjs +1 -1
  80. package/src/components/LayoutContentGrups.vue +399 -77
  81. package/src/components/LayoutContentPemis.vue +400 -78
  82. package/src/components/LayoutContentTargets.vue +261 -25
  83. package/src/components/LayoutContentUsers.vue +336 -98
  84. package/src/components/VeCgrups.vue +119 -115
  85. package/src/components/VeCpemis.vue +117 -115
  86. package/src/components/VeCrules.vue +18 -2
  87. package/src/components/VeGrupBlngUsers.vue +240 -73
  88. package/src/components/VePemiBlngGrups.vue +247 -83
  89. package/src/js/cvKpToArr.mjs +53 -0
  90. package/src/js/cvKpToObj.mjs +138 -0
  91. package/src/js/draftSync.mjs +70 -0
  92. package/src/js/eachOwn.mjs +1 -1
  93. package/src/js/editorRefRows.mjs +284 -0
  94. package/src/js/externalRows.mjs +266 -0
  95. package/src/js/externalUi.mjs +158 -0
  96. package/src/js/findRefRows.mjs +35 -0
  97. package/src/js/findTouchedDup.mjs +78 -0
  98. package/src/js/fmtLossyMsg.mjs +23 -0
  99. package/src/js/fromAudit.mjs +417 -0
  100. package/src/js/getAddImpact.mjs +197 -0
  101. package/src/js/getAuthWarn.mjs +391 -0
  102. package/src/js/getCascadePreview.mjs +186 -0
  103. package/src/js/getDelRefCount.mjs +138 -0
  104. package/src/js/getEffMode.mjs +20 -0
  105. package/src/js/getKpLossy.mjs +83 -0
  106. package/src/js/getNameErrs.mjs +194 -0
  107. package/src/js/getOwn.mjs +1 -1
  108. package/src/js/getPropKey.mjs +37 -0
  109. package/src/js/isAuthMode.mjs +20 -0
  110. package/src/js/joinNames.mjs +24 -0
  111. package/src/js/pairRowsToSnapshot.mjs +84 -0
  112. package/src/js/refAudit.mjs +287 -0
  113. package/src/js/refCascade.mjs +500 -0
  114. package/src/js/relDialogSend.mjs +127 -0
  115. package/src/js/rowDigest.mjs +193 -0
  116. package/src/plugins/dataReloadGuard.mjs +37 -0
  117. package/src/plugins/mShare.mjs +147 -174
  118. package/src/plugins/mUI.mjs +3 -8
  119. package/src/schema/tables/users.mjs +152 -152
  120. package/dist/css/app.6918ec45.css +0 -1
  121. package/dist/js/app-legacy.963e800c.js +0 -1
  122. package/dist/js/app.963e800c.js +0 -1
@@ -0,0 +1,175 @@
1
+ //懸空引用之離線稽核工具(適用表 n 上線後之升版前盤點; 規則帳本 R-REF-CASCADE-01 之配套; 不屬測試, 只讀匯出檔、不寫庫).
2
+ //
3
+ //用法(自專案根; 套件安裝後此檔位於 node_modules/w-web-perm/server/tools/, 以該路徑執行即可, 匯出檔路徑相對於目前工作目錄):
4
+ // node server/tools/authRefsAudit.mjs <匯出檔.json> [--json] [--clean-out <檔>]
5
+ //
6
+ // 匯出檔內容 { users, grups, pemis, targets }(各為列陣列, 如以資料通道 select 之結果或 g_backupDb.mjs 之備份整理而得)。
7
+ // --json 輸出結構化結果 { items, counts, changes, badJson, unnamed, unsafe, noId, widen }(供其他工具), 省略則輸出文字報告.
8
+ // --clean-out 把「清理後之引用端列」(僅變更者之主鍵 id 與新字串)寫成 json 供審閱: { users: [{ id, cgrups }], grups: [{ id, cpemis }], pemis: [{ id, crules }] }.
9
+ //
10
+ //報告內容:
11
+ // 1. 懸空引用清單(users.cgrups 引用不存在之群組名、grups.cpemis 引用不存在之權限名、pemis.crules 引用不存在之管控對象 id): 逐引用端列出被引用之名稱、isActive、mode 與對授權之影響.
12
+ // 2. 清理後授權可能變多之使用者/群組數(啟用之 AND 懸空項使結果為空, 清理後限制解除; 含「唯一 OR 項」與經群組傳導之情形).
13
+ // 3. 壞 JSON(解析失敗, 視為無引用, 不處理)清單、名稱非字串之引用(不處理, 須人工)清單.
14
+ // 4. 建議處置步驟.
15
+ // 不回顯 email/name 等個資(只列主鍵 id 與被引用之名稱).
16
+ //
17
+ //清理由 g_authRefsClean.mjs 執行(預設只列不改, 加 --apply 才寫入; 寫入前備份); 解析與改寫邏輯同 src/js/refAudit.mjs(改寫經 src/js/refCascade.mjs 之 rewriteRefs).
18
+
19
+ import fs from 'fs'
20
+ import path from 'path'
21
+ import isMain from './isMain.mjs'
22
+ import auditRefs, { EFFECT } from '../../src/js/refAudit.mjs'
23
+
24
+
25
+ const TABLE_LABEL = { users: 'users.cgrups(引用群組名)', grups: 'grups.cpemis(引用權限名)', pemis: 'pemis.crules(引用管控對象 id)' }
26
+ const TARGET_LABEL = { users: '群組', grups: '權限', pemis: '管控對象' }
27
+ const BAD_TXT = { parse: 'JSON5 解析失敗', notContainer: '解析結果非物件/陣列', decode: '解碼失敗' }
28
+ const MAX_HOLDERS = 500 //文字報告每表最多列出之引用端列數(完整請用 --json)
29
+
30
+
31
+ let arr = (v) => (Array.isArray(v) ? v : [])
32
+ let j = (v) => JSON.stringify(v)
33
+
34
+
35
+ /**
36
+ * 建立稽核結果(純函數, 不讀寫檔案): auditRefs 之結果加上各表筆數
37
+ *
38
+ * @param {Object} data { users, grups, pemis, targets }
39
+ * @returns {Object} 稽核結果
40
+ */
41
+ function buildReport(data) {
42
+ let d = (data !== null && typeof data === 'object') ? data : {}
43
+ let rp = auditRefs(d)
44
+ return {
45
+ rows: { users: arr(d.users).length, grups: arr(d.grups).length, pemis: arr(d.pemis).length, targets: arr(d.targets).length },
46
+ ...rp,
47
+ }
48
+ }
49
+
50
+
51
+ /**
52
+ * 清理後之引用端列(供 --clean-out): 僅變更者之主鍵 id 與新字串, 即 ORM save 之輸入形狀
53
+ *
54
+ * @param {Array} changes auditRefs 之 changes
55
+ * @returns {Object} { users: [{ id, cgrups }], grups: [{ id, cpemis }], pemis: [{ id, crules }] }
56
+ */
57
+ function cleanOutOf(changes) {
58
+ let out = { users: [], grups: [], pemis: [] }
59
+ for (let c of arr(changes)) {
60
+ out[c.table].push({ id: c.id, [c.field]: c.after })
61
+ }
62
+ return out
63
+ }
64
+
65
+
66
+ //formatReport: 結果轉文字
67
+ function formatReport(rp, opt = {}) {
68
+ let maxHolders = Number.isInteger(opt.maxHolders) ? opt.maxHolders : MAX_HOLDERS
69
+ let c = rp.counts
70
+ let L = []
71
+ L.push(`資料筆數: users ${rp.rows.users}、grups ${rp.rows.grups}、pemis ${rp.rows.pemis}、targets ${rp.rows.targets}`)
72
+ L.push('')
73
+ L.push(`== 懸空引用(引用之名稱查無實體): 共 ${c.nItems} 項, 涉及引用端 ${c.byTable.users.nHolders + c.byTable.grups.nHolders + c.byTable.pemis.nHolders} 列; 清理後會改寫 ${c.nChanges} 列 ==`)
74
+ for (let t of ['users', 'grups', 'pemis']) {
75
+ let its = rp.items.filter((x) => x.table === t)
76
+ let bt = c.byTable[t]
77
+ L.push(`-- ${TABLE_LABEL[t]}: ${bt.nItems} 項, ${bt.nHolders} 列`)
78
+ let byHolder = new Map()
79
+ for (let x of its) {
80
+ if (!byHolder.has(x.holderId)) {
81
+ byHolder.set(x.holderId, [])
82
+ }
83
+ byHolder.get(x.holderId).push(x)
84
+ }
85
+ let n = 0
86
+ for (let [id, xs] of byHolder) {
87
+ if (n++ >= maxHolders) {
88
+ L.push(` …(其餘 ${byHolder.size - maxHolders} 列從略, 完整清單請加 --json)`)
89
+ break
90
+ }
91
+ let txt = xs.map((x) => `${j(x.name)}${t === 'pemis' ? '' : `(${x.isActive === 'y' ? '啟用' : '停用'}, mode=${j(x.mode)})`}`).join('、')
92
+ L.push(` ${t} id=${id}: ${txt}`)
93
+ }
94
+ }
95
+ L.push('')
96
+ L.push(`== 對授權之影響(ref 類之 cgrups/cpemis; crules 懸空項不影響授權) ==`)
97
+ L.push(`- 啟用之懸空項 ${c.nActive} 項(其中有效模式為 AND 者 ${c.nAndActive} 項、OR 者 ${c.nOrActive} 項), 停用之懸空項 ${c.nInactive} 項`)
98
+ L.push(`- 擁有「啟用 AND 懸空項」之使用者 ${c.byTable.users.nAndActiveHolders} 位、群組 ${c.byTable.grups.nAndActiveHolders} 個: 現況其授權(該層合併結果)被收嚴為空; 清理後 AND 限制解除, 授權可能變多`)
99
+ L.push(`- 清理後授權可能變多(含唯一 OR 懸空項而同層有合法 AND 項、經群組傳導者): 使用者 ${c.nWidenUsers} 位、群組 ${c.nWidenGrups} 個`)
100
+ for (let w of rp.widen.slice(0, maxHolders)) {
101
+ L.push(` ${w.table} id=${w.holderId}${w.via === 'grups' ? '(經其啟用引用之群組傳導)' : ''}`)
102
+ }
103
+ if (rp.widen.length > maxHolders) {
104
+ L.push(` …(其餘 ${rp.widen.length - maxHolders} 筆從略, 完整清單請加 --json)`)
105
+ }
106
+ L.push('- 影響說明:')
107
+ L.push(` AND: ${EFFECT.and}`)
108
+ L.push(` OR: ${EFFECT.or}`)
109
+ L.push(` 停用: ${EFFECT.inactive}`)
110
+ L.push(` crules: ${EFFECT.rule}`)
111
+ L.push('')
112
+ L.push(`== 授權字串壞 JSON(解析失敗等, 視為無引用, 清理時不處理): ${rp.badJson.length} 列 ==`)
113
+ rp.badJson.forEach((b) => L.push(`- ${b.table} id=${b.id} 欄位 ${b.field}: ${BAD_TXT[b.reason] || b.reason}`))
114
+ L.push('')
115
+ L.push(`== 名稱非字串之引用(陣列元素無 name 等, 不對應任何${TARGET_LABEL.users}/${TARGET_LABEL.grups}名, 清理時不處理, 須人工於後台修正): ${rp.unnamed.length} 項 ==`)
116
+ rp.unnamed.slice(0, maxHolders).forEach((x) => L.push(`- ${x.table} id=${x.holderId} 欄位 ${x.field} isActive=${j(x.isActive)} mode=${j(x.mode)}`))
117
+ if (rp.unsafe && rp.unsafe.length > 0) {
118
+ L.push(`== 含無法安全重新編碼之內容(JSON5 Infinity/NaN 數值)而不清理之授權欄位, 須人工處理: ${rp.unsafe.length} 項 ==`)
119
+ rp.unsafe.slice(0, maxHolders).forEach((x) => L.push(`- ${x.table} id=${x.id} 欄位 ${x.field}`))
120
+ }
121
+ if (rp.noId.length > 0) {
122
+ L.push('')
123
+ L.push(`== 缺字串主鍵而無從寫回之引用端列: ${rp.noId.length} 列(不清理) ==`)
124
+ rp.noId.forEach((x) => L.push(`- ${x.table} id=${j(x.holderId)} 欄位 ${x.field}: ${x.nItems} 項懸空`))
125
+ }
126
+ L.push('')
127
+ L.push('== 建議處置步驟 ==')
128
+ L.push('1. 先確認本報告「對授權之影響」: 啟用 AND 懸空項若本意為限制(例如曾刪除群組/權限而期望該使用者從此無授權), 清理會使其授權變多; 此類請先於後台改正名稱(或停用該引用)再清理.')
129
+ L.push('2. 預設只列不改: node g_authRefsClean.mjs(連專案資料庫, 與本報告同一判準); 確認清單後再加 --apply 寫入(寫入前自動備份資料庫與將改寫之三張引用端表, 任一備份失敗即中止).')
130
+ L.push('3. 建議於停服(或無人編輯)時執行 --apply, 執行後重啟服務(避免伺服端快取沿用舊字串), 並以 getPerm/getPermUserInfor 抽查數位使用者之授權.')
131
+ L.push('4. 寫入後工具會重新稽核, 應為 0 懸空; 壞 JSON 與名稱非字串者不受清理影響, 須另行處理.')
132
+ return L.join('\n')
133
+ }
134
+
135
+
136
+ //CLI
137
+ if (isMain(import.meta.url)) {
138
+ let args = process.argv.slice(2)
139
+ let iOut = args.indexOf('--clean-out')
140
+ let cleanOut = (iOut >= 0) ? args[iOut + 1] : undefined
141
+ let fp = args.find((a, k) => !a.startsWith('--') && !(iOut >= 0 && k === iOut + 1))
142
+ if (!fp || (iOut >= 0 && (cleanOut === undefined || cleanOut.startsWith('--')))) {
143
+ console.log('用法: node server/tools/authRefsAudit.mjs <匯出檔.json> [--json] [--clean-out <檔>]')
144
+ process.exit(2)
145
+ }
146
+ //輸出不得覆寫輸入(同一檔; 以解析後之絕對路徑與已存在檔之 inode 判定): --clean-out 只含變更列之 id 與引用欄位, 覆寫匯出檔會使其遺失其餘列與欄位
147
+ if (cleanOut !== undefined) {
148
+ let same = path.resolve(fp).toLowerCase() === path.resolve(cleanOut).toLowerCase()
149
+ if (!same && fs.existsSync(cleanOut) && fs.existsSync(fp)) {
150
+ let a = fs.statSync(fp)
151
+ let b = fs.statSync(cleanOut)
152
+ same = a.ino !== 0 && a.ino === b.ino && a.dev === b.dev
153
+ }
154
+ if (same) {
155
+ console.error('失敗: --clean-out 不得與輸入匯出檔為同一檔(會覆寫原匯出檔)')
156
+ process.exit(2)
157
+ }
158
+ }
159
+ try {
160
+ let data = JSON.parse(fs.readFileSync(fp, 'utf8'))
161
+ let rp = buildReport(data)
162
+ console.log(args.includes('--json') ? JSON.stringify(rp, null, 1) : formatReport(rp))
163
+ if (cleanOut !== undefined) {
164
+ fs.writeFileSync(cleanOut, JSON.stringify(cleanOutOf(rp.changes), null, 1), 'utf8')
165
+ console.error(`已寫入清理後之引用端列(僅變更者): ${cleanOut}`) //stderr: 不污染 --json 之 stdout
166
+ }
167
+ }
168
+ catch (err) {
169
+ console.error(`失敗: ${err && err.message ? err.message : err}`)
170
+ process.exitCode = 1
171
+ }
172
+ }
173
+
174
+
175
+ export { buildReport, formatReport, cleanOutOf }
@@ -0,0 +1,263 @@
1
+ //懸空引用之一次性清理(升版工具; 規則帳本 R-REF-CASCADE-01 之配套): 稽核 users.cgrups/grups.cpemis/pemis.crules 中查無實體之引用, 預設只列不改, apply 時才寫入.
2
+ //由專案根之 g_authRefsClean.mjs 呼叫(連專案資料庫); 邏輯抽成可注入 woItems 之函數, 供單元測試以臨時資料庫驗證(test/unit-refAudit.test.mjs).
3
+ //
4
+ //apply 之順序(任一步失敗即中止, 其後之步驟不執行; 備份失敗時零寫入):
5
+ // 1. 讀取四表並稽核(src/js/refAudit.mjs); 無須清理者不備份、不寫入
6
+ // 2. (a) 備份整個資料庫(getDb.backup(woItems), 落點為模組載入時之工作目錄之 backup-時間.json, 同 g_backupDb.mjs; 可由 backupDb 注入)
7
+ // 3. (b) 另匯出將被改寫之三張引用端表(users/grups/pemis)全部列(並附 targets 供稽核重現)為 json(backupPath; 不覆寫既有檔), 寫後讀回驗證
8
+ // 4. 重新讀取引用端表, 逐筆確認 before 仍等於庫內現值(稽核後被他人改動者中止, 免以過期資料覆蓋)
9
+ // 5. 寫入: woItems[表].save([{ id, [欄位]: 新字串 }], { autoInsert: false })——只改該欄位, 列已不存在時不得補出只有一欄之殘列; 逐筆結果 ok 非 1、n 為 0 或有 nInserted 者視為失敗並列出
10
+ // 6. 重新讀取並稽核: 懸空須為 0、變更之列只有該欄位改變、其餘列逐位元不變, 否則視為失敗
11
+ // 例外(已知殘留, 非失敗): 含無法安全重新編碼之內容(JSON5 Infinity/NaN, 重新編碼會變成 null 而改指別的規則名; 見 refCascade.rewriteRefs 之 unsafe)之授權欄位不清理,
12
+ // 其懸空項目寫前寫後皆在, 逐筆須與寫前相同(多或少即失敗), 並於報告列為「須人工處理」(res.unsafe/res.residue)
13
+ //備份檔路徑: 以 flag wx 建立(同名既有檔、同一檔之別名——大小寫不同、相對/絕對、硬連結——一律拒絕、不覆寫, 失敗於 backupRows 階段、零寫入)
14
+ //備份檔含 email 等個資, 請妥善保管、勿外傳、勿納入版控.
15
+
16
+ import fs from 'fs'
17
+ import path from 'path'
18
+ import { isDeepStrictEqual } from 'util'
19
+ import getDb from 'w-serv-orm/src/getDb.mjs'
20
+ import now2strp from 'wsemi/src/now2strp.mjs'
21
+ import { buildReport, formatReport } from './authRefsAudit.mjs'
22
+ import { isJsonLossy } from './fromTools.mjs'
23
+
24
+
25
+ const TABLES = ['users', 'grups', 'pemis', 'targets']
26
+ const HOLDERS = ['users', 'grups', 'pemis']
27
+
28
+
29
+ let arr = (v) => (Array.isArray(v) ? v : [])
30
+ let msgOf = (err) => (err && err.message ? err.message : String(err))
31
+ let byId = (rows) => new Map(arr(rows).map((r) => [r.id, r]))
32
+
33
+
34
+ //readTables: 讀取四表全部列
35
+ async function readTables(woItems) {
36
+ let rows = {}
37
+ for (let t of TABLES) {
38
+ let rs = await woItems[t].select()
39
+ if (!Array.isArray(rs)) {
40
+ throw new Error(`select ${t} did not return an array`)
41
+ }
42
+ rows[t] = rs
43
+ }
44
+ return rows
45
+ }
46
+
47
+
48
+ /**
49
+ * 稽核並(apply 時)清理懸空引用
50
+ *
51
+ * @param {Object} p 輸入參數物件
52
+ * @param {Object} p.woItems 輸入 ORM 資料表物件(users、grups、pemis、targets 各有 select/save)
53
+ * @param {Boolean} [p.apply=false] 輸入是否寫入, 預設 false(只列不改)
54
+ * @param {String} [p.backupPath] 輸入引用端表之備份檔路徑, 預設 ./authRefsClean-backup-<時間戳>.json(相對工作目錄)
55
+ * @param {Function} [p.backupDb] 輸入資料庫備份函數(回傳 Promise, reject 表失敗), 預設 getDb.backup(woItems)
56
+ * @param {Function} [p.log] 輸入輸出文字之函數, 預設 console.log
57
+ * @returns {Promise<Object>} 回傳 { ok, apply, stage, err, audit, auditAfter, dbBackup, backupPath, written, failed, stale }; ok 為 false 時 stage 為失敗之階段(check/select/backupFidelity/backupDb/backupRows/stale/write/verify), err 為訊息; 不 reject
58
+ */
59
+ async function runClean(p = {}) {
60
+ let woItems = p.woItems
61
+ let apply = p.apply === true
62
+ let log = typeof p.log === 'function' ? p.log : console.log
63
+ let backupDb = typeof p.backupDb === 'function' ? p.backupDb : () => getDb.backup(woItems)
64
+ let res = { ok: false, apply, stage: 'check', err: '', audit: null, auditAfter: null, dbBackup: false, backupPath: '', written: [], failed: [], stale: [], unsafe: [], residue: [] }
65
+ let fail = (stage, err) => {
66
+ res.ok = false
67
+ res.stage = stage
68
+ res.err = msgOf(err)
69
+ log(`失敗(階段 ${stage}): ${res.err}`)
70
+ if (res.written.length > 0 || res.failed.length > 0) {
71
+ log(`已寫入 ${res.written.length} 列, 失敗 ${res.failed.length} 列${res.failed.length > 0 ? ': ' + res.failed.map((f) => `${f.table} id=${f.id}(${f.err})`).join('; ') : ''}; 請依備份檔還原或重跑本工具`)
72
+ }
73
+ return res
74
+ }
75
+
76
+ //check
77
+ if (woItems === null || typeof woItems !== 'object') {
78
+ return fail('check', 'invalid woItems')
79
+ }
80
+ for (let t of TABLES) {
81
+ if (!woItems[t] || typeof woItems[t].select !== 'function' || (apply && typeof woItems[t].save !== 'function')) {
82
+ return fail('check', `woItems.${t} lacks select${apply ? '/save' : ''}`)
83
+ }
84
+ }
85
+
86
+ //select, audit
87
+ let rows0 = null
88
+ try {
89
+ rows0 = await readTables(woItems)
90
+ }
91
+ catch (err) {
92
+ return fail('select', err)
93
+ }
94
+ let rp = buildReport(rows0)
95
+ res.audit = rp
96
+ res.unsafe = rp.unsafe
97
+ log(formatReport(rp))
98
+ let unsafeNote = rp.unsafe.length > 0 ? `另有 ${rp.unsafe.length} 個授權欄位含無法安全重新編碼之內容(JSON5 Infinity/NaN), 本工具不清理(其懸空項目仍在), 須人工處理: ${rp.unsafe.map((u) => `${u.table} id=${u.id}`).join('、')}` : ''
99
+ if (!apply) {
100
+ log('')
101
+ log('(預設只列不改; 確認清單後加 --apply 才寫入, 寫入前會自動備份)')
102
+ res.ok = true
103
+ res.stage = 'audit'
104
+ return res
105
+ }
106
+ if (rp.changes.length === 0) {
107
+ log('')
108
+ log('無須清理(沒有可改寫之懸空引用), 不備份、不寫入.')
109
+ if (unsafeNote !== '') {
110
+ log(unsafeNote)
111
+ }
112
+ res.ok = true
113
+ res.stage = 'audit'
114
+ return res
115
+ }
116
+
117
+ //備份保真(run08 外審 sol #2, 與 fromTools.backupBefore 同一判準 isJsonLossy): 引用端表備份為 JSON, NaN/±Infinity/bigint/Date/undefined/Buffer 等非 JSON 值會被存成 null 或別型,
118
+ //依備份還原即遺失原值而筆數回驗不會發現; 列內含此類值時於任何備份與寫入之前中止(零寫入), 須人工處理該列
119
+ res.stage = 'backupFidelity'
120
+ for (let t of TABLES) {
121
+ if (isJsonLossy(rows0[t])) {
122
+ return fail('backupFidelity', `backup would be lossy: table ${t} has a row that does not survive a JSON round trip (NaN, Infinity, bigint, Date, undefined, Buffer...); nothing was written. Please fix that row first`)
123
+ }
124
+ }
125
+
126
+ //(a) 備份整個資料庫
127
+ res.stage = 'backupDb'
128
+ try {
129
+ await backupDb()
130
+ res.dbBackup = true
131
+ }
132
+ catch (err) {
133
+ return fail('backupDb', err)
134
+ }
135
+
136
+ //(b) 匯出引用端表全部列
137
+ res.stage = 'backupRows'
138
+ try {
139
+ let fp = path.resolve(p.backupPath || `./authRefsClean-backup-${now2strp()}.json`)
140
+ res.backupPath = fp
141
+ let body = { meta: { tool: 'authRefsClean', time: new Date().toISOString(), note: 'users/grups/pemis 為將被改寫之引用端表之全部列(寫入前); targets 未被改寫, 附上供稽核重現. 含個資, 請妥善保管.' } }
142
+ for (let t of TABLES) {
143
+ body[t] = rows0[t]
144
+ }
145
+ let txt = JSON.stringify(body)
146
+ fs.writeFileSync(fp, txt, { encoding: 'utf8', flag: 'wx' }) //wx: 既有檔不覆寫
147
+ let back = JSON.parse(fs.readFileSync(fp, 'utf8'))
148
+ for (let t of TABLES) {
149
+ if (!Array.isArray(back[t]) || back[t].length !== rows0[t].length) {
150
+ throw new Error(`backup file verification failed: ${t}`)
151
+ }
152
+ }
153
+ log(`已備份: 資料庫(backup-時間.json, 於工作目錄)、引用端表全部列 ${fp}`)
154
+ }
155
+ catch (err) {
156
+ return fail('backupRows', err)
157
+ }
158
+
159
+ //稽核後被他人改動者中止
160
+ res.stage = 'stale'
161
+ let rows1 = null
162
+ try {
163
+ rows1 = await readTables(woItems)
164
+ }
165
+ catch (err) {
166
+ return fail('stale', err)
167
+ }
168
+ for (let c of rp.changes) {
169
+ let r = byId(rows1[c.table]).get(c.id)
170
+ if (r === undefined || r[c.field] !== c.before) {
171
+ res.stale.push({ table: c.table, id: c.id })
172
+ }
173
+ }
174
+ if (res.stale.length > 0) {
175
+ return fail('stale', `${res.stale.length} rows changed or removed since the audit: ${res.stale.slice(0, 10).map((s) => `${s.table} id=${s.id}`).join(', ')}`)
176
+ }
177
+
178
+ //write
179
+ res.stage = 'write'
180
+ for (let t of HOLDERS) {
181
+ let cs = rp.changes.filter((c) => c.table === t)
182
+ if (cs.length === 0) {
183
+ continue
184
+ }
185
+ let rs = null
186
+ try {
187
+ rs = await woItems[t].save(cs.map((c) => ({ id: c.id, [c.field]: c.after })), { autoInsert: false })
188
+ }
189
+ catch (err) {
190
+ cs.forEach((c) => res.failed.push({ table: t, id: c.id, err: msgOf(err) }))
191
+ continue
192
+ }
193
+ if (!Array.isArray(rs) || rs.length !== cs.length) {
194
+ cs.forEach((c) => res.failed.push({ table: t, id: c.id, err: 'unexpected save result' }))
195
+ continue
196
+ }
197
+ cs.forEach((c, k) => {
198
+ let r = rs[k]
199
+ if (r && r.ok === 1 && r.n !== 0 && !(r.nInserted > 0)) {
200
+ res.written.push({ table: t, id: c.id })
201
+ }
202
+ else {
203
+ res.failed.push({ table: t, id: c.id, err: (r && r.err) ? String(r.err) : `save result ${JSON.stringify(r)}` })
204
+ }
205
+ })
206
+ }
207
+ if (res.failed.length > 0) {
208
+ return fail('write', `${res.failed.length} rows failed to save`)
209
+ }
210
+
211
+ //verify: 重新稽核須為 0 懸空, 變更之列只有該欄位改變, 其餘列逐位元不變
212
+ res.stage = 'verify'
213
+ try {
214
+ let rows2 = await readTables(woItems)
215
+ res.auditAfter = buildReport(rows2)
216
+ let newOf = new Map(rp.changes.map((c) => [`${c.table}\u0000${c.id}`, c]))
217
+ let bad = []
218
+ for (let t of HOLDERS) {
219
+ let m0 = byId(rows0[t])
220
+ let m2 = byId(rows2[t])
221
+ if (m2.size !== m0.size || rows2[t].length !== rows0[t].length) {
222
+ bad.push(`${t} row count ${rows0[t].length} -> ${rows2[t].length}`)
223
+ continue
224
+ }
225
+ for (let [id, r0] of m0) {
226
+ let c = newOf.get(`${t}\u0000${id}`)
227
+ let expect = c ? { ...r0, [c.field]: c.after } : r0
228
+ if (!isDeepStrictEqual(m2.get(id), expect)) {
229
+ bad.push(`${t} id=${id}`)
230
+ }
231
+ }
232
+ }
233
+ if (bad.length > 0) {
234
+ return fail('verify', `rows differ from expected after write: ${bad.slice(0, 10).join(', ')}`)
235
+ }
236
+ //已知殘留: 含無法安全重新編碼之內容(unsafe)之列不清理, 其懸空項目於寫前寫後皆在(須人工處理); 除此之外不得有懸空
237
+ let unsafeKeys = new Set(rp.unsafe.map((u) => `${u.table}\u0000${u.id}`))
238
+ res.residue = rp.items.filter((x) => unsafeKeys.has(`${x.table}\u0000${x.holderId}`))
239
+ let rest = res.auditAfter.items.filter((x) => !unsafeKeys.has(`${x.table}\u0000${x.holderId}`))
240
+ if (rest.length !== 0) {
241
+ return fail('verify', `${rest.length} dangling refs remain after clean`)
242
+ }
243
+ if (!isDeepStrictEqual(res.auditAfter.items, res.residue)) {
244
+ return fail('verify', 'residue of unsafe rows changed unexpectedly')
245
+ }
246
+ }
247
+ catch (err) {
248
+ return fail('verify', err)
249
+ }
250
+
251
+ res.ok = true
252
+ res.stage = 'done'
253
+ let n = (t) => res.written.filter((w) => w.table === t).length
254
+ log('')
255
+ log(`已清理: 寫入 ${res.written.length} 列(users ${n('users')}、grups ${n('grups')}、pemis ${n('pemis')}); 重新稽核懸空 ${res.auditAfter.items.length} 項${res.residue.length > 0 ? `(皆為下述不清理之列的既知殘留, 非錯誤)` : ''}. 請重啟服務並抽查授權.`)
256
+ if (unsafeNote !== '') {
257
+ log(unsafeNote)
258
+ }
259
+ return res
260
+ }
261
+
262
+
263
+ export { runClean, TABLES }