feat(standalone/setup): set up new document

This commit is contained in:
Elian Doran
2026-03-23 21:02:49 +02:00
parent 39972a9bd7
commit bf4b5dad5a
9 changed files with 157 additions and 234 deletions

View File

@@ -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);

View File

@@ -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");
}}
/>
<SetupOptionCard
@@ -63,6 +66,16 @@ function SetupOptions({ setState }: { setState: (state: State) => 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 (
<div class="page sync-from-server">
<h1>{t("setup.sync-from-server-page-title")}</h1>
@@ -71,18 +84,18 @@ function SyncFromServer({ setState }: { setState: (state: State) => void }) {
<main>
<form>
<FormItemWithIcon icon="bx bx-server">
<FormTextBox placeholder="https://example.com" />
<FormTextBox placeholder="https://example.com" currentValue={serverUrl} onChange={setServerUrl} />
</FormItemWithIcon>
<FormItemWithIcon icon="bx bx-lock">
<FormTextBox placeholder={t("setup.password-placeholder")} type="password" />
<FormTextBox placeholder={t("setup.password-placeholder")} type="password" currentValue={password} onChange={setPassword} />
</FormItemWithIcon>
</form>
</main>
<footer>
<Button text={t("setup.button-back")} onClick={() => setState("firstOptions")} kind="lowProfile" />
<Button text={t("setup.button-finish-setup")} kind="primary" />
<Button text={t("setup.button-finish-setup")} kind="primary" onClick={handleFinishSetup} />
</footer>
</div>
);

View File

@@ -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);

View File

@@ -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<SetupStatusResponse>("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<void>("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<T>(method: string, path: string, body?: string | {}): Promise<T> {
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<SetupSyncSeedResponse>({
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
};

View File

@@ -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");

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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<void>();
@@ -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 };