diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index dd2391b67..074e03e4c 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -159,6 +159,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> } saveToRecentNotes(resolvedNotePath: string) { + if (options.is("databaseReadonly")) { + return; + } setTimeout(async () => { // we include the note in the recent list only if the user stayed on the note at least 5 seconds if (resolvedNotePath && resolvedNotePath === this.notePath) { @@ -254,6 +257,10 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> return false; } + if (options.is("databaseReadonly")) { + return true; + } + if (this.note.isLabelTruthy("readOnly")) { return true; } diff --git a/apps/client/src/components/tab_manager.ts b/apps/client/src/components/tab_manager.ts index cf2876a5a..fa83470ce 100644 --- a/apps/client/src/components/tab_manager.ts +++ b/apps/client/src/components/tab_manager.ts @@ -44,6 +44,9 @@ export default class TabManager extends Component { if (!appContext.isMainWindow) { return; } + if (options.is("databaseReadonly")) { + return; + } const openNoteContexts = this.noteContexts .map((nc) => nc.getPojoState()) diff --git a/apps/client/src/widgets/floating_buttons/edit_button.ts b/apps/client/src/widgets/floating_buttons/edit_button.ts index 2a11f0d01..344447f31 100644 --- a/apps/client/src/widgets/floating_buttons/edit_button.ts +++ b/apps/client/src/widgets/floating_buttons/edit_button.ts @@ -6,6 +6,7 @@ import { t } from "../../services/i18n.js"; import LoadResults from "../../services/load_results.js"; import type { AttributeRow } from "../../services/load_results.js"; import FNote from "../../entities/fnote.js"; +import options from "../../services/options.js"; export default class EditButton extends OnClickButtonWidget { isEnabled(): boolean { @@ -27,6 +28,10 @@ export default class EditButton extends OnClickButtonWidget { } async refreshWithNote(note: FNote): Promise { + if (options.is("databaseReadonly")) { + this.toggleInt(false); + return; + } if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) { this.toggleInt(false); } else { diff --git a/apps/client/src/widgets/note_tree.ts b/apps/client/src/widgets/note_tree.ts index 9b8ab10cd..825e6d8f8 100644 --- a/apps/client/src/widgets/note_tree.ts +++ b/apps/client/src/widgets/note_tree.ts @@ -1172,16 +1172,19 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { let noneCollapsedYet = true; - this.tree.getRootNode().visit((node) => { - if (node.isExpanded() && !noteIdsToKeepExpanded.has(node.data.noteId)) { - node.setExpanded(false); + if (!options.is("databaseReadonly")) { + // can't change expanded notes when database is readonly + this.tree.getRootNode().visit((node) => { + if (node.isExpanded() && !noteIdsToKeepExpanded.has(node.data.noteId)) { + node.setExpanded(false); - if (noneCollapsedYet) { - toastService.showMessage(t("note_tree.auto-collapsing-notes-after-inactivity")); - noneCollapsedYet = false; + if (noneCollapsedYet) { + toastService.showMessage(t("note_tree.auto-collapsing-notes-after-inactivity")); + noneCollapsedYet = false; + } } - } - }, false); + }, false); + } this.filterHoistedBranch(true); }, 600 * 1000); diff --git a/apps/client/src/widgets/type_widgets/canvas.ts b/apps/client/src/widgets/type_widgets/canvas.ts index 962a7ff4a..490350686 100644 --- a/apps/client/src/widgets/type_widgets/canvas.ts +++ b/apps/client/src/widgets/type_widgets/canvas.ts @@ -3,6 +3,7 @@ import utils from "../../services/utils.js"; import linkService from "../../services/link.js"; import server from "../../services/server.js"; import type FNote from "../../entities/fnote.js"; +import options from "../../services/options.js"; import type { ExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types"; import type { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem, SceneData } from "@excalidraw/excalidraw/types"; import type { JSX } from "react"; @@ -447,6 +448,9 @@ export default class ExcalidrawTypeWidget extends TypeWidget { } onChangeHandler() { + if (options.is("databaseReadonly")) { + return; + } // changeHandler is called upon any tiny change in excalidraw. button clicked, hover, etc. // make sure only when a new element is added, we actually save something. const isNewSceneVersion = this.isNewSceneVersion(); @@ -540,7 +544,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget { this.saveData(); }, onChange: () => this.onChangeHandler(), - viewModeEnabled: false, + viewModeEnabled: options.is("databaseReadonly"), zenModeEnabled: false, gridModeEnabled: false, isCollaborating: false, @@ -567,6 +571,10 @@ export default class ExcalidrawTypeWidget extends TypeWidget { * info: sceneVersions are not incrementing. it seems to be a pseudo-random number */ isNewSceneVersion() { + if (options.is("databaseReadonly")) { + return false; + } + const sceneVersion = this.getSceneVersion(); return ( diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index a31206404..3a0a9b06b 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -7,6 +7,7 @@ import ValidationError from "../../errors/validation_error.js"; import type { Request } from "express"; import { changeLanguage, getLocales } from "../../services/i18n.js"; import type { OptionNames } from "@triliumnext/commons"; +import config from "../../services/config.js"; // options allowed to be updated directly in the Options dialog const ALLOWED_OPTIONS = new Set([ @@ -127,6 +128,12 @@ function getOptions() { } resultMap["isPasswordSet"] = optionMap["passwordVerificationHash"] ? "true" : "false"; + // if database is read-only, disable editing in UI by setting 0 here + if (config.General.readOnly) { + resultMap["autoReadonlySizeText"] = "0"; + resultMap["autoReadonlySizeCode"] = "0"; + resultMap["databaseReadonly"] = "true"; + } return resultMap; } diff --git a/apps/server/src/services/config.ts b/apps/server/src/services/config.ts index 1d7cc9dec..2089c03ce 100644 --- a/apps/server/src/services/config.ts +++ b/apps/server/src/services/config.ts @@ -21,6 +21,7 @@ export interface TriliumConfig { noAuthentication: boolean; noBackup: boolean; noDesktopIcon: boolean; + readOnly: boolean; }; Network: { host: string; @@ -62,7 +63,10 @@ const config: TriliumConfig = { envToBoolean(process.env.TRILIUM_GENERAL_NOBACKUP) || iniConfig.General.noBackup || false, noDesktopIcon: - envToBoolean(process.env.TRILIUM_GENERAL_NODESKTOPICON) || iniConfig.General.noDesktopIcon || false + envToBoolean(process.env.TRILIUM_GENERAL_NODESKTOPICON) || iniConfig.General.noDesktopIcon || false, + + readOnly: + envToBoolean(process.env.TRILIUM_GENERAL_READONLY) || iniConfig.General.readOnly || false }, Network: { diff --git a/apps/server/src/services/sql.ts b/apps/server/src/services/sql.ts index 1bbfeb348..15f5af389 100644 --- a/apps/server/src/services/sql.ts +++ b/apps/server/src/services/sql.ts @@ -13,18 +13,20 @@ import Database from "better-sqlite3"; import ws from "./ws.js"; import becca_loader from "../becca/becca_loader.js"; import entity_changes from "./entity_changes.js"; +import config from "./config.js"; let dbConnection: DatabaseType = buildDatabase(); let statementCache: Record = {}; function buildDatabase() { + // for integration tests, ignore the config's readOnly setting if (process.env.TRILIUM_INTEGRATION_TEST === "memory") { return buildIntegrationTestDatabase(); } else if (process.env.TRILIUM_INTEGRATION_TEST === "memory-no-store") { return new Database(":memory:"); } - return new Database(dataDir.DOCUMENT_PATH); + return new Database(dataDir.DOCUMENT_PATH, { readonly: config.General.readOnly }); } function buildIntegrationTestDatabase(dbPath?: string) { @@ -208,6 +210,13 @@ function getColumn(query: string, params: Params = []): T[] { } function execute(query: string, params: Params = []): RunResult { + if (config.General.readOnly && (query.startsWith("UPDATE") || query.startsWith("INSERT") || query.startsWith("DELETE"))) { + log.error(`read-only DB ignored: ${query} with parameters ${JSON.stringify(params)}`); + return { + changes: 0, + lastInsertRowid: 0 + }; + } return wrap(query, (s) => s.run(params)) as RunResult; } diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts index f5f7e4e48..5fb0bd573 100644 --- a/apps/server/src/services/sql_init.ts +++ b/apps/server/src/services/sql_init.ts @@ -186,6 +186,9 @@ function setDbAsInitialized() { } function optimize() { + if (config.General.readOnly) { + return; + } log.info("Optimizing database"); const start = Date.now();