diff --git a/src/LocalPouchDB.ts b/src/LocalPouchDB.ts index 5bf443a..bfbe656 100644 --- a/src/LocalPouchDB.ts +++ b/src/LocalPouchDB.ts @@ -52,7 +52,7 @@ export class LocalPouchDB extends LocalPouchDBBase { } - async connectRemoteCouchDB(uri: string, auth: { username: string; password: string }, disableRequestURI: boolean, passphrase: string | boolean, useDynamicIterationCount: boolean): Promise; info: PouchDB.Core.DatabaseInfo }> { + async connectRemoteCouchDB(uri: string, auth: { username: string; password: string }, disableRequestURI: boolean, passphrase: string | false, useDynamicIterationCount: boolean): Promise; info: PouchDB.Core.DatabaseInfo }> { if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid"; if (uri.toLowerCase() != uri) return "Remote URI and database name could not contain capital letters."; if (uri.indexOf(" ") !== -1) return "Remote URI and database name could not contain spaces."; @@ -154,7 +154,7 @@ export class LocalPouchDB extends LocalPouchDBBase { }; const db: PouchDB.Database = new PouchDB(uri, conf); - if (passphrase && typeof passphrase === "string") { + if (passphrase !== "false" && typeof passphrase === "string") { enableEncryption(db, passphrase, useDynamicIterationCount); } try { diff --git a/src/ObsidianLiveSyncSettingTab.ts b/src/ObsidianLiveSyncSettingTab.ts index d132c97..97ab4be 100644 --- a/src/ObsidianLiveSyncSettingTab.ts +++ b/src/ObsidianLiveSyncSettingTab.ts @@ -1,5 +1,5 @@ import { App, PluginSettingTab, Setting, sanitizeHTMLToDom, RequestUrlParam, requestUrl, TextAreaComponent, MarkdownRenderer, stringifyYaml } from "obsidian"; -import { DEFAULT_SETTINGS, LOG_LEVEL, ObsidianLiveSyncSettings, RemoteDBSettings } from "./lib/src/types"; +import { DEFAULT_SETTINGS, LOG_LEVEL, ObsidianLiveSyncSettings, ConfigPassphraseStore, RemoteDBSettings } from "./lib/src/types"; import { path2id, id2path } from "./utils"; import { delay } from "./lib/src/utils"; import { Semaphore } from "./lib/src/semaphore"; @@ -43,6 +43,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { } display(): void { const { containerEl } = this; + let encrypt = this.plugin.settings.encrypt; + let passphrase = this.plugin.settings.passphrase; + let useDynamicIterationCount = this.plugin.settings.useDynamicIterationCount; containerEl.empty(); @@ -293,68 +296,78 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { ) ); - new Setting(containerRemoteDatabaseEl) + const e2e = new Setting(containerRemoteDatabaseEl) .setName("End to End Encryption") .setDesc("Encrypt contents on the remote database. If you use the plugin's synchronization feature, enabling this is recommend.") .addToggle((toggle) => - toggle.setValue(this.plugin.settings.workingEncrypt).onChange(async (value) => { + toggle.setValue(encrypt).onChange(async (value) => { if (inWizard) { this.plugin.settings.encrypt = value; - passphrase.setDisabled(!value); + passphraseSetting.setDisabled(!value); dynamicIteration.setDisabled(!value); await this.plugin.saveSettings(); } else { - this.plugin.settings.workingEncrypt = value; - passphrase.setDisabled(!value); + encrypt = value; + passphraseSetting.setDisabled(!value); dynamicIteration.setDisabled(!value); await this.plugin.saveSettings(); + markDirtyControl(); } }) ); - const passphrase = new Setting(containerRemoteDatabaseEl) + + const markDirtyControl = () => { + passphraseSetting.controlEl.toggleClass("sls-item-dirty", passphrase != this.plugin.settings.passphrase); + e2e.controlEl.toggleClass("sls-item-dirty", encrypt != this.plugin.settings.encrypt); + dynamicIteration.controlEl.toggleClass("sls-item-dirty", useDynamicIterationCount != this.plugin.settings.useDynamicIterationCount) + } + + const passphraseSetting = new Setting(containerRemoteDatabaseEl) .setName("Passphrase") .setDesc("Encrypting passphrase. If you change the passphrase of a existing database, overwriting the remote database is strongly recommended.") .addText((text) => { text.setPlaceholder("") - .setValue(this.plugin.settings.workingPassphrase) + .setValue(passphrase) .onChange(async (value) => { if (inWizard) { this.plugin.settings.passphrase = value; await this.plugin.saveSettings(); } else { - this.plugin.settings.workingPassphrase = value; + passphrase = value; await this.plugin.saveSettings(); + markDirtyControl(); } }); text.inputEl.setAttribute("type", "password"); }); - passphrase.setDisabled(!this.plugin.settings.workingEncrypt); + passphraseSetting.setDisabled(!encrypt); const dynamicIteration = new Setting(containerRemoteDatabaseEl) .setName("Use dynamic iteration count (experimental)") .setDesc("Balancing the encryption/decryption load against the length of the passphrase if toggled. (v0.17.5 or higher required)") .addToggle((toggle) => { - toggle.setValue(this.plugin.settings.workingUseDynamicIterationCount) + toggle.setValue(useDynamicIterationCount) .onChange(async (value) => { if (inWizard) { this.plugin.settings.useDynamicIterationCount = value; await this.plugin.saveSettings(); } else { - this.plugin.settings.workingUseDynamicIterationCount = value; + useDynamicIterationCount = value; await this.plugin.saveSettings(); + markDirtyControl(); } }); }) .setClass("wizardHidden"); - dynamicIteration.setDisabled(!this.plugin.settings.workingEncrypt); + dynamicIteration.setDisabled(!encrypt); const checkWorkingPassphrase = async (): Promise => { const settingForCheck: RemoteDBSettings = { ...this.plugin.settings, - encrypt: this.plugin.settings.workingEncrypt, - passphrase: this.plugin.settings.workingPassphrase, - useDynamicIterationCount: this.plugin.settings.workingUseDynamicIterationCount, + encrypt: encrypt, + passphrase: passphrase, + useDynamicIterationCount: useDynamicIterationCount, }; console.dir(settingForCheck); const db = await this.plugin.localDatabase.connectRemoteCouchDBWithSetting(settingForCheck, this.plugin.localDatabase.isMobile); @@ -372,19 +385,19 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { } }; const applyEncryption = async (sendToServer: boolean) => { - if (this.plugin.settings.workingEncrypt && this.plugin.settings.workingPassphrase == "") { + if (encrypt && passphrase == "") { Logger("If you enable encryption, you have to set the passphrase", LOG_LEVEL.NOTICE); return; } - if (this.plugin.settings.workingEncrypt && !(await testCrypt())) { + if (encrypt && !(await testCrypt())) { Logger("WARNING! Your device would not support encryption.", LOG_LEVEL.NOTICE); return; } if (!(await checkWorkingPassphrase()) && !sendToServer) { return; } - if (!this.plugin.settings.workingEncrypt) { - this.plugin.settings.workingPassphrase = ""; + if (!encrypt) { + passphrase = ""; } this.plugin.settings.liveSync = false; this.plugin.settings.periodicReplication = false; @@ -392,11 +405,12 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { this.plugin.settings.syncOnStart = false; this.plugin.settings.syncOnFileOpen = false; this.plugin.settings.syncAfterMerge = false; - this.plugin.settings.encrypt = this.plugin.settings.workingEncrypt; - this.plugin.settings.passphrase = this.plugin.settings.workingPassphrase; - this.plugin.settings.useDynamicIterationCount = this.plugin.settings.workingUseDynamicIterationCount; + this.plugin.settings.encrypt = encrypt; + this.plugin.settings.passphrase = passphrase; + this.plugin.settings.useDynamicIterationCount = useDynamicIterationCount; await this.plugin.saveSettings(); + markDirtyControl(); if (sendToServer) { await this.plugin.initializeDatabase(true); await this.plugin.markRemoteLocked(); @@ -1326,6 +1340,45 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { }) ); + const passphrase_options: Record = { + "": "Default", + LOCALSTORAGE: "Use a custom passphrase", + ASK_AT_LAUNCH: "Ask an passphrase at every launch", + } + new Setting(containerMiscellaneousEl) + .setName("Encrypting sensitive configuration items") + .addDropdown((dropdown) => + dropdown + .addOptions(passphrase_options) + .setValue(this.plugin.settings.configPassphraseStore) + .onChange(async (value) => { + this.plugin.settings.configPassphraseStore = value as ConfigPassphraseStore; + this.plugin.usedPassphrase = ""; + confPassphraseSetting.setDisabled(this.plugin.settings.configPassphraseStore != "LOCALSTORAGE"); + await this.plugin.saveSettings(); + }) + ) + .setClass("wizardHidden"); + + + const confPassphrase = localStorage.getItem("ls-setting-passphrase") || ""; + const confPassphraseSetting = new Setting(containerMiscellaneousEl) + .setName("Passphrase of sensitive configuration items") + .setDesc("This passphrase will not be copied to another device. It will be set to `Default` until you configure it again.") + .addText((text) => { + text.setPlaceholder("") + .setValue(confPassphrase) + .onChange(async (value) => { + this.plugin.usedPassphrase = ""; + localStorage.setItem("ls-setting-passphrase", value); + await this.plugin.saveSettings(); + markDirtyControl(); + }); + text.inputEl.setAttribute("type", "password"); + }) + .setClass("wizardHidden"); + confPassphraseSetting.setDisabled(this.plugin.settings.configPassphraseStore != "LOCALSTORAGE"); + const infoApply = containerMiscellaneousEl.createEl("div", { text: `To finish setup, please select one of the presets` }); infoApply.addClass("op-warn-info"); infoApply.addClass("wizardOnly") @@ -1367,7 +1420,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { pluginConfig.couchDB_URI = isCloudantURI(pluginConfig.couchDB_URI) ? "cloudant" : "self-hosted"; pluginConfig.couchDB_USER = REDACTED; pluginConfig.passphrase = REDACTED; - pluginConfig.workingPassphrase = REDACTED; + pluginConfig.encryptedPassphrase = REDACTED; + pluginConfig.encryptedCouchDBConnection = REDACTED; const msgConfig = `----remote config---- ${stringifyYaml(responseConfig)} diff --git a/src/dialogs.ts b/src/dialogs.ts index 53d45ee..39dede3 100644 --- a/src/dialogs.ts +++ b/src/dialogs.ts @@ -52,14 +52,14 @@ export class InputStringDialog extends Modal { const { contentEl } = this; contentEl.createEl("h1", { text: this.title }); - - new Setting(contentEl).setName(this.key).addText((text) => + // For enter to submit + const formEl = contentEl.createEl("form"); + new Setting(formEl).setName(this.key).addText((text) => text.onChange((value) => { this.result = value; }) ); - - new Setting(contentEl).addButton((btn) => + new Setting(formEl).addButton((btn) => btn .setButtonText("Ok") .setCta() diff --git a/src/lib b/src/lib index 7be1dad..14fecd2 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 7be1dad0beec5ca29084f633482c3f14c9841ae3 +Subproject commit 14fecd2411bc5824381a253abf3ab0a3f002dd1e diff --git a/src/main.ts b/src/main.ts index ac694cc..8b893f0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { debounce, Notice, Plugin, TFile, addIcon, TFolder, normalizePath, TAbstractFile, Editor, MarkdownView, PluginManifest, App } from "obsidian"; import { Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "diff-match-patch"; -import { EntryDoc, LoadedEntry, ObsidianLiveSyncSettings, diff_check_result, diff_result_leaf, EntryBody, LOG_LEVEL, VER, DEFAULT_SETTINGS, diff_result, FLAGMD_REDFLAG, SYNCINFO_ID, InternalFileEntry } from "./lib/src/types"; +import { EntryDoc, LoadedEntry, ObsidianLiveSyncSettings, diff_check_result, diff_result_leaf, EntryBody, LOG_LEVEL, VER, DEFAULT_SETTINGS, diff_result, FLAGMD_REDFLAG, SYNCINFO_ID, InternalFileEntry, SALT_OF_PASSPHRASE, ConfigPassphraseStore, CouchDBConnection } from "./lib/src/types"; import { PluginDataEntry, PERIODIC_PLUGIN_SWEEP, PluginList, DevicePluginList, InternalFileInfo, queueItem, FileInfo } from "./types"; import { getDocData, isDocContentSame } from "./lib/src/utils"; import { Logger } from "./lib/src/logger"; @@ -10,7 +10,7 @@ import { ConflictResolveModal } from "./ConflictResolveModal"; import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab"; import { DocumentHistoryModal } from "./DocumentHistoryModal"; import { applyPatch, clearAllPeriodic, clearAllTriggers, clearTrigger, disposeMemoObject, generatePatchObj, id2path, isObjectMargeApplicable, isSensibleMargeApplicable, memoIfNotExist, memoObject, path2id, retrieveMemoObject, setTrigger, tryParseJSON } from "./utils"; -import { decrypt, encrypt } from "./lib/src/e2ee_v2"; +import { decrypt, encrypt, tryDecrypt } from "./lib/src/e2ee_v2"; const isDebug = false; @@ -398,11 +398,11 @@ export default class ObsidianLiveSyncPlugin extends Plugin { const configURIBase = "obsidian://setuplivesync?settings="; this.addCommand({ id: "livesync-copysetupuri", - name: "Copy setup URI", + name: "Copy the setup URI", callback: async () => { - const encryptingPassphrase = await askString(this.app, "Encrypt your settings", "Passphrase", ""); + const encryptingPassphrase = await askString(this.app, "Encrypt your settings", "The passphrase to encrypt the setup URI", ""); if (encryptingPassphrase === false) return; - const setting = { ...this.settings }; + const setting = { ...this.settings, configPassphraseStore: "", encryptedCouchDBConnection: "", encryptedPassphrase: "" }; const keys = Object.keys(setting) as (keyof ObsidianLiveSyncSettings)[]; for (const k of keys) { if (JSON.stringify(k in setting ? setting[k] : "") == JSON.stringify(k in DEFAULT_SETTINGS ? DEFAULT_SETTINGS[k] : "*")) { @@ -417,11 +417,11 @@ export default class ObsidianLiveSyncPlugin extends Plugin { }); this.addCommand({ id: "livesync-copysetupurifull", - name: "Copy setup URI (Full)", + name: "Copy the setup URI (Full)", callback: async () => { - const encryptingPassphrase = await askString(this.app, "Encrypt your settings", "Passphrase", ""); + const encryptingPassphrase = await askString(this.app, "Encrypt your settings", "The passphrase to encrypt the setup URI", ""); if (encryptingPassphrase === false) return; - const setting = { ...this.settings }; + const setting = { ...this.settings, configPassphraseStore: "", encryptedCouchDBConnection: "", encryptedPassphrase: "" }; const encryptedSetting = encodeURIComponent(await encrypt(JSON.stringify(setting), encryptingPassphrase, false)); const uri = `${configURIBase}${encryptedSetting}`; await navigator.clipboard.writeText(uri); @@ -430,7 +430,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { }); this.addCommand({ id: "livesync-opensetupuri", - name: "Open setup URI", + name: "Open the setup URI", callback: async () => { const setupURI = await askString(this.app, "Easy setup", "Set up URI", `${configURIBase}aaaaa`); if (setupURI === false) return; @@ -446,16 +446,20 @@ export default class ObsidianLiveSyncPlugin extends Plugin { const setupWizard = async (confString: string) => { try { const oldConf = JSON.parse(JSON.stringify(this.settings)); - const encryptingPassphrase = await askString(this.app, "Passphrase", "Passphrase for your settings", ""); + const encryptingPassphrase = await askString(this.app, "Passphrase", "The passphrase to decrypt your setup URI", ""); if (encryptingPassphrase === false) return; const newConf = await JSON.parse(await decrypt(confString, encryptingPassphrase, false)); if (newConf) { const result = await askYesNo(this.app, "Importing LiveSync's conf, OK?"); if (result == "yes") { - const newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf); + const newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf) as ObsidianLiveSyncSettings; this.localDatabase.closeReplication(); this.settings.suspendFileWatching = true; console.dir(newSettingW); + // Back into the default method once. + newSettingW.configPassphraseStore = ""; + newSettingW.encryptedPassphrase = ""; + newSettingW.encryptedCouchDBConnection = ""; const setupJustImport = "Just import setting"; const setupAsNew = "Set it up as secondary or subsequent device"; const setupAgain = "Reconfigure and reconstitute the data"; @@ -464,9 +468,11 @@ export default class ObsidianLiveSyncPlugin extends Plugin { const setupType = await askSelectString(this.app, "How would you like to set it up?", [setupAsNew, setupAgain, setupJustImport, setupManually]); if (setupType == setupJustImport) { this.settings = newSettingW; + this.usedPassphrase = ""; await this.saveSettings(); } else if (setupType == setupAsNew) { this.settings = newSettingW; + this.usedPassphrase = ""; await this.saveSettings(); await this.resetLocalOldDatabase(); await this.resetLocalDatabase(); @@ -478,6 +484,8 @@ export default class ObsidianLiveSyncPlugin extends Plugin { if (await askSelectString(this.app, "Do you really want to do this?", ["Cancel", confirm]) != confirm) { return; } + this.settings = newSettingW; + this.usedPassphrase = ""; await this.saveSettings(); await this.resetLocalOldDatabase(); await this.resetLocalDatabase(); @@ -494,6 +502,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { if (keepLocalDB == "yes" && keepRemoteDB == "yes") { // nothing to do. so peaceful. this.settings = newSettingW; + this.usedPassphrase = ""; await this.saveSettings(); const replicate = await askYesNo(this.app, "Unlock and replicate?"); if (replicate == "yes") { @@ -513,6 +522,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } let initDB; this.settings = newSettingW; + this.usedPassphrase = ""; await this.saveSettings(); if (keepLocalDB == "no") { this.resetLocalOldDatabase(); @@ -715,11 +725,93 @@ export default class ObsidianLiveSyncPlugin extends Plugin { return await this.localDatabase.initializeDatabase(); } + usedPassphrase = ""; + + getPassphrase(settings: ObsidianLiveSyncSettings) { + const methods: Record Promise)> = { + "": () => Promise.resolve("*"), + "LOCALSTORAGE": () => Promise.resolve(localStorage.getItem("ls-setting-passphrase") ?? false), + "ASK_AT_LAUNCH": () => askString(this.app, "Passphrase", "passphrase", "") + } + const method = settings.configPassphraseStore; + const methodFunc = method in methods ? methods[method] : methods[""]; + return methodFunc(); + } + + async decryptConfigurationItem(encrypted: string, passphrase: string) { + const dec = await tryDecrypt(encrypted, passphrase + SALT_OF_PASSPHRASE, false); + if (dec) { + this.usedPassphrase = passphrase; + return dec; + } + return false; + } + tryDecodeJson(encoded: string | false): object | false { + try { + if (!encoded) return false; + return JSON.parse(encoded); + } catch (ex) { + return false; + } + } + + async encryptConfigurationItem(src: string, settings: ObsidianLiveSyncSettings) { + if (this.usedPassphrase != "") { + return await encrypt(src, this.usedPassphrase + SALT_OF_PASSPHRASE, false); + } + + const passphrase = await this.getPassphrase(settings); + if (passphrase === false) { + Logger("Could not determine passphrase to save data.json! You probably make the configuration sure again!", LOG_LEVEL.URGENT); + return ""; + } + const dec = await encrypt(src, passphrase + SALT_OF_PASSPHRASE, false); + if (dec) { + this.usedPassphrase = passphrase; + return dec; + } + + return ""; + } async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); - this.settings.workingEncrypt = this.settings.encrypt; - this.settings.workingPassphrase = this.settings.passphrase; + const settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()) as ObsidianLiveSyncSettings; + const passphrase = await this.getPassphrase(settings); + if (passphrase === false) { + Logger("Could not determine passphrase for reading data.json! DO NOT synchronize with the remote before making sure your configuration is!", LOG_LEVEL.URGENT); + } else { + if (settings.encryptedCouchDBConnection) { + const keys = ["couchDB_URI", "couchDB_USER", "couchDB_PASSWORD", "couchDB_DBNAME"] as (keyof CouchDBConnection)[]; + const decrypted = this.tryDecodeJson(await this.decryptConfigurationItem(settings.encryptedCouchDBConnection, passphrase)) as CouchDBConnection; + if (decrypted) { + for (const key of keys) { + if (key in decrypted) { + settings[key] = decrypted[key] + } + } + } else { + Logger("Could not decrypt passphrase for reading data.json! DO NOT synchronize with the remote before making sure your configuration is!", LOG_LEVEL.URGENT); + for (const key of keys) { + settings[key] = ""; + } + } + } + if (settings.encrypt && settings.encryptedPassphrase) { + const encrypted = settings.encryptedPassphrase; + const decrypted = await this.decryptConfigurationItem(encrypted, passphrase); + if (decrypted) { + settings.passphrase = decrypted; + } else { + Logger("Could not decrypt passphrase for reading data.json! DO NOT synchronize with the remote before making sure your configuration is!", LOG_LEVEL.URGENT); + settings.passphrase = ""; + } + } + + } + this.settings = settings; + if ("workingEncrypt" in this.settings) delete this.settings.workingEncrypt; + if ("workingPassphrase" in this.settings) delete this.settings.workingPassphrase; + // Delete this feature to avoid problems on mobile. this.settings.disableRequestURI = true; @@ -751,7 +843,29 @@ export default class ObsidianLiveSyncPlugin extends Plugin { const lsKey = "obsidian-live-sync-vaultanddevicename-" + this.getVaultName(); localStorage.setItem(lsKey, this.deviceAndVaultName || ""); - await this.saveData(this.settings); + const settings = { ...this.settings }; + if (this.usedPassphrase == "" && !await this.getPassphrase(settings)) { + Logger("Could not determine passphrase for saving data.json! Our data.json have insecure items!", LOG_LEVEL.NOTICE); + } else { + if (settings.couchDB_PASSWORD != "" || settings.couchDB_URI != "" || settings.couchDB_USER != "" || settings.couchDB_DBNAME) { + const connectionSetting: CouchDBConnection = { + couchDB_DBNAME: settings.couchDB_DBNAME, + couchDB_PASSWORD: settings.couchDB_PASSWORD, + couchDB_URI: settings.couchDB_URI, + couchDB_USER: settings.couchDB_USER, + }; + settings.encryptedCouchDBConnection = await this.encryptConfigurationItem(JSON.stringify(connectionSetting), settings); + settings.couchDB_PASSWORD = ""; + settings.couchDB_DBNAME = ""; + settings.couchDB_URI = ""; + settings.couchDB_USER = ""; + } + if (settings.encrypt && settings.passphrase != "") { + settings.encryptedPassphrase = await this.encryptConfigurationItem(settings.passphrase, settings); + settings.passphrase = ""; + } + } + await this.saveData(settings); this.localDatabase.settings = this.settings; this.triggerRealizeSettingSyncMode(); } @@ -1020,7 +1134,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } recentProcessedInternalFiles = [] as string[]; async watchVaultRawEventsAsync(path: string) { - const stat = await this.app.vault.adapter.stat(path); // sometimes folder is coming. if (stat && stat.type != "file") return; diff --git a/styles.css b/styles.css index 71b24dc..befde2b 100644 --- a/styles.css +++ b/styles.css @@ -247,4 +247,8 @@ div.sls-setting-menu-btn { .sls-setting:not(.isWizard) .wizardOnly { display: none; +} + +.sls-item-dirty::before { + content: "✏"; } \ No newline at end of file