diff --git a/src/common/reportTool.ts b/src/common/reportTool.ts new file mode 100644 index 0000000..8eef539 --- /dev/null +++ b/src/common/reportTool.ts @@ -0,0 +1,142 @@ +import { REMOTE_COUCHDB, REMOTE_MINIO } from "@lib/common/models/setting.const"; +import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; +import { generateCredentialObject } from "@lib/replication/httplib"; +import { parseHeaderValues } from "@lib/common/utils"; +import { requestToCouchDBWithCredentials } from "./utils"; +import { LOG_LEVEL_VERBOSE, Logger } from "@lib/common/logger"; +import { DEFAULT_SETTINGS } from "@lib/common/models/setting.const.defaults"; +import { isCloudantURI } from "@lib/pouchdb/utils_couchdb"; +import { compatGlobal } from "@lib/common/coreEnvFunctions"; +import { manifestVersion, packageVersion } from "@lib/common/coreEnvVars"; +import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; +function redactObject(obj: Record, dotted: string, redactedValue = "REDACTED") { + const keys = dotted.split("."); + let current = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!(key in current)) { + current[key] = {} as Record; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + current = current[key]; + } + const lastKey = keys[keys.length - 1]; + if (lastKey in current) { + current[lastKey] = redactedValue; + } + return obj; +} +export async function generateReport(settings: ObsidianLiveSyncSettings, core: LiveSyncBaseCore) { + let responseConfig: Record = {}; + const REDACTED = "𝑅𝐸𝐷𝐴𝐢𝑇𝐸𝐷"; + if (settings.remoteType == REMOTE_COUCHDB) { + try { + const credential = generateCredentialObject(settings); + const customHeaders = parseHeaderValues(settings.couchDB_CustomHeaders); + const r = await requestToCouchDBWithCredentials( + settings.couchDB_URI, + credential, + window.origin, + undefined, + undefined, + undefined, + customHeaders + ); + responseConfig = r.json as Record; + redactObject(responseConfig, "couch_httpd_auth.secret"); + redactObject(responseConfig, "couch_httpd_auth.authentication_db"); + redactObject(responseConfig, "couch_httpd_auth.authentication_redirect"); + redactObject(responseConfig, "couchdb.uuid"); + redactObject(responseConfig, "admins"); + redactObject(responseConfig, "users"); + redactObject(responseConfig, "chttpd_auth.secret"); + delete responseConfig["jwt_keys"]; + } catch (ex) { + Logger(ex, LOG_LEVEL_VERBOSE); + responseConfig = { + error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.", + }; + } + } else if (settings.remoteType == REMOTE_MINIO) { + responseConfig = { error: "Object Storage Synchronisation" }; + // + } + const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[]; + const pluginConfig = JSON.parse(JSON.stringify(settings)) as ObsidianLiveSyncSettings; + const pluginKeys = Object.keys(pluginConfig); + for (const key of pluginKeys) { + if (defaultKeys.includes(key as keyof ObsidianLiveSyncSettings)) continue; + delete pluginConfig[key as keyof ObsidianLiveSyncSettings]; + } + + pluginConfig.couchDB_DBNAME = REDACTED; + pluginConfig.couchDB_PASSWORD = REDACTED; + const scheme = pluginConfig.couchDB_URI.startsWith("http:") + ? "(HTTP)" + : pluginConfig.couchDB_URI.startsWith("https:") + ? "(HTTPS)" + : ""; + pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : `self-hosted${scheme}`; + pluginConfig.couchDB_USER = REDACTED; + pluginConfig.passphrase = REDACTED; + pluginConfig.encryptedPassphrase = REDACTED; + pluginConfig.encryptedCouchDBConnection = REDACTED; + pluginConfig.accessKey = REDACTED; + pluginConfig.secretKey = REDACTED; + const redact = (source: string) => `${REDACTED}(${source.length} letters)`; + const toSchemeOnly = (uri: string) => { + try { + return `${new URL(uri).protocol}//`; + } catch { + const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//); + return matched?.[0] ?? REDACTED; + } + }; + pluginConfig.remoteConfigurations = Object.fromEntries( + Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [ + id, + { + ...config, + uri: toSchemeOnly(config.uri), + }, + ]) + ); + pluginConfig.region = redact(pluginConfig.region); + pluginConfig.bucket = redact(pluginConfig.bucket); + pluginConfig.pluginSyncExtendedSetting = {}; + pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID); + pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase); + pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID); + pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays); + pluginConfig.jwtKey = redact(pluginConfig.jwtKey); + pluginConfig.jwtSub = redact(pluginConfig.jwtSub); + pluginConfig.jwtKid = redact(pluginConfig.jwtKid); + pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders); + pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders); + pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential); + pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername); + pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`; + const endpoint = pluginConfig.endpoint; + if (endpoint == "") { + pluginConfig.endpoint = "Not configured or AWS"; + } else { + const endpointScheme = pluginConfig.endpoint.startsWith("http:") + ? "(HTTP)" + : pluginConfig.endpoint.startsWith("https:") + ? "(HTTPS)" + : ""; + pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`; + } + const obsidianInfo = { + navigator: compatGlobal.navigator.userAgent, + fileSystem: core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive", + }; + const result = { + obsidianInfo, + responseConfig, + pluginConfig, + manifestVersion, + packageVersion, + }; + return result; +} diff --git a/src/lib b/src/lib index 36b9935..6abcea6 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 36b99354f63e0e3a59df460a61386ff6c32b4660 +Subproject commit 6abcea69eb929ea261308b543ac42cd54a00eee2 diff --git a/src/modules/features/ModuleLog.ts b/src/modules/features/ModuleLog.ts index 16de2d9..1eb7945 100644 --- a/src/modules/features/ModuleLog.ts +++ b/src/modules/features/ModuleLog.ts @@ -25,7 +25,7 @@ import { EVENT_ON_UNRESOLVED_ERROR, } from "../../common/events.ts"; import { AbstractObsidianModule } from "../AbstractObsidianModule.ts"; -import { addIcon, normalizePath, Notice } from "../../deps.ts"; +import { addIcon, debounce, normalizePath, Notice, stringifyYaml, type WorkspaceLeaf } from "../../deps.ts"; import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger"; import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts"; import { serialized } from "octagonal-wheels/concurrency/lock"; @@ -41,6 +41,8 @@ import { } from "@lib/string_and_binary/path.ts"; import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts"; import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts"; +import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; +import { generateReport } from "@/common/reportTool.ts"; // This module cannot be a core module because it depends on the Obsidian UI. @@ -50,18 +52,51 @@ const globalLogFunction = (message: any, level?: number, key?: string) => { const messageX = message instanceof Error ? new LiveSyncError("[Error Logged]: " + message.message, { cause: message }) - : message; + : typeof message === "string" + ? message + : JSON.stringify(message); const entry = { message: messageX, level, key } as LogEntry; recentLogEntries.value = [...recentLogEntries.value, entry]; }; setGlobalLogFunction(globalLogFunction); -let recentLogs = [] as string[]; +// Keep the recent logs in memory for display, but also keep a longer history in logForDump for when the user wants to see more logs. +// logForDump is not reactive and is only used for dumping logs when requested, while recentLogs is reactive and is used for displaying logs in the UI. +const logForDump = [] as string[]; function addLog(log: string) { - recentLogs = [...recentLogs, log].splice(-200); - logMessages.value = recentLogs; + logForDump.push(log); + while (logForDump.length > 1000) { + logForDump.shift(); + } } + +// Display log is kept separate from the full log history to optimize performance and memory usage. +// And debounce the updates to the display log to avoid excessive UI updates when there are many log entries in a short time. +const logForDisplay = [] as string[]; + +const updateLogMessage = debounce(() => { + logMessages.value = [...logForDisplay]; +}, 25); +function addDisplayLog(log: string) { + logForDisplay.push(log); + while (logForDisplay.length > 200) { + logForDisplay.shift(); + } + updateLogMessage(); +} + +const redactPatterns = [/PBKDF2 salt \(Security Seed\):.*$/]; +function redactLog(log: string) { + let redactedLog = log; + for (const pattern of redactPatterns) { + redactedLog = redactedLog.replace(pattern, (match) => { + return match.split(":")[0] + ": [REDACTED]"; + }); + } + return redactedLog; +} + // logStore.intercept(e => e.slice(Math.min(e.length - 200, 0))); const showDebugLog = false; @@ -86,15 +121,15 @@ export class ModuleLog extends AbstractObsidianModule { // const emptyMark = `\u{2003}`; function padLeftSpComputed(numI: ReactiveValue, mark: string) { const formatted = reactiveSource(""); - let timer: ReturnType | undefined = undefined; + let timer: number | undefined = undefined; let maxLen = 1; numI.onChanged((numX) => { const num = numX.value; const numLen = `${Math.abs(num)}`.length + 1; maxLen = maxLen < numLen ? numLen : maxLen; - if (timer) clearTimeout(timer); + if (timer) compatGlobal.clearTimeout(timer); if (num == 0) { - timer = setTimeout(() => { + timer = compatGlobal.setTimeout(() => { formatted.value = ""; maxLen = 1; }, 3000); @@ -323,7 +358,7 @@ export class ModuleLog extends AbstractObsidianModule { if (this.nextFrameQueue) { return; } - this.nextFrameQueue = requestAnimationFrame(() => { + this.nextFrameQueue = compatGlobal.requestAnimationFrame(() => { this.nextFrameQueue = undefined; const { message, status } = this.statusBarLabels.value; // const recent = logMessages.value; @@ -346,7 +381,8 @@ export class ModuleLog extends AbstractObsidianModule { (a, b) => (a < b.ttl ? a : b.ttl), Number.MAX_SAFE_INTEGER ); - if (this.logLines.length > 0) setTimeout(() => this.applyStatusBarText(), minimumNext - now); + if (this.logLines.length > 0) + compatGlobal.setTimeout(() => this.applyStatusBarText(), minimumNext - now); const recent = this.logLines.map((e) => e.message); const recentLogs = recent.reverse().join("\n"); if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs; @@ -368,7 +404,7 @@ export class ModuleLog extends AbstractObsidianModule { if (this.statusDiv) { this.statusDiv.remove(); } - document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove()); + compatGlobal.document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove()); return Promise.resolve(true); } _everyOnloadStart(): Promise { @@ -390,7 +426,28 @@ export class ModuleLog extends AbstractObsidianModule { void this.services.API.showWindow(VIEW_TYPE_LOG); }, }); - this.registerView(VIEW_TYPE_LOG, (leaf) => new LogPaneView(leaf, this.plugin)); + this.addCommand({ + id: "dump-debug-info", + name: "Generate full report for opening the issue with debug info", + callback: async () => { + const recentLog = [...logForDump]; + const report = await generateReport(this.services.setting.currentSettings(), this.core); + const info = { + ...report, + recentLog: recentLog.map(redactLog), + }; + const yaml = `\`\`\`\` +# ---- Debug Info Dump ---- +${stringifyYaml(info)} +\`\`\`\``; + if (await this.services.UI.promptCopyToClipboard("Debug info", yaml)) { + new Notice( + "Debug info copied to clipboard. You can paste it in the issue. Be careful as it may contain sensitive information, review it before sharing." + ); + } + }, + }); + this.registerView(VIEW_TYPE_LOG, (leaf: WorkspaceLeaf) => new LogPaneView(leaf, this.plugin)); return Promise.resolve(true); } private _everyOnloadAfterLoadSettings(): Promise { @@ -404,7 +461,7 @@ export class ModuleLog extends AbstractObsidianModule { void this.setFileStatus(); }); - const w = document.querySelectorAll(`.livesync-status`); + const w = compatGlobal.document.querySelectorAll(`.livesync-status`); w.forEach((e) => e.remove()); this.observeForLogs(); @@ -421,6 +478,8 @@ export class ModuleLog extends AbstractObsidianModule { this.statusBar?.addClass("syncstatusbar"); } this.adjustStatusDivPosition(); + this._log("Log module loaded", LOG_LEVEL_INFO); + this._log("Verbose log", LOG_LEVEL_VERBOSE); return Promise.resolve(true); } @@ -444,11 +503,12 @@ export class ModuleLog extends AbstractObsidianModule { if (level == LOG_LEVEL_DEBUG && !showDebugLog) { return; } + let memoOnly = false; if (level <= LOG_LEVEL_INFO && this.settings && this.settings.lessInformationInLog) { - return; + memoOnly = true; } if (this.settings && !this.settings.showVerboseLog && level == LOG_LEVEL_VERBOSE) { - return; + memoOnly = true; } const vaultName = this.services.vault.getVaultName(); const now = new Date(); @@ -469,6 +529,15 @@ export class ModuleLog extends AbstractObsidianModule { ? `${errorInfo}` : JSON.stringify(message, null, 2); const newMessage = timestamp + "->" + messageContent; + + if (this.settings?.writeLogToTheFile) { + this.writeLogToTheFile(now, vaultName, newMessage); + } + addLog(newMessage); + if (memoOnly) { + return; + } + addDisplayLog(newMessage); if (message instanceof Error) { console.error(vaultName + ":" + newMessage); } else if (level >= LOG_LEVEL_INFO) { @@ -479,10 +548,6 @@ export class ModuleLog extends AbstractObsidianModule { if (!this.settings?.showOnlyIconsOnEditor) { this.statusLog.value = messageContent; } - if (this.settings?.writeLogToTheFile) { - this.writeLogToTheFile(now, vaultName, newMessage); - } - addLog(newMessage); this.logLines.push({ ttl: now.getTime() + 3000, message: newMessage }); if (level >= LOG_LEVEL_NOTICE) { diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index ebfb377..d9ca27c 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -39,6 +39,7 @@ import { EVENT_REQUEST_SHOW_HISTORY } from "../../../common/obsidianEvents.ts"; import { generateCredentialObject } from "../../../lib/src/replication/httplib.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { PageFunctions } from "./SettingPane.ts"; +import { generateReport } from "@/common/reportTool.ts"; export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void { // const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` }); // hatchWarn.addClass("op-warn-info"); @@ -69,140 +70,14 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE); }) ); + new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) => button .setButtonText($msg("Copy Report to clipboard")) .setCta() .setDisabled(false) .onClick(async () => { - let responseConfig: any = {}; - const REDACTED = "𝑅𝐸𝐷𝐴𝐢𝑇𝐸𝐷"; - if (this.editingSettings.remoteType == REMOTE_COUCHDB) { - try { - const credential = generateCredentialObject(this.editingSettings); - const customHeaders = parseHeaderValues(this.editingSettings.couchDB_CustomHeaders); - const r = await requestToCouchDBWithCredentials( - this.editingSettings.couchDB_URI, - credential, - window.origin, - undefined, - undefined, - undefined, - customHeaders - ); - - Logger(JSON.stringify(r.json, null, 2)); - - responseConfig = r.json; - responseConfig["couch_httpd_auth"].secret = REDACTED; - responseConfig["couch_httpd_auth"].authentication_db = REDACTED; - responseConfig["couch_httpd_auth"].authentication_redirect = REDACTED; - responseConfig["couchdb"].uuid = REDACTED; - responseConfig["admins"] = REDACTED; - delete responseConfig["jwt_keys"]; - if ("secret" in responseConfig["chttpd_auth"]) - responseConfig["chttpd_auth"].secret = REDACTED; - } catch (ex) { - Logger(ex, LOG_LEVEL_VERBOSE); - responseConfig = { - error: "Requesting information from the remote CouchDB has failed. If you are using IBM Cloudant, this is normal behaviour.", - }; - } - } else if (this.editingSettings.remoteType == REMOTE_MINIO) { - responseConfig = { error: "Object Storage Synchronisation" }; - // - } - const defaultKeys = Object.keys(DEFAULT_SETTINGS) as (keyof ObsidianLiveSyncSettings)[]; - const pluginConfig = JSON.parse(JSON.stringify(this.editingSettings)) as ObsidianLiveSyncSettings; - const pluginKeys = Object.keys(pluginConfig); - for (const key of pluginKeys) { - if (defaultKeys.includes(key as any)) continue; - delete pluginConfig[key as keyof ObsidianLiveSyncSettings]; - } - - pluginConfig.couchDB_DBNAME = REDACTED; - pluginConfig.couchDB_PASSWORD = REDACTED; - const scheme = pluginConfig.couchDB_URI.startsWith("http:") - ? "(HTTP)" - : pluginConfig.couchDB_URI.startsWith("https:") - ? "(HTTPS)" - : ""; - pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) - ? "cloudant" - : `self-hosted${scheme}`; - pluginConfig.couchDB_USER = REDACTED; - pluginConfig.passphrase = REDACTED; - pluginConfig.encryptedPassphrase = REDACTED; - pluginConfig.encryptedCouchDBConnection = REDACTED; - pluginConfig.accessKey = REDACTED; - pluginConfig.secretKey = REDACTED; - const redact = (source: string) => `${REDACTED}(${source.length} letters)`; - const toSchemeOnly = (uri: string) => { - try { - return `${new URL(uri).protocol}//`; - } catch { - const matched = uri.match(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//); - return matched?.[0] ?? REDACTED; - } - }; - pluginConfig.remoteConfigurations = Object.fromEntries( - Object.entries(pluginConfig.remoteConfigurations || {}).map(([id, config]) => [ - id, - { - ...config, - uri: toSchemeOnly(config.uri), - }, - ]) - ); - pluginConfig.region = redact(pluginConfig.region); - pluginConfig.bucket = redact(pluginConfig.bucket); - pluginConfig.pluginSyncExtendedSetting = {}; - pluginConfig.P2P_AppID = redact(pluginConfig.P2P_AppID); - pluginConfig.P2P_passphrase = redact(pluginConfig.P2P_passphrase); - pluginConfig.P2P_roomID = redact(pluginConfig.P2P_roomID); - pluginConfig.P2P_relays = redact(pluginConfig.P2P_relays); - pluginConfig.jwtKey = redact(pluginConfig.jwtKey); - pluginConfig.jwtSub = redact(pluginConfig.jwtSub); - pluginConfig.jwtKid = redact(pluginConfig.jwtKid); - pluginConfig.bucketCustomHeaders = redact(pluginConfig.bucketCustomHeaders); - pluginConfig.couchDB_CustomHeaders = redact(pluginConfig.couchDB_CustomHeaders); - pluginConfig.P2P_turnCredential = redact(pluginConfig.P2P_turnCredential); - pluginConfig.P2P_turnUsername = redact(pluginConfig.P2P_turnUsername); - pluginConfig.P2P_turnServers = `(${pluginConfig.P2P_turnServers.split(",").length} servers configured)`; - const endpoint = pluginConfig.endpoint; - if (endpoint == "") { - pluginConfig.endpoint = "Not configured or AWS"; - } else { - const endpointScheme = pluginConfig.endpoint.startsWith("http:") - ? "(HTTP)" - : pluginConfig.endpoint.startsWith("https:") - ? "(HTTPS)" - : ""; - pluginConfig.endpoint = `${endpoint.indexOf(".r2.cloudflarestorage.") !== -1 ? "R2" : "self-hosted?"}(${endpointScheme})`; - } - const obsidianInfo = { - navigator: navigator.userAgent, - fileSystem: this.core.services.vault.isStorageInsensitive() ? "insensitive" : "sensitive", - }; - const msgConfig = `# ---- Obsidian info ---- -${stringifyYaml(obsidianInfo)} ---- -# ---- remote config ---- -${stringifyYaml(responseConfig)} ---- -# ---- Plug-in config ---- -${stringifyYaml({ - version: this.manifestVersion, - ...pluginConfig, -})}`; - console.log(msgConfig); - if ((await this.services.UI.promptCopyToClipboard("Generated report", msgConfig)) == true) { - // await navigator.clipboard.writeText(msgConfig); - // Logger( - // `Generated report has been copied to clipboard. Please report the issue with this! Thank you for your cooperation!`, - // LOG_LEVEL_NOTICE - // ); - } + await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info"); }) ); new Setting(paneEl) diff --git a/updates.md b/updates.md index 4fa6cf1..d057280 100644 --- a/updates.md +++ b/updates.md @@ -10,11 +10,15 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid ### Improved - Improved the error verbosity on concurrent processing during the start-up process. +- Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`). +- Updating logs is now debounced to avoid excessive updates during rapid log generation. +- Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue. ### Fixed - Fix an issue about resuming from background on iOS (#888). + ## 0.25.64 17th May, 2026