diff --git a/apps/client-standalone/src/lightweight/browser_routes.ts b/apps/client-standalone/src/lightweight/browser_routes.ts index 2f7426e6e3..6cc13e9653 100644 --- a/apps/client-standalone/src/lightweight/browser_routes.ts +++ b/apps/client-standalone/src/lightweight/browser_routes.ts @@ -179,10 +179,23 @@ function apiResultHandler(_req: any, res: ResultHandlerResponse, result: unknown } /** - * No-op auth middleware for standalone — there's no authentication. + * No-op middleware stubs for standalone mode. + * + * In a browser context there is no network authentication, rate limiting, + * or multi-user access, so all auth/rate-limit middleware is a no-op. + * + * `checkAppNotInitialized` still guards setup routes: if the database is + * already initialised the middleware throws so the route handler is never + * reached (mirrors the server behaviour). */ -function checkApiAuth() { - // No authentication in standalone mode. +function noopMiddleware() { + // No-op. +} + +function checkAppNotInitialized() { + if (sql_init.isDbInitialized()) { + throw new Error("App already initialized."); + } } /** @@ -207,11 +220,15 @@ export function registerRoutes(router: BrowserRouter): void { const apiRoute = createApiRoute(router, true); routes.buildSharedApiRoutes({ route: createRoute(router), + asyncRoute: createRoute(router), apiRoute, asyncApiRoute: createApiRoute(router, false), apiResultHandler, - checkApiAuth, - checkApiAuthOrElectron: checkApiAuth + checkApiAuth: noopMiddleware, + checkApiAuthOrElectron: noopMiddleware, + checkAppNotInitialized, + checkCredentials: noopMiddleware, + loginRateLimiter: noopMiddleware }); apiRoute('get', '/bootstrap', bootstrapRoute); diff --git a/apps/client/src/setup.tsx b/apps/client/src/setup.tsx index da127bcfc9..c4b2c4922b 100644 --- a/apps/client/src/setup.tsx +++ b/apps/client/src/setup.tsx @@ -4,9 +4,9 @@ import { ComponentChildren, render } from "preact"; import { useState } from "preact/hooks"; import { initLocale, t } from "./services/i18n"; +import server from "./services/server"; import Button from "./widgets/react/Button"; import { CardFrame } from "./widgets/react/Card"; -import FormGroup from "./widgets/react/FormGroup"; import FormTextBox from "./widgets/react/FormTextBox"; import Icon from "./widgets/react/Icon"; @@ -42,6 +42,9 @@ function SetupOptions({ setState }: { setState: (state: State) => void }) { icon="bx bx-file-blank" title={t("setup.new-document")} description={t("setup.new-document-description")} + onClick={() => { + server.post("setup/new-document"); + }} /> void }) { } function SyncFromServer({ setState }: { setState: (state: State) => void }) { + const [serverUrl, setServerUrl] = useState(""); + const [password, setPassword] = useState(""); + + function handleFinishSetup() { + server.post("setup/sync-from-server", { + syncServerHost: serverUrl, + password + }); + } + return (

{t("setup.sync-from-server-page-title")}

@@ -71,18 +84,18 @@ function SyncFromServer({ setState }: { setState: (state: State) => void }) {
- + - +
); diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index f42ddd3f33..d2bb246041 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -34,7 +34,6 @@ import passwordApiRoute from "./api/password.js"; import recoveryCodes from './api/recovery_codes.js'; import scriptRoute from "./api/script.js"; import senderRoute from "./api/sender.js"; -import setupApiRoute from "./api/setup.js"; import systemInfoRoute from "./api/system_info.js"; import totp from './api/totp.js'; // API routes @@ -83,11 +82,15 @@ function register(app: express.Application) { routes.buildSharedApiRoutes({ route, + asyncRoute, apiRoute, asyncApiRoute, apiResultHandler, checkApiAuth: auth.checkApiAuth, - checkApiAuthOrElectron: auth.checkApiAuthOrElectron + checkApiAuthOrElectron: auth.checkApiAuthOrElectron, + checkAppNotInitialized: auth.checkAppNotInitialized, + checkCredentials: auth.checkCredentials, + loginRateLimiter }); route(PUT, "/api/notes/:noteId/file", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], filesRoute.updateFile, apiResultHandler); @@ -149,13 +152,6 @@ function register(app: express.Application) { // docker health check route(GET, "/api/health-check", [], () => ({ status: "ok" }), apiResultHandler); - // group of the services below are meant to be executed from the outside - route(GET, "/api/setup/status", [], setupApiRoute.getStatus, apiResultHandler); - asyncRoute(PST, "/api/setup/new-document", [auth.checkAppNotInitialized], setupApiRoute.setupNewDocument, apiResultHandler); - asyncRoute(PST, "/api/setup/sync-from-server", [auth.checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler); - route(GET, "/api/setup/sync-seed", [loginRateLimiter, auth.checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler); - asyncRoute(PST, "/api/setup/sync-seed", [auth.checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler); - route(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler); // this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username) apiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession); diff --git a/apps/server/src/services/setup.ts b/apps/server/src/services/setup.ts deleted file mode 100644 index 8acf314731..0000000000 --- a/apps/server/src/services/setup.ts +++ /dev/null @@ -1,120 +0,0 @@ -import syncService from "./sync.js"; -import log from "./log.js"; -import sqlInit from "./sql_init.js"; -import optionService from "./options.js"; -import syncOptions from "./sync_options.js"; -import { request } from "@triliumnext/core"; -import appInfo from "./app_info.js"; -import { timeLimit } from "./utils.js"; -import becca from "../becca/becca.js"; -import type { SetupStatusResponse, SetupSyncSeedResponse } from "./api-interface.js"; - -async function hasSyncServerSchemaAndSeed() { - const response = await requestToSyncServer("GET", "/api/setup/status"); - - if (response.syncVersion !== appInfo.syncVersion) { - throw new Error( - `Could not setup sync since local sync protocol version is ${appInfo.syncVersion} while remote is ${response.syncVersion}. To fix this issue, use same Trilium version on all instances.` - ); - } - - return response.schemaExists; -} - -function triggerSync() { - log.info("Triggering sync."); - - // it's ok to not wait for it here - syncService.sync().then((res) => { - if (res.success) { - sqlInit.setDbAsInitialized(); - } - }); -} - -async function sendSeedToSyncServer() { - log.info("Initiating sync to server"); - - await requestToSyncServer("POST", "/api/setup/sync-seed", { - options: getSyncSeedOptions(), - syncVersion: appInfo.syncVersion - }); - - // this is a completely new sync, need to reset counters. If this was not a new sync, - // the previous request would have failed. - optionService.setOption("lastSyncedPush", 0); - optionService.setOption("lastSyncedPull", 0); -} - -async function requestToSyncServer(method: string, path: string, body?: string | {}): Promise { - const timeout = syncOptions.getSyncTimeout(); - - return (await timeLimit( - request.exec({ - method, - url: syncOptions.getSyncServerHost() + path, - body, - proxy: syncOptions.getSyncProxy(), - timeout: timeout - }), - timeout - )) as T; -} - -async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string) { - if (sqlInit.isDbInitialized()) { - return { - result: "failure", - error: "DB is already initialized." - }; - } - - try { - log.info("Getting document options FROM sync server."); - - // the response is expected to contain documentId and documentSecret options - const resp = await request.exec({ - method: "get", - url: `${syncServerHost}/api/setup/sync-seed`, - auth: { password }, - proxy: syncProxy, - timeout: 30000 // seed request should not take long - }); - - if (resp.syncVersion !== appInfo.syncVersion) { - const message = `Could not setup sync since local sync protocol version is ${appInfo.syncVersion} while remote is ${resp.syncVersion}. To fix this issue, use same Trilium version on all instances.`; - - log.error(message); - - return { - result: "failure", - error: message - }; - } - - await sqlInit.createDatabaseForSync(resp.options, syncServerHost, syncProxy); - - triggerSync(); - - return { result: "success" }; - } catch (e: any) { - log.error(`Sync failed: '${e.message}', stack: ${e.stack}`); - - return { - result: "failure", - error: e.message - }; - } -} - -function getSyncSeedOptions() { - return [becca.getOption("documentId"), becca.getOption("documentSecret")]; -} - -export default { - hasSyncServerSchemaAndSeed, - triggerSync, - sendSeedToSyncServer, - setupSyncFromSyncServer, - getSyncSeedOptions -}; diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts index aa88a26a71..01f43c22c1 100644 --- a/apps/server/src/services/sql_init.ts +++ b/apps/server/src/services/sql_init.ts @@ -23,95 +23,6 @@ const initDbConnection = coreSqlInit.initDbConnection; const initializeDb = coreSqlInit.initializeDb; export const getDbSize = coreSqlInit.getDbSize; -/** - * Applies the database schema, creating the necessary tables and importing the demo content. - * - * @param skipDemoDb if set to `true`, then the demo database will not be imported, resulting in an empty root note. - * @throws {Error} if the database is already initialized. - */ -async function createInitialDatabase(skipDemoDb?: boolean) { - if (isDbInitialized()) { - throw new Error("DB is already initialized"); - } - - const schema = fs.readFileSync(`${resourceDir.DB_INIT_DIR}/schema.sql`, "utf-8"); - const demoFile = (!skipDemoDb ? fs.readFileSync(`${resourceDir.DB_INIT_DIR}/demo.zip`) : null); - - let rootNote!: BNote; - - // We have to import async since options init requires keyboard actions which require translations. - const optionsInitService = (await import("./options_init.js")).default; - const becca_loader = (await import("@triliumnext/core")).becca_loader; - - sql.transactional(() => { - log.info("Creating database schema ..."); - - sql.executeScript(schema); - - becca_loader.load(); - - log.info("Creating root note ..."); - - rootNote = new BNote({ - noteId: "root", - title: "root", - type: "text", - mime: "text/html" - }).save(); - - rootNote.setContent(""); - - new BBranch({ - noteId: "root", - parentNoteId: "none", - isExpanded: true, - notePosition: 10 - }).save(); - - optionsInitService.initDocumentOptions(); - optionsInitService.initNotSyncedOptions(true, {}); - optionsInitService.initStartupOptions(); - password.resetPassword(); - }); - - // Check hidden subtree. - // This ensures the existence of system templates, for the demo content. - console.log("Checking hidden subtree at first start."); - cls.init(() => hidden_subtree.checkHiddenSubtree()); - - // Import demo content. - log.info("Importing demo content..."); - - const dummyTaskContext = new TaskContext("no-progress-reporting", "importNotes", null); - - if (demoFile) { - await zipImportService.importZip(dummyTaskContext, demoFile, rootNote); - } - - // Post-demo. - sql.transactional(() => { - // this needs to happen after ZIP import, - // the previous solution was to move option initialization here, but then the important parts of initialization - // are not all in one transaction (because ZIP import is async and thus not transactional) - - const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 ORDER BY notePosition"); - - optionService.setOption( - "openNoteContexts", - JSON.stringify([ - { - notePath: startNoteId, - active: true - } - ]) - ); - }); - - log.info("Schema and initial content generated."); - - initDbConnection(); -} - async function createDatabaseForSync(options: OptionRow[], syncServerHost = "", syncProxy = "") { log.info("Creating database for sync"); diff --git a/apps/server/src/assets/db/schema.sql b/packages/trilium-core/src/assets/schema.sql similarity index 100% rename from apps/server/src/assets/db/schema.sql rename to packages/trilium-core/src/assets/schema.sql diff --git a/apps/server/src/routes/api/setup.ts b/packages/trilium-core/src/routes/api/setup.ts similarity index 93% rename from apps/server/src/routes/api/setup.ts rename to packages/trilium-core/src/routes/api/setup.ts index 718bc37a75..6a929d6d57 100644 --- a/apps/server/src/routes/api/setup.ts +++ b/packages/trilium-core/src/routes/api/setup.ts @@ -1,8 +1,6 @@ -"use strict"; - import sqlInit from "../../services/sql_init.js"; import setupService from "../../services/setup.js"; -import log from "../../services/log.js"; +import { getLog } from "../../services/log.js"; import appInfo from "../../services/app_info.js"; import type { Request } from "express"; @@ -27,6 +25,7 @@ function setupSyncFromServer(req: Request) { function saveSyncSeed(req: Request) { const { options, syncVersion } = req.body; + const log = getLog(); if (appInfo.syncVersion !== syncVersion) { const message = `Could not setup sync since local sync protocol version is ${appInfo.syncVersion} while remote is ${syncVersion}. To fix this issue, use same Trilium version on all instances.`; @@ -42,7 +41,7 @@ function saveSyncSeed(req: Request) { log.info("Saved sync seed."); - sqlInit.createDatabaseForSync(options); + // sqlInit.createDatabaseForSync(options); } /** @@ -74,7 +73,7 @@ function saveSyncSeed(req: Request) { * - user-password: [] */ function getSyncSeed() { - log.info("Serving sync seed."); + getLog().info("Serving sync seed."); return { options: setupService.getSyncSeedOptions(), diff --git a/packages/trilium-core/src/routes/index.ts b/packages/trilium-core/src/routes/index.ts index e0e3c9856e..ed088aa1a6 100644 --- a/packages/trilium-core/src/routes/index.ts +++ b/packages/trilium-core/src/routes/index.ts @@ -23,6 +23,7 @@ import syncApiRoute from "./api/sync"; import autocompleteApiRoute from "./api/autocomplete"; import similarNotesRoute from "./api/similar_notes"; import imageRoute from "./api/image"; +import setupApiRoute from "./api/setup"; // TODO: Deduplicate with routes.ts const GET = "get", @@ -33,14 +34,18 @@ const GET = "get", interface SharedApiRoutesContext { route: any; + asyncRoute: any; apiRoute: any; asyncApiRoute: any; checkApiAuth: any; apiResultHandler: any; checkApiAuthOrElectron: any; + checkAppNotInitialized: any; + loginRateLimiter: any; + checkCredentials: any; } -export function buildSharedApiRoutes({ route, apiRoute, asyncApiRoute, checkApiAuth, apiResultHandler, checkApiAuthOrElectron }: SharedApiRoutesContext) { +export function buildSharedApiRoutes({ route, asyncRoute, apiRoute, asyncApiRoute, checkApiAuth, apiResultHandler, checkApiAuthOrElectron, checkAppNotInitialized, checkCredentials, loginRateLimiter }: SharedApiRoutesContext) { apiRoute(GET, '/api/tree', treeApiRoute.getTree); apiRoute(PST, '/api/tree/load', treeApiRoute.load); @@ -111,6 +116,13 @@ export function buildSharedApiRoutes({ route, apiRoute, asyncApiRoute, checkApiA route(GET, "/api/attachments/:attachmentId/image/:filename", [checkApiAuthOrElectron], imageRoute.returnAttachedImage); route(GET, "/api/images/:noteId/:filename", [checkApiAuthOrElectron], imageRoute.returnImageFromNote); + // group of the services below are meant to be executed from the outside + route(GET, "/api/setup/status", [], setupApiRoute.getStatus, apiResultHandler); + asyncRoute(PST, "/api/setup/new-document", [checkAppNotInitialized], setupApiRoute.setupNewDocument, apiResultHandler); + asyncRoute(PST, "/api/setup/sync-from-server", [checkAppNotInitialized], setupApiRoute.setupSyncFromServer, apiResultHandler); + route(GET, "/api/setup/sync-seed", [loginRateLimiter, checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler); + asyncRoute(PST, "/api/setup/sync-seed", [checkAppNotInitialized], setupApiRoute.saveSyncSeed, apiResultHandler); + asyncApiRoute(PST, "/api/sync/test", syncApiRoute.testSync); asyncApiRoute(PST, "/api/sync/now", syncApiRoute.syncNow); apiRoute(PST, "/api/sync/fill-entity-changes", syncApiRoute.fillEntityChanges); diff --git a/packages/trilium-core/src/services/sql_init.ts b/packages/trilium-core/src/services/sql_init.ts index 1301a6b56a..03f38e1618 100644 --- a/packages/trilium-core/src/services/sql_init.ts +++ b/packages/trilium-core/src/services/sql_init.ts @@ -7,6 +7,11 @@ import optionService from "./options"; import eventService from "./events"; import { getContext } from "./context"; import config from "./config"; +import BNote from "../becca/entities/bnote"; +import BBranch from "../becca/entities/bbranch"; +import schema from "../assets/schema.sql?raw"; +import hidden_subtree from "./hidden_subtree"; +import TaskContext from "./task_context"; export const dbReady = deferred(); @@ -122,4 +127,94 @@ function initializeDb() { }); } -export default { isDbInitialized, createDatabaseForSync, setDbAsInitialized, schemaExists, getDbSize, initDbConnection, dbReady, initializeDb }; +/** + * Applies the database schema, creating the necessary tables and importing the demo content. + * + * @param skipDemoDb if set to `true`, then the demo database will not be imported, resulting in an empty root note. + * @throws {Error} if the database is already initialized. + */ +async function createInitialDatabase(skipDemoDb?: boolean) { + if (isDbInitialized()) { + throw new Error("DB is already initialized"); + } + + let rootNote!: BNote; + + // We have to import async since options init requires keyboard actions which require translations. + const optionsInitService = (await import("./options_init.js")).default; + const becca_loader = (await import("../becca/becca_loader.js")).default; + + const sql = getSql(); + const log = getLog(); + sql.transactional(() => { + log.info("Creating database schema ..."); + + console.log("Got schema:", schema.substring(0, 100)); // Log the first 100 characters of the schema to verify it's loaded correctly + sql.executeScript(schema); + + becca_loader.load(); + + log.info("Creating root note ..."); + + rootNote = new BNote({ + noteId: "root", + title: "root", + type: "text", + mime: "text/html" + }).save(); + + rootNote.setContent(""); + + new BBranch({ + noteId: "root", + parentNoteId: "none", + isExpanded: true, + notePosition: 10 + }).save(); + + // Bring in option init. + optionsInitService.initDocumentOptions(); + optionsInitService.initNotSyncedOptions(true, {}); + optionsInitService.initStartupOptions(); + // password.resetPassword(); + }); + + // Check hidden subtree. + // This ensures the existence of system templates, for the demo content. + console.log("Checking hidden subtree at first start."); + getContext().init(() => hidden_subtree.checkHiddenSubtree()); + + // Import demo content. + log.info("Importing demo content..."); + + const dummyTaskContext = new TaskContext("no-progress-reporting", "importNotes", null); + + // if (demoFile) { + // await zipImportService.importZip(dummyTaskContext, demoFile, rootNote); + // } + + // Post-demo. + sql.transactional(() => { + // this needs to happen after ZIP import, + // the previous solution was to move option initialization here, but then the important parts of initialization + // are not all in one transaction (because ZIP import is async and thus not transactional) + + const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 ORDER BY notePosition"); + + optionService.setOption( + "openNoteContexts", + JSON.stringify([ + { + notePath: startNoteId, + active: true + } + ]) + ); + }); + + log.info("Schema and initial content generated."); + + initDbConnection(); +} + +export default { isDbInitialized, createDatabaseForSync, setDbAsInitialized, schemaExists, getDbSize, initDbConnection, dbReady, initializeDb, createInitialDatabase };