mirror of
https://github.com/zadam/trilium.git
synced 2026-03-06 20:20:56 +01:00
Compare commits
51 Commits
feature/to
...
totp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16083dbb85 | ||
|
|
c356159664 | ||
|
|
579be68ca1 | ||
|
|
a6326a682e | ||
|
|
ee21185e64 | ||
|
|
6d0676c37d | ||
|
|
1d4768a581 | ||
|
|
d086bb7fcb | ||
|
|
2607c4a32e | ||
|
|
624333a2ef | ||
|
|
d4acb37f21 | ||
|
|
6c1a1e9812 | ||
|
|
9a13641f9b | ||
|
|
699e0624c9 | ||
|
|
47ceb0d4d2 | ||
|
|
15c42f4a09 | ||
|
|
bf8401bb26 | ||
|
|
f234433c63 | ||
|
|
d610c63c28 | ||
|
|
5e820a407f | ||
|
|
62610979b7 | ||
|
|
7767116b3d | ||
|
|
8d9c888481 | ||
|
|
11e4b672d1 | ||
|
|
bace3daadc | ||
|
|
dee5380e60 | ||
|
|
bc6a6fd860 | ||
|
|
e928337fe9 | ||
|
|
432f86ea4b | ||
|
|
5d2daecee0 | ||
|
|
7c8eb311af | ||
|
|
4ac22678df | ||
|
|
5057c02176 | ||
|
|
929e5ee590 | ||
|
|
d301e56216 | ||
|
|
3c22ab8c9c | ||
|
|
0212398815 | ||
|
|
db0c515bad | ||
|
|
9b4f8c5003 | ||
|
|
85d8c4c8fa | ||
|
|
5afab6938a | ||
|
|
a437169ad5 | ||
|
|
f632d3aeb6 | ||
|
|
513fffcb1a | ||
|
|
178a3b4318 | ||
|
|
d4810be0ab | ||
|
|
cf7ac03a70 | ||
|
|
446cc7ab5c | ||
|
|
d595a1d019 | ||
|
|
6004f35f66 | ||
|
|
aac5127e1f |
27
.github/workflows/dev.yml
vendored
27
.github/workflows/dev.yml
vendored
@@ -40,11 +40,32 @@ jobs:
|
||||
- name: Run the client-side tests
|
||||
run: pnpm run --filter=client test
|
||||
|
||||
- name: Upload client test report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: client-test-report
|
||||
path: apps/client/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run the server-side tests
|
||||
run: pnpm run --filter=server test
|
||||
|
||||
- name: Upload server test report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: server-test-report
|
||||
path: apps/server/test-output/vitest/html/
|
||||
retention-days: 30
|
||||
|
||||
- name: Run CKEditor e2e tests
|
||||
run: |
|
||||
pnpm run --filter=ckeditor5-mermaid test
|
||||
pnpm run --filter=ckeditor5-math test
|
||||
|
||||
- name: Run the rest of the tests
|
||||
run: pnpm run --filter=\!client --filter=\!server test"
|
||||
run: pnpm run --filter=\!client --filter=\!server --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
|
||||
|
||||
build_docker:
|
||||
name: Build Docker image
|
||||
@@ -69,7 +90,7 @@ jobs:
|
||||
- name: Trigger server build
|
||||
run: pnpm run server:build
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
cache-from: type=gha
|
||||
@@ -106,7 +127,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
|
||||
16
.github/workflows/main-docker.yml
vendored
16
.github/workflows/main-docker.yml
vendored
@@ -59,7 +59,7 @@ jobs:
|
||||
run: pnpm run server:build
|
||||
|
||||
- name: Build and export to Docker
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -175,13 +175,13 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: apps/server
|
||||
file: apps/server/${{ matrix.dockerfile }}
|
||||
@@ -232,14 +232,14 @@ jobs:
|
||||
uses: imjasonh/setup-crane@v0.4
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_REGISTRY }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -247,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
|
||||
@@ -57,13 +57,13 @@
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.3",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.12.3",
|
||||
"mind-elixir": "5.9.1",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.4",
|
||||
"react-i18next": "16.5.4",
|
||||
"react-i18next": "16.5.5",
|
||||
"react-window": "2.2.7",
|
||||
"reveal.js": "5.2.1",
|
||||
"rrule": "2.8.1",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import "jquery";
|
||||
import utils from "./services/utils.js";
|
||||
|
||||
import ko from "knockout";
|
||||
|
||||
import utils from "./services/utils.js";
|
||||
|
||||
// TriliumNextTODO: properly make use of below types
|
||||
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";
|
||||
@@ -16,6 +18,8 @@ class SetupModel {
|
||||
syncServerHost: ko.Observable<string | undefined>;
|
||||
syncProxy: ko.Observable<string | undefined>;
|
||||
password: ko.Observable<string | undefined>;
|
||||
totpToken: ko.Observable<string | undefined>;
|
||||
totpEnabled: ko.Observable<boolean>;
|
||||
|
||||
constructor(syncInProgress: boolean) {
|
||||
this.syncInProgress = syncInProgress;
|
||||
@@ -27,6 +31,8 @@ class SetupModel {
|
||||
this.syncServerHost = ko.observable();
|
||||
this.syncProxy = ko.observable();
|
||||
this.password = ko.observable();
|
||||
this.totpToken = ko.observable();
|
||||
this.totpEnabled = ko.observable(false);
|
||||
|
||||
if (this.syncInProgress) {
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
@@ -40,7 +46,7 @@ class SetupModel {
|
||||
return !!this.setupType();
|
||||
}
|
||||
|
||||
selectSetupType() {
|
||||
async selectSetupType() {
|
||||
if (this.setupType() === "new-document") {
|
||||
this.step("new-document-in-progress");
|
||||
|
||||
@@ -52,6 +58,24 @@ class SetupModel {
|
||||
}
|
||||
}
|
||||
|
||||
async checkTotpStatus() {
|
||||
const syncServerHost = this.syncServerHost();
|
||||
if (!syncServerHost) {
|
||||
this.totpEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await $.post("api/setup/check-server-totp", {
|
||||
syncServerHost
|
||||
});
|
||||
this.totpEnabled(!!resp.totpEnabled);
|
||||
} catch {
|
||||
// If we can't reach the server, don't show TOTP field yet
|
||||
this.totpEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
back() {
|
||||
this.step("setup-type");
|
||||
this.setupType("");
|
||||
@@ -72,11 +96,22 @@ class SetupModel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check TOTP status before submitting (in case it wasn't checked yet)
|
||||
await this.checkTotpStatus();
|
||||
|
||||
const totpToken = this.totpToken();
|
||||
|
||||
if (this.totpEnabled() && !totpToken) {
|
||||
showAlert("TOTP token can't be empty when two-factor authentication is enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// not using server.js because it loads too many dependencies
|
||||
const resp = await $.post("api/setup/sync-from-server", {
|
||||
syncServerHost: syncServerHost,
|
||||
syncProxy: syncProxy,
|
||||
password: password
|
||||
syncServerHost,
|
||||
syncProxy,
|
||||
password,
|
||||
totpToken
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./TableOfContents.css";
|
||||
|
||||
import { attributeChangeAffectsHeading, CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
|
||||
import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
@@ -170,14 +170,11 @@ function EditableTextTableOfContents() {
|
||||
|
||||
const affectsHeadings = changes.some( change => {
|
||||
return (
|
||||
change.type === 'insert' || change.type === 'remove' ||
|
||||
(change.type === 'attribute' && attributeChangeAffectsHeading(change, textEditor))
|
||||
change.type === 'insert' || change.type === 'remove' || (change.type === 'attribute' && change.attributeKey === 'headingLevel')
|
||||
);
|
||||
});
|
||||
if (affectsHeadings) {
|
||||
requestAnimationFrame(() => {
|
||||
setHeadings(extractTocFromTextEditor(textEditor));
|
||||
});
|
||||
setHeadings(extractTocFromTextEditor(textEditor));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -120,7 +120,11 @@ export default defineConfig(() => ({
|
||||
environment: "happy-dom",
|
||||
setupFiles: [
|
||||
"./src/test/setup.ts"
|
||||
]
|
||||
],
|
||||
reporters: [
|
||||
"verbose",
|
||||
["html", { outputFile: "./test-output/vitest/html/index.html" }]
|
||||
],
|
||||
},
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.6.1",
|
||||
"electron": "40.8.0",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "40.6.1",
|
||||
"electron": "40.8.0",
|
||||
"fs-extra": "11.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -81,15 +81,15 @@
|
||||
"csrf-csrf": "3.2.2",
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "4.0.1",
|
||||
"electron": "40.6.1",
|
||||
"ejs": "5.0.1",
|
||||
"electron": "40.8.0",
|
||||
"electron-debug": "4.1.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.2.1",
|
||||
"express-http-proxy": "2.1.2",
|
||||
"express-openid-connect": "2.19.4",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"express-rate-limit": "8.3.0",
|
||||
"express-session": "1.19.0",
|
||||
"file-uri-to-path": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
@@ -106,9 +106,9 @@
|
||||
"is-svg": "6.1.0",
|
||||
"jimp": "1.6.0",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"marked": "17.0.3",
|
||||
"marked": "17.0.4",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.1.0",
|
||||
"multer": "2.1.1",
|
||||
"normalize-strings": "1.1.1",
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
|
||||
@@ -86,8 +86,9 @@ export default async function buildApp() {
|
||||
app.use(`/robots.txt`, express.static(path.join(publicAssetsDir, "robots.txt")));
|
||||
app.use(`/icon.png`, express.static(path.join(publicAssetsDir, "icon.png")));
|
||||
|
||||
const sessionParser = (await import("./routes/session_parser.js")).default;
|
||||
const { default: sessionParser, startSessionCleanup } = await import("./routes/session_parser.js");
|
||||
app.use(sessionParser);
|
||||
startSessionCleanup();
|
||||
app.use(favicon(path.join(assetsDir, isDev ? "icon-dev.ico" : "icon.ico")));
|
||||
|
||||
if (openID.isOpenIDEnabled())
|
||||
@@ -98,16 +99,16 @@ export default async function buildApp() {
|
||||
custom.register(app);
|
||||
error_handlers.register(app);
|
||||
|
||||
// triggers sync timer
|
||||
await import("./services/sync.js");
|
||||
const { startSyncTimer } = await import("./services/sync.js");
|
||||
startSyncTimer();
|
||||
|
||||
// triggers backup timer
|
||||
await import("./services/backup.js");
|
||||
|
||||
// trigger consistency checks timer
|
||||
await import("./services/consistency_checks.js");
|
||||
const { startConsistencyChecks } = await import("./services/consistency_checks.js");
|
||||
startConsistencyChecks();
|
||||
|
||||
await import("./services/scheduler.js");
|
||||
const { startScheduler } = await import("./services/scheduler.js");
|
||||
startScheduler();
|
||||
|
||||
startScheduledCleanup();
|
||||
|
||||
|
||||
@@ -155,6 +155,8 @@
|
||||
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
|
||||
"password": "密码",
|
||||
"password-placeholder": "密码",
|
||||
"totp-token": "TOTP 验证码",
|
||||
"totp-token-placeholder": "请输入 TOTP 验证码",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成设置"
|
||||
},
|
||||
|
||||
@@ -252,6 +252,8 @@
|
||||
"proxy-instruction": "If you leave proxy setting blank, system proxy will be used (applies to the desktop application only)",
|
||||
"password": "Password",
|
||||
"password-placeholder": "Password",
|
||||
"totp-token": "TOTP Token",
|
||||
"totp-token-placeholder": "Enter your TOTP code",
|
||||
"back": "Back",
|
||||
"finish-setup": "Finish setup"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,8 @@
|
||||
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
|
||||
"password": "密碼",
|
||||
"password-placeholder": "密碼",
|
||||
"totp-token": "TOTP 驗證碼",
|
||||
"totp-token-placeholder": "請輸入 TOTP 驗證碼",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成設定"
|
||||
},
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sync-server-host"><%= t("setup_sync-from-server.server-host") %></label>
|
||||
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
|
||||
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost, event: { blur: checkTotpStatus }" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sync-proxy"><%= t("setup_sync-from-server.proxy-server") %></label>
|
||||
@@ -141,6 +141,10 @@
|
||||
<label for="password"><%= t("setup_sync-from-server.password") %></label>
|
||||
<input type="password" id="password" class="form-control" data-bind="value: password" placeholder="<%= t("setup_sync-from-server.password-placeholder") %>">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 8px;" data-bind="visible: totpEnabled">
|
||||
<label for="totpToken"><%= t("setup_sync-from-server.totp-token") %></label>
|
||||
<input type="text" id="totpToken" class="form-control" data-bind="value: totpToken" placeholder="<%= t("setup_sync-from-server.totp-token-placeholder") %>" autocomplete="one-time-code">
|
||||
</div>
|
||||
|
||||
<button type="button" data-bind="click: back" class="btn btn-secondary"><%= t("setup_sync-from-server.back") %></button>
|
||||
|
||||
|
||||
1
apps/server/src/express.d.ts
vendored
1
apps/server/src/express.d.ts
vendored
@@ -8,6 +8,7 @@ export declare module "express-serve-static-core" {
|
||||
|
||||
authorization?: string;
|
||||
"trilium-cred"?: string;
|
||||
"trilium-totp"?: string;
|
||||
"x-csrf-token"?: string;
|
||||
|
||||
"trilium-component-id"?: string;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
import sqlInit from "../../services/sql_init.js";
|
||||
import setupService from "../../services/setup.js";
|
||||
import log from "../../services/log.js";
|
||||
import appInfo from "../../services/app_info.js";
|
||||
|
||||
import type { Request } from "express";
|
||||
|
||||
import appInfo from "../../services/app_info.js";
|
||||
import log from "../../services/log.js";
|
||||
import setupService from "../../services/setup.js";
|
||||
import sqlInit from "../../services/sql_init.js";
|
||||
import totp from "../../services/totp.js";
|
||||
|
||||
function getStatus() {
|
||||
return {
|
||||
isInitialized: sqlInit.isDbInitialized(),
|
||||
schemaExists: sqlInit.schemaExists(),
|
||||
syncVersion: appInfo.syncVersion
|
||||
syncVersion: appInfo.syncVersion,
|
||||
totpEnabled: totp.isTotpEnabled()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,9 +22,9 @@ async function setupNewDocument() {
|
||||
}
|
||||
|
||||
function setupSyncFromServer(req: Request) {
|
||||
const { syncServerHost, syncProxy, password } = req.body;
|
||||
const { syncServerHost, syncProxy, password, totpToken } = req.body;
|
||||
|
||||
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password);
|
||||
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password, totpToken);
|
||||
}
|
||||
|
||||
function saveSyncSeed(req: Request) {
|
||||
@@ -82,10 +85,26 @@ function getSyncSeed() {
|
||||
};
|
||||
}
|
||||
|
||||
async function checkServerTotpStatus(req: Request) {
|
||||
const { syncServerHost } = req.body;
|
||||
|
||||
if (!syncServerHost) {
|
||||
return { totpEnabled: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await setupService.checkRemoteTotpStatus(syncServerHost);
|
||||
return { totpEnabled: !!resp.totpEnabled };
|
||||
} catch {
|
||||
return { totpEnabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getStatus,
|
||||
setupNewDocument,
|
||||
setupSyncFromServer,
|
||||
getSyncSeed,
|
||||
saveSyncSeed
|
||||
saveSyncSeed,
|
||||
checkServerTotpStatus
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { dayjs } from "@triliumnext/commons";
|
||||
import type { Application } from "express";
|
||||
import { SessionData } from "express-session";
|
||||
import supertest, { type Response } from "supertest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import cls from "../services/cls.js";
|
||||
import { type SQLiteSessionStore } from "./session_parser.js";
|
||||
@@ -20,6 +20,10 @@ describe("Login Route test", () => {
|
||||
({ sessionStore, CLEAN_UP_INTERVAL } = (await import("./session_parser.js")));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should return the login page, when using a GET request", async () => {
|
||||
|
||||
// RegExp for login page specific string in HTML
|
||||
|
||||
@@ -244,6 +244,7 @@ function register(app: express.Application) {
|
||||
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);
|
||||
asyncRoute(PST, "/api/setup/check-server-totp", [auth.checkAppNotInitialized], setupApiRoute.checkServerTotpStatus, apiResultHandler);
|
||||
|
||||
apiRoute(GET, "/api/autocomplete", autocompleteApiRoute.getAutocomplete);
|
||||
apiRoute(GET, "/api/autocomplete/notesCount", autocompleteApiRoute.getNotesCount);
|
||||
|
||||
@@ -113,11 +113,13 @@ const sessionParser: express.RequestHandler = session({
|
||||
store: sessionStore
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
// Clean up expired sesions.
|
||||
const now = Date.now();
|
||||
const result = sql.execute(/*sql*/`DELETE FROM sessions WHERE expires < ?`, now);
|
||||
console.log("Cleaning up expired sessions: ", result.changes);
|
||||
}, CLEAN_UP_INTERVAL);
|
||||
export function startSessionCleanup() {
|
||||
setInterval(() => {
|
||||
// Clean up expired sessions.
|
||||
const now = Date.now();
|
||||
const result = sql.execute(/*sql*/`DELETE FROM sessions WHERE expires < ?`, now);
|
||||
console.log("Cleaning up expired sessions: ", result.changes);
|
||||
}, CLEAN_UP_INTERVAL);
|
||||
}
|
||||
|
||||
export default sessionParser;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { OptionRow } from "@triliumnext/commons";
|
||||
export interface SetupStatusResponse {
|
||||
syncVersion: number;
|
||||
schemaExists: boolean;
|
||||
totpEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,10 @@ import options from "./options";
|
||||
|
||||
let app: Application;
|
||||
|
||||
function encodeCred(password: string): string {
|
||||
return Buffer.from(`dummy:${password}`).toString("base64");
|
||||
}
|
||||
|
||||
describe("Auth", () => {
|
||||
beforeAll(async () => {
|
||||
const buildApp = (await (import("../../src/app.js"))).default;
|
||||
@@ -72,4 +76,49 @@ describe("Auth", () => {
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Setup status endpoint", () => {
|
||||
it("returns totpEnabled: true when TOTP is enabled", async () => {
|
||||
cls.init(() => {
|
||||
options.setOption("mfaEnabled", "true");
|
||||
options.setOption("mfaMethod", "totp");
|
||||
options.setOption("totpVerificationHash", "hi");
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.get("/api/setup/status")
|
||||
.expect(200);
|
||||
expect(response.body.totpEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("returns totpEnabled: false when TOTP is disabled", async () => {
|
||||
cls.init(() => {
|
||||
options.setOption("mfaEnabled", "false");
|
||||
});
|
||||
const response = await supertest(app)
|
||||
.get("/api/setup/status")
|
||||
.expect(200);
|
||||
expect(response.body.totpEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkCredentials TOTP enforcement", () => {
|
||||
beforeAll(() => {
|
||||
config.General.noAuthentication = false;
|
||||
refreshAuth();
|
||||
});
|
||||
|
||||
it("does not require TOTP token when TOTP is disabled", async () => {
|
||||
cls.init(() => {
|
||||
options.setOption("mfaEnabled", "false");
|
||||
});
|
||||
// Will still fail with 401 due to wrong password, but NOT because of missing TOTP
|
||||
const response = await supertest(app)
|
||||
.get("/api/setup/sync-seed")
|
||||
.set("trilium-cred", encodeCred("wrongpassword"))
|
||||
.expect(401);
|
||||
// The error should be about password, not TOTP
|
||||
expect(response.text).toContain("Incorrect password");
|
||||
});
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import etapiTokenService from "./etapi_tokens.js";
|
||||
import log from "./log.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import { isElectron } from "./utils.js";
|
||||
import passwordEncryptionService from "./encryption/password_encryption.js";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
import attributes from "./attributes.js";
|
||||
import config from "./config.js";
|
||||
import passwordService from "./encryption/password.js";
|
||||
import totp from "./totp.js";
|
||||
import passwordEncryptionService from "./encryption/password_encryption.js";
|
||||
import recoveryCodeService from "./encryption/recovery_codes.js";
|
||||
import etapiTokenService from "./etapi_tokens.js";
|
||||
import log from "./log.js";
|
||||
import openID from "./open_id.js";
|
||||
import options from "./options.js";
|
||||
import attributes from "./attributes.js";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import totp from "./totp.js";
|
||||
import { isElectron } from "./utils.js";
|
||||
|
||||
let noAuthentication = false;
|
||||
refreshAuth();
|
||||
@@ -161,9 +163,28 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) {
|
||||
if (!passwordEncryptionService.verifyPassword(password)) {
|
||||
res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect password");
|
||||
log.info(`WARNING: Wrong password from ${req.ip}, rejecting.`);
|
||||
} else {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify TOTP if enabled
|
||||
if (totp.isTotpEnabled()) {
|
||||
const totpHeader = req.headers["trilium-totp"];
|
||||
const totpToken = Array.isArray(totpHeader) ? totpHeader[0] : totpHeader;
|
||||
if (typeof totpToken !== "string" || !totpToken) {
|
||||
res.setHeader("Content-Type", "text/plain").status(401).send("TOTP token is required");
|
||||
log.info(`WARNING: Missing or invalid TOTP token from ${req.ip}, rejecting.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept TOTP code or recovery code
|
||||
if (!totp.validateTOTP(totpToken) && !recoveryCodeService.verifyRecoveryCode(totpToken)) {
|
||||
res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect TOTP token");
|
||||
log.info(`WARNING: Wrong TOTP token from ${req.ip}, rejecting.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -953,12 +953,14 @@ function runEntityChangesChecks() {
|
||||
consistencyChecks.findEntityChangeIssues();
|
||||
}
|
||||
|
||||
sqlInit.dbReady.then(() => {
|
||||
setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
|
||||
export function startConsistencyChecks() {
|
||||
sqlInit.dbReady.then(() => {
|
||||
setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
|
||||
|
||||
// kickoff checks soon after startup (to not block the initial load)
|
||||
setTimeout(cls.wrap(runPeriodicChecks), 4 * 1000);
|
||||
});
|
||||
// kickoff checks soon after startup (to not block the initial load)
|
||||
setTimeout(cls.wrap(runPeriodicChecks), 4 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
runOnDemandChecks,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import optionService from "../options.js";
|
||||
import myScryptService from "./my_scrypt.js";
|
||||
import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js";
|
||||
import dataEncryptionService from "./data_encryption.js";
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
|
||||
import optionService from "../options.js";
|
||||
import { constantTimeCompare,randomSecureToken, toBase64 } from "../utils.js";
|
||||
import dataEncryptionService from "./data_encryption.js";
|
||||
import myScryptService from "./my_scrypt.js";
|
||||
|
||||
const TOTP_OPTIONS: Record<string, OptionNames> = {
|
||||
SALT: "totpEncryptionSalt",
|
||||
ENCRYPTED_SECRET: "totpEncryptedSecret",
|
||||
|
||||
@@ -24,6 +24,7 @@ async function testImport(fileName: string, mimetype: string) {
|
||||
const rootNote = becca.getNote("root");
|
||||
if (!rootNote) {
|
||||
reject("Missing root note.");
|
||||
return;
|
||||
}
|
||||
|
||||
const importedNote = single.importSingleFile(
|
||||
|
||||
@@ -62,6 +62,9 @@ async function exec<T>(opts: ExecOpts): Promise<T> {
|
||||
|
||||
if (opts.auth) {
|
||||
headers["trilium-cred"] = Buffer.from(`dummy:${opts.auth.password}`).toString("base64");
|
||||
if (opts.auth.totpToken) {
|
||||
headers["trilium-totp"] = opts.auth.totpToken;
|
||||
}
|
||||
}
|
||||
|
||||
const request = (await client).request({
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ExecOpts {
|
||||
cookieJar?: CookieJar;
|
||||
auth?: {
|
||||
password?: string;
|
||||
totpToken?: string;
|
||||
};
|
||||
timeout: number;
|
||||
body?: string | {};
|
||||
|
||||
@@ -35,39 +35,41 @@ function runNotesWithLabel(runAttrValue: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// If the database is already initialized, we need to check the hidden subtree. Otherwise, hidden subtree
|
||||
// is also checked before importing the demo.zip, so no need to do it again.
|
||||
if (sqlInit.isDbInitialized()) {
|
||||
console.log("Checking hidden subtree.");
|
||||
sqlInit.dbReady.then(() => cls.init(() => hiddenSubtreeService.checkHiddenSubtree()));
|
||||
}
|
||||
|
||||
// Periodic checks.
|
||||
sqlInit.dbReady.then(() => {
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
setTimeout(
|
||||
cls.wrap(() => runNotesWithLabel("backendStartup")),
|
||||
10 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => runNotesWithLabel("hourly")),
|
||||
3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => runNotesWithLabel("daily")),
|
||||
24 * 3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
|
||||
7 * 3600 * 1000
|
||||
);
|
||||
export function startScheduler() {
|
||||
// If the database is already initialized, we need to check the hidden subtree. Otherwise, hidden subtree
|
||||
// is also checked before importing the demo.zip, so no need to do it again.
|
||||
if (sqlInit.isDbInitialized()) {
|
||||
console.log("Checking hidden subtree.");
|
||||
sqlInit.dbReady.then(() => cls.init(() => hiddenSubtreeService.checkHiddenSubtree()));
|
||||
}
|
||||
|
||||
setInterval(() => checkProtectedSessionExpiration(), 30000);
|
||||
});
|
||||
// Periodic checks.
|
||||
sqlInit.dbReady.then(() => {
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
setTimeout(
|
||||
cls.wrap(() => runNotesWithLabel("backendStartup")),
|
||||
10 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => runNotesWithLabel("hourly")),
|
||||
3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => runNotesWithLabel("daily")),
|
||||
24 * 3600 * 1000
|
||||
);
|
||||
|
||||
setInterval(
|
||||
cls.wrap(() => hiddenSubtreeService.checkHiddenSubtree()),
|
||||
7 * 3600 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
setInterval(() => checkProtectedSessionExpiration(), 30000);
|
||||
});
|
||||
}
|
||||
|
||||
function checkProtectedSessionExpiration() {
|
||||
const protectedSessionTimeout = options.getOptionInt("protectedSessionTimeout");
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 "./request.js";
|
||||
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";
|
||||
import appInfo from "./app_info.js";
|
||||
import log from "./log.js";
|
||||
import optionService from "./options.js";
|
||||
import request from "./request.js";
|
||||
import sqlInit from "./sql_init.js";
|
||||
import syncService from "./sync.js";
|
||||
import syncOptions from "./sync_options.js";
|
||||
import { timeLimit } from "./utils.js";
|
||||
|
||||
async function hasSyncServerSchemaAndSeed() {
|
||||
const response = await requestToSyncServer<SetupStatusResponse>("GET", "/api/setup/status");
|
||||
@@ -55,13 +55,13 @@ async function requestToSyncServer<T>(method: string, path: string, body?: strin
|
||||
url: syncOptions.getSyncServerHost() + path,
|
||||
body,
|
||||
proxy: syncOptions.getSyncProxy(),
|
||||
timeout: timeout
|
||||
timeout
|
||||
}),
|
||||
timeout
|
||||
)) as T;
|
||||
}
|
||||
|
||||
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string) {
|
||||
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: string, totpToken?: string) {
|
||||
if (sqlInit.isDbInitialized()) {
|
||||
return {
|
||||
result: "failure",
|
||||
@@ -76,7 +76,7 @@ async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string
|
||||
const resp = await request.exec<SetupSyncSeedResponse>({
|
||||
method: "get",
|
||||
url: `${syncServerHost}/api/setup/sync-seed`,
|
||||
auth: { password },
|
||||
auth: { password, totpToken },
|
||||
proxy: syncProxy,
|
||||
timeout: 30000 // seed request should not take long
|
||||
});
|
||||
@@ -111,10 +111,30 @@ function getSyncSeedOptions() {
|
||||
return [becca.getOption("documentId"), becca.getOption("documentSecret")];
|
||||
}
|
||||
|
||||
async function checkRemoteTotpStatus(syncServerHost: string): Promise<{ totpEnabled: boolean }> {
|
||||
// Validate URL scheme to mitigate SSRF
|
||||
if (!syncServerHost.startsWith("http://") && !syncServerHost.startsWith("https://")) {
|
||||
return { totpEnabled: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await request.exec<{ totpEnabled?: boolean }>({
|
||||
method: "get",
|
||||
url: `${syncServerHost}/api/setup/status`,
|
||||
proxy: null,
|
||||
timeout: 10000
|
||||
});
|
||||
return { totpEnabled: !!resp?.totpEnabled };
|
||||
} catch {
|
||||
return { totpEnabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
hasSyncServerSchemaAndSeed,
|
||||
triggerSync,
|
||||
sendSeedToSyncServer,
|
||||
setupSyncFromSyncServer,
|
||||
getSyncSeedOptions
|
||||
getSyncSeedOptions,
|
||||
checkRemoteTotpStatus
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ async function initDbConnection() {
|
||||
|
||||
await migrationService.migrateIfNecessary();
|
||||
|
||||
sql.execute('CREATE TEMP TABLE "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
|
||||
sql.execute('CREATE TEMP TABLE IF NOT EXISTS "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
|
||||
|
||||
sql.execute(`
|
||||
CREATE TABLE IF NOT EXISTS "user_data"
|
||||
|
||||
@@ -446,15 +446,17 @@ function getOutstandingPullCount() {
|
||||
return outstandingPullCount;
|
||||
}
|
||||
|
||||
becca_loader.beccaLoaded.then(() => {
|
||||
setInterval(cls.wrap(sync), 60000);
|
||||
export function startSyncTimer() {
|
||||
becca_loader.beccaLoaded.then(() => {
|
||||
setInterval(cls.wrap(sync), 60000);
|
||||
|
||||
// kickoff initial sync immediately, but should happen after initial consistency checks
|
||||
setTimeout(cls.wrap(sync), 5000);
|
||||
// kickoff initial sync immediately, but should happen after initial consistency checks
|
||||
setTimeout(cls.wrap(sync), 5000);
|
||||
|
||||
// called just so ws.setLastSyncedPush() is called
|
||||
getLastSyncedPush();
|
||||
});
|
||||
// called just so ws.setLastSyncedPush() is called
|
||||
getLastSyncedPush();
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
sync,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Totp, generateSecret } from 'time2fa';
|
||||
import options from './options.js';
|
||||
import { generateSecret,Totp } from 'time2fa';
|
||||
|
||||
import totpEncryptionService from './encryption/totp_encryption.js';
|
||||
import options from './options.js';
|
||||
|
||||
function isTotpEnabled(): boolean {
|
||||
return options.getOptionOrNull('mfaEnabled') === "true" &&
|
||||
@@ -10,7 +11,7 @@ function isTotpEnabled(): boolean {
|
||||
|
||||
function createSecret(): { success: boolean; message?: string } {
|
||||
try {
|
||||
const secret = generateSecret();
|
||||
const secret = generateSecret(20);
|
||||
|
||||
totpEncryptionService.setTotpSecret(secret);
|
||||
|
||||
@@ -43,6 +44,8 @@ function validateTOTP(submittedPasscode: string): boolean {
|
||||
return Totp.validate({
|
||||
passcode: submittedPasscode,
|
||||
secret: secret.trim()
|
||||
}, {
|
||||
secretSize: secret.trim().length === 32 ? 20 : 10
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to validate TOTP:', e);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Application, NextFunction,Request, Response } from "express";
|
||||
import supertest from "supertest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
|
||||
@@ -23,6 +23,10 @@ describe("Share API test", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cannotSetHeadersCount = 0;
|
||||
});
|
||||
|
||||
@@ -19,16 +19,18 @@ export default defineConfig(() => ({
|
||||
exclude: [
|
||||
"spec/build-checks/**",
|
||||
],
|
||||
hookTimeout: 20000,
|
||||
hookTimeout: 20_000,
|
||||
testTimeout: 40_000,
|
||||
reporters: [
|
||||
"verbose"
|
||||
"verbose",
|
||||
["html", { outputFile: "./test-output/vitest/html/index.html" }]
|
||||
],
|
||||
coverage: {
|
||||
reportsDirectory: './test-output/vitest/coverage',
|
||||
provider: 'v8' as const,
|
||||
reporter: [ "text", "html" ]
|
||||
},
|
||||
pool: "vmForks",
|
||||
maxWorkers: 3
|
||||
pool: "forks",
|
||||
maxWorkers: 6
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"preact": "10.28.4",
|
||||
"preact-iso": "2.11.1",
|
||||
"preact-render-to-string": "6.6.6",
|
||||
"react-i18next": "16.5.4"
|
||||
"react-i18next": "16.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "2.10.3",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.11.0",
|
||||
"@types/node": "24.12.0",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
|
||||
@@ -11,7 +11,6 @@ export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, Model
|
||||
export type { TemplateDefinition } from "ckeditor5-premium-features";
|
||||
export { default as buildExtraCommands } from "./extra_slash_commands.js";
|
||||
export { default as getCkLocale } from "./i18n.js";
|
||||
export * from "./utils.js";
|
||||
|
||||
// Import with sideffects to ensure that type augmentations are present.
|
||||
import "@triliumnext/ckeditor5-math";
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { DifferItemAttribute, Editor, ModelDocumentFragment, ModelElement, ModelNode } from "ckeditor5";
|
||||
|
||||
function hasHeadingAncestor(node: ModelElement | ModelNode | ModelDocumentFragment | null): boolean {
|
||||
let current: ModelElement | ModelNode | ModelDocumentFragment | null = node;
|
||||
while (current) {
|
||||
if (!!current && current.is('element') && (current as ModelElement).name.startsWith("heading")) return true;
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function attributeChangeAffectsHeading(change: DifferItemAttribute, editor: Editor): boolean {
|
||||
if (change.type !== "attribute") return false;
|
||||
|
||||
// Fast checks on range boundaries
|
||||
if (hasHeadingAncestor(change.range.start.parent) || hasHeadingAncestor(change.range.end.parent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Robust check across the whole changed range
|
||||
const range = editor.model.createRange(change.range.start, change.range.end);
|
||||
for (const item of range.getItems()) {
|
||||
const baseNode = item.is("$textProxy") ? item.parent : item;
|
||||
if (hasHeadingAncestor(baseNode)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
429
pnpm-lock.yaml
generated
429
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user