Compare commits

..

4 Commits

Author SHA1 Message Date
Elian Doran
0e052bffe8 chore(react): add backup 2026-03-05 21:03:43 +02:00
Elian Doran
3fa2673b55 chore(react): integrate mobile layout as well 2026-03-05 20:59:59 +02:00
Elian Doran
a2130f4aa3 chore(react): port desktop version of note wrapper 2026-03-05 20:17:23 +02:00
Elian Doran
6c9246bc5b chore(react): get title bar to render 2026-03-05 20:10:26 +02:00
46 changed files with 485 additions and 674 deletions

View File

@@ -40,32 +40,11 @@ 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 --filter=\!ckeditor5-mermaid --filter=\!ckeditor5-math test
run: pnpm run --filter=\!client --filter=\!server test"
build_docker:
name: Build Docker image
@@ -90,7 +69,7 @@ jobs:
- name: Trigger server build
run: pnpm run server:build
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v7
- uses: docker/build-push-action@v6
with:
context: apps/server
cache-from: type=gha
@@ -127,7 +106,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Build and export to Docker
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}

View File

@@ -59,7 +59,7 @@ jobs:
run: pnpm run server:build
- name: Build and export to Docker
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: apps/server
file: apps/server/${{ matrix.dockerfile }}
@@ -164,7 +164,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
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@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v3
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@v7
uses: docker/build-push-action@v6
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@v4
uses: docker/login-action@v3
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -247,7 +247,7 @@ jobs:
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |

View File

@@ -57,13 +57,13 @@
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.4",
"marked": "17.0.3",
"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.5",
"react-i18next": "16.5.4",
"react-window": "2.2.7",
"reveal.js": "5.2.1",
"rrule": "2.8.1",

View File

@@ -3,54 +3,29 @@ import type { WidgetsByParent } from "../services/bundle.js";
import { isExperimentalFeatureEnabled } from "../services/experimental_features.js";
import options from "../services/options.js";
import utils from "../services/utils.js";
import ApiLog from "../widgets/api_log.jsx";
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
import GlobalMenu from "../widgets/buttons/global_menu.jsx";
import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js";
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
import RightPaneToggle from "../widgets/buttons/right_pane_toggle.jsx";
import CloseZenModeButton from "../widgets/close_zen_button.jsx";
import NoteList from "../widgets/collections/NoteList.jsx";
import ContentHeader from "../widgets/containers/content_header.js";
import FlexContainer from "../widgets/containers/flex_container.js";
import LeftPaneContainer from "../widgets/containers/left_pane_container.js";
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
import RootContainer from "../widgets/containers/root_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
import FindWidget from "../widgets/find.js";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
import HighlightsListWidget from "../widgets/highlights_list.js";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
import StatusBar from "../widgets/layout/StatusBar.jsx";
import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteTitleWidget from "../widgets/note_title.jsx";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import NoteWrapperWidget from "../widgets/NoteWrapper.js";
import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import { FixedFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.jsx";
import NoteActions from "../widgets/ribbon/NoteActions.jsx";
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
import ScrollPadding from "../widgets/scroll_padding.js";
import SearchResult from "../widgets/search_result.jsx";
import SharedInfo from "../widgets/shared_info.jsx";
import RightPanelContainer from "../widgets/sidebar/RightPanelContainer.jsx";
import TabRowWidget from "../widgets/tab_row.js";
import TabHistoryNavigationButtons from "../widgets/TabHistoryNavigationButtons.jsx";
import TitleBarButtons from "../widgets/title_bar_buttons.jsx";
import TocWidget from "../widgets/toc.js";
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
import { applyModals } from "./layout_commons.js";
export default class DesktopLayout {
@@ -133,44 +108,7 @@ export default class DesktopLayout {
.filling()
.collapsible()
.id("center-pane")
.child(
new SplitNoteContainer(() =>
new NoteWrapperWidget()
.child(new FlexContainer("row")
.class("title-row note-split-title")
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.optChild(isNewLayout, <NoteBadges />)
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
.optChild(!isNewLayout, <MovePaneButton direction="left" />)
.optChild(!isNewLayout, <MovePaneButton direction="right" />)
.optChild(!isNewLayout, <ClosePaneButton />)
.optChild(!isNewLayout, <CreatePaneButton />)
.optChild(isNewLayout, <NoteActions />))
.optChild(!isNewLayout, <Ribbon />)
.child(new WatchedFileUpdateStatusWidget())
.optChild(!isNewLayout, <FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
.child(
new ScrollingContainer()
.filling()
.optChild(isNewLayout, <InlineTitle />)
.optChild(isNewLayout, <NoteTitleActions />)
.optChild(!isNewLayout, new ContentHeader()
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfo />)
)
.optChild(!isNewLayout, <PromotedAttributes />)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<SearchResult />)
.child(<ScrollPadding />)
)
.child(<ApiLog />)
.child(new FindWidget())
.child(...this.customWidgets.get("note-detail-pane"))
)
)
.child(new SplitNoteContainer(() => <NoteWrapperWidget />))
.child(...this.customWidgets.get("center-pane"))
)

View File

@@ -3,29 +3,15 @@ import "./mobile_layout.css";
import type AppContext from "../components/app_context.js";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import CloseZenModeButton from "../widgets/close_zen_button.js";
import NoteList from "../widgets/collections/NoteList.jsx";
import FlexContainer from "../widgets/containers/flex_container.js";
import RootContainer from "../widgets/containers/root_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import SplitNoteContainer from "../widgets/containers/split_note_container.js";
import FindWidget from "../widgets/find.js";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
import InlineTitle from "../widgets/layout/InlineTitle.jsx";
import NoteBadges from "../widgets/layout/NoteBadges.jsx";
import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
import ToggleSidebarButton from "../widgets/mobile_widgets/toggle_sidebar_button.jsx";
import NoteIconWidget from "../widgets/note_icon.jsx";
import NoteTitleWidget from "../widgets/note_title.js";
import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import NoteDetail from "../widgets/NoteDetail.jsx";
import NoteWrapperWidget from "../widgets/NoteWrapper";
import QuickSearchWidget from "../widgets/quick_search.js";
import ScrollPadding from "../widgets/scroll_padding";
import SearchResult from "../widgets/search_result.jsx";
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
import { applyModals } from "./layout_commons.js";
export default class MobileLayout {
@@ -52,35 +38,7 @@ export default class MobileLayout {
new ScreenContainer("detail", "row")
.id("detail-container")
.class("d-sm-flex d-md-flex d-lg-flex d-xl-flex col-12 col-sm-7 col-md-8 col-lg-9")
.child(
new SplitNoteContainer(() =>
new NoteWrapperWidget()
.child(
new FlexContainer("row")
.class("title-row note-split-title")
.contentSized()
.css("align-items", "center")
.child(<ToggleSidebarButton />)
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.child(<NoteBadges />)
.child(<MobileDetailMenu />)
)
.child(
new ScrollingContainer()
.filling()
.contentSized()
.child(<InlineTitle />)
.child(<NoteTitleActions />)
.child(<NoteDetail />)
.child(<NoteList media="screen" />)
.child(<SearchResult />)
.child(<ScrollPadding />)
)
.child(<MobileEditorToolbar />)
.child(new FindWidget())
)
)
.child(new SplitNoteContainer(() => <NoteWrapperWidget />))
)
)
.child(

View File

@@ -1,8 +1,6 @@
import "jquery";
import ko from "knockout";
import utils from "./services/utils.js";
import ko from "knockout";
// TriliumNextTODO: properly make use of below types
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
@@ -18,8 +16,6 @@ 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;
@@ -31,8 +27,6 @@ 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);
@@ -46,7 +40,7 @@ class SetupModel {
return !!this.setupType();
}
async selectSetupType() {
selectSetupType() {
if (this.setupType() === "new-document") {
this.step("new-document-in-progress");
@@ -58,24 +52,6 @@ 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("");
@@ -96,22 +72,11 @@ 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,
syncProxy,
password,
totpToken
syncServerHost: syncServerHost,
syncProxy: syncProxy,
password: password
});
if (resp.result === "success") {

View File

@@ -11,12 +11,6 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
private noteContext?: NoteContext;
constructor() {
super("column");
this.css("flex-grow", "1").collapsible();
}
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) {
this.noteContext = noteContext;
@@ -53,8 +47,6 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
this.$widget.addClass("active");
}
this.$widget.addClass("component note-split");
const note = this.noteContext?.note;
if (!note) {
this.$widget.addClass("bgfx empty-note");

View File

@@ -0,0 +1,16 @@
.component.note-split {
display: flex;
flex-grow: 1;
flex-direction: column;
contain: none;
> .title-row {
display: flex;
flex-direction: row;
contain: none;
}
}
body.desktop .component.note-split > .title-row > * {
margin: 5px;
}

View File

@@ -0,0 +1,96 @@
import "./NoteWrapper.css";
import { isExperimentalFeatureEnabled } from "../services/experimental_features";
import { isDesktop } from "../services/utils";
import ApiLog from "./api_log";
import ClosePaneButton from "./buttons/close_pane_button";
import CreatePaneButton from "./buttons/create_pane_button";
import MovePaneButton from "./buttons/move_pane_button";
import NoteList from "./collections/NoteList";
import ScrollingContainer from "./containers/ScrollingContainer";
import FindWidget from "./find";
import FloatingButtons from "./FloatingButtons";
import { DESKTOP_FLOATING_BUTTONS } from "./FloatingButtonsDefinitions";
import { LegacyWidgetRenderer } from "./launch_bar/LauncherDefinitions";
import SpacerWidget from "./launch_bar/SpacerWidget";
import InlineTitle from "./layout/InlineTitle";
import NoteBadges from "./layout/NoteBadges";
import NoteTitleActions from "./layout/NoteTitleActions";
import MobileDetailMenu from "./mobile_widgets/mobile_detail_menu";
import ToggleSidebarButton from "./mobile_widgets/toggle_sidebar_button";
import NoteIcon from "./note_icon";
import NoteTitleWidget from "./note_title";
import NoteDetail from "./NoteDetail";
import PromotedAttributes from "./PromotedAttributes";
import ReadOnlyNoteInfoBar from "./ReadOnlyNoteInfoBar";
import NoteActions from "./ribbon/NoteActions";
import Ribbon from "./ribbon/Ribbon";
import ScrollPadding from "./scroll_padding";
import SearchResult from "./search_result";
import SharedInfo from "./shared_info";
import MobileEditorToolbar from "./type_widgets/text/mobile_editor_toolbar";
import WatchedFileUpdateStatusWidget from "./watched_file_update_status";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
const cachedIsDesktop = isDesktop();
const cachedIsMobile = !cachedIsDesktop;
export default function NoteWrapper() {
return (
<div className="component note-split">
<TitleRow />
{!isNewLayout && <Ribbon />}
{cachedIsDesktop && <LegacyWidgetRenderer widget={new WatchedFileUpdateStatusWidget()} />}
{!isNewLayout && <FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />}
<ScrollingContainer>
{isNewLayout ? (
<>
<InlineTitle />
<NoteTitleActions />
</>
) : (
<>
<ReadOnlyNoteInfoBar />
<SharedInfo />
</>
)}
{!isNewLayout && <PromotedAttributes />}
<NoteDetail />
<NoteList media="screen" />
<SearchResult />
<ScrollPadding />
</ScrollingContainer>
<ApiLog />
{cachedIsMobile && <MobileEditorToolbar />}
<LegacyWidgetRenderer widget={new FindWidget()} />
</div>
);
}
function TitleRow() {
return (
<div className="component title-row note-split-title">
{cachedIsMobile && <ToggleSidebarButton />}
<NoteIcon />
<NoteTitleWidget />
{isNewLayout && <NoteBadges />}
{cachedIsDesktop ? (
<>
<SpacerWidget baseSize={0} growthFactor={1} />
{!isNewLayout ? (
<>
<MovePaneButton direction="left" />
<MovePaneButton direction="right" />
<ClosePaneButton />
<CreatePaneButton />
</>
) : (
<NoteActions />
)}
</>
) : (
<MobileDetailMenu />
)}
</div>
);
}

View File

@@ -10,8 +10,6 @@ export default class ScrollingContainer extends Container<BasicWidget> {
constructor() {
super();
this.class("scrolling-container");
}
setNoteContextEvent({ noteContext }: EventData<"setNoteContext">) {

View File

@@ -4,6 +4,7 @@
overflow: auto;
scroll-behavior: smooth;
position: relative;
flex-grow: 1;
> .note-detail > .note-detail-editable-text > .note-detail-editable-text-editor,
> .note-list-widget:not(.full-height) .note-list-wrapper {

View File

@@ -0,0 +1,9 @@
import { ComponentChildren } from "preact";
export default function ScrollingContainer({ children }: { children: ComponentChildren }) {
return (
<div className="scrolling-container">
{children}
</div>
);
}

View File

@@ -1,9 +1,12 @@
import { VNode } from "preact";
import appContext, { type CommandData, type CommandListenerData, type EventData, type EventNames, type NoteSwitchedContext } from "../../components/app_context.js";
import Component from "../../components/component.js";
import NoteContext from "../../components/note_context.js";
import splitService from "../../services/resizer.js";
import { isMobile } from "../../services/utils.js";
import type BasicWidget from "../basic_widget.js";
import { wrapReactWidgets } from "../basic_widget.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import FlexContainer from "./flex_container.js";
@@ -12,7 +15,7 @@ interface SplitNoteWidget extends BasicWidget {
ntxId?: string;
}
type WidgetFactory = () => SplitNoteWidget;
type WidgetFactory = () => (SplitNoteWidget | VNode);
export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
@@ -31,7 +34,7 @@ export default class SplitNoteContainer extends FlexContainer<SplitNoteWidget> {
}
async newNoteContextCreatedEvent({ noteContext }: EventData<"newNoteContextCreated">) {
const widget = this.widgetFactory();
const widget = wrapReactWidgets([ this.widgetFactory() ])[0];
const $renderedWidget = widget.render();

View File

@@ -6,7 +6,7 @@ import "./MindMap.css";
import nodeMenu from "@mind-elixir/node-menu";
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
import { snapdom } from "@zumer/snapdom";
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME } from "mind-elixir";
import { default as VanillaMindElixir,MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME, DARK_THEME } from "mind-elixir";
import { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
@@ -154,7 +154,6 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
const apiRef = useRef<MindElixirInstance>(null);
const [ locale ] = useTriliumOption("locale");
const colorScheme = useColorScheme();
const defaultColorScheme = useRef(colorScheme);
function reinitialize() {
if (!containerRef.current) return;
@@ -163,7 +162,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
el: containerRef.current,
locale: LOCALE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined,
editable,
theme: defaultColorScheme.current === "dark" ? DARK_THEME : LIGHT_THEME
theme: LIGHT_THEME
});
if (editable) {
@@ -189,11 +188,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
if (!apiRef.current) return;
const newTheme = colorScheme === "dark" ? DARK_THEME : LIGHT_THEME;
if (apiRef.current.theme === newTheme) return; // Avoid unnecessary theme changes, which can be expensive to render.
try {
apiRef.current.changeTheme(newTheme);
} catch (e) {
console.warn("Failed to change mind map theme:", e);
}
apiRef.current.changeTheme(newTheme);
}, [ colorScheme ]);
useEffect(() => {

View File

@@ -120,11 +120,7 @@ export default defineConfig(() => ({
environment: "happy-dom",
setupFiles: [
"./src/test/setup.ts"
],
reporters: [
"verbose",
["html", { outputFile: "./test-output/vitest/html/index.html" }]
],
]
},
commonjsOptions: {
transformMixedEsModules: true,

View File

@@ -35,7 +35,7 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "40.8.0",
"electron": "40.6.1",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "40.8.0",
"electron": "40.6.1",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -81,15 +81,15 @@
"csrf-csrf": "3.2.2",
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "40.8.0",
"ejs": "4.0.1",
"electron": "40.6.1",
"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.3.0",
"express-rate-limit": "8.2.1",
"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.4",
"marked": "17.0.3",
"mime-types": "3.0.2",
"multer": "2.1.1",
"multer": "2.1.0",
"normalize-strings": "1.1.1",
"rand-token": "1.0.1",
"safe-compare": "1.1.4",

View File

@@ -86,9 +86,8 @@ 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 { default: sessionParser, startSessionCleanup } = await import("./routes/session_parser.js");
const sessionParser = (await import("./routes/session_parser.js")).default;
app.use(sessionParser);
startSessionCleanup();
app.use(favicon(path.join(assetsDir, isDev ? "icon-dev.ico" : "icon.ico")));
if (openID.isOpenIDEnabled())
@@ -99,16 +98,16 @@ export default async function buildApp() {
custom.register(app);
error_handlers.register(app);
const { startSyncTimer } = await import("./services/sync.js");
startSyncTimer();
// triggers sync timer
await import("./services/sync.js");
// triggers backup timer
await import("./services/backup.js");
const { startConsistencyChecks } = await import("./services/consistency_checks.js");
startConsistencyChecks();
// trigger consistency checks timer
await import("./services/consistency_checks.js");
const { startScheduler } = await import("./services/scheduler.js");
startScheduler();
await import("./services/scheduler.js");
startScheduledCleanup();

View File

@@ -155,8 +155,6 @@
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
"password": "密码",
"password-placeholder": "密码",
"totp-token": "TOTP 验证码",
"totp-token-placeholder": "请输入 TOTP 验证码",
"back": "返回",
"finish-setup": "完成设置"
},

View File

@@ -252,8 +252,6 @@
"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"
},

View File

@@ -155,8 +155,6 @@
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
"password": "密碼",
"password-placeholder": "密碼",
"totp-token": "TOTP 驗證碼",
"totp-token-placeholder": "請輸入 TOTP 驗證碼",
"back": "返回",
"finish-setup": "完成設定"
},

View File

@@ -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, event: { blur: checkTotpStatus }" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost" 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,10 +141,6 @@
<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>

View File

@@ -8,7 +8,6 @@ export declare module "express-serve-static-core" {
authorization?: string;
"trilium-cred"?: string;
"trilium-totp"?: string;
"x-csrf-token"?: string;
"trilium-component-id"?: string;

View File

@@ -1,19 +1,16 @@
"use strict";
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";
import setupService from "../../services/setup.js";
import log from "../../services/log.js";
import appInfo from "../../services/app_info.js";
import type { Request } from "express";
function getStatus() {
return {
isInitialized: sqlInit.isDbInitialized(),
schemaExists: sqlInit.schemaExists(),
syncVersion: appInfo.syncVersion,
totpEnabled: totp.isTotpEnabled()
syncVersion: appInfo.syncVersion
};
}
@@ -22,9 +19,9 @@ async function setupNewDocument() {
}
function setupSyncFromServer(req: Request) {
const { syncServerHost, syncProxy, password, totpToken } = req.body;
const { syncServerHost, syncProxy, password } = req.body;
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password, totpToken);
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password);
}
function saveSyncSeed(req: Request) {
@@ -85,26 +82,10 @@ 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,
checkServerTotpStatus
saveSyncSeed
};

View File

@@ -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 { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import cls from "../services/cls.js";
import { type SQLiteSessionStore } from "./session_parser.js";
@@ -20,10 +20,6 @@ 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

View File

@@ -244,7 +244,6 @@ 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);

View File

@@ -113,13 +113,11 @@ const sessionParser: express.RequestHandler = session({
store: sessionStore
});
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);
}
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 default sessionParser;

View File

@@ -6,7 +6,6 @@ import type { OptionRow } from "@triliumnext/commons";
export interface SetupStatusResponse {
syncVersion: number;
schemaExists: boolean;
totpEnabled: boolean;
}
/**

View File

@@ -9,10 +9,6 @@ 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;
@@ -76,49 +72,4 @@ 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);

View File

@@ -1,17 +1,15 @@
import type { NextFunction, Request, Response } from "express";
import attributes from "./attributes.js";
import config from "./config.js";
import passwordService from "./encryption/password.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 sqlInit from "./sql_init.js";
import { isElectron } from "./utils.js";
import passwordEncryptionService from "./encryption/password_encryption.js";
import config from "./config.js";
import passwordService from "./encryption/password.js";
import totp from "./totp.js";
import openID from "./open_id.js";
import options from "./options.js";
import sqlInit from "./sql_init.js";
import totp from "./totp.js";
import { isElectron } from "./utils.js";
import attributes from "./attributes.js";
import type { NextFunction, Request, Response } from "express";
let noAuthentication = false;
refreshAuth();
@@ -163,28 +161,9 @@ 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.`);
return;
} else {
next();
}
// 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 {

View File

@@ -953,14 +953,12 @@ function runEntityChangesChecks() {
consistencyChecks.findEntityChangeIssues();
}
export function startConsistencyChecks() {
sqlInit.dbReady.then(() => {
setInterval(cls.wrap(runPeriodicChecks), 60 * 60 * 1000);
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,

View File

@@ -1,9 +1,8 @@
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";
import { randomSecureToken, toBase64, constantTimeCompare } from "../utils.js";
import dataEncryptionService from "./data_encryption.js";
import type { OptionNames } from "@triliumnext/commons";
const TOTP_OPTIONS: Record<string, OptionNames> = {
SALT: "totpEncryptionSalt",

View File

@@ -24,7 +24,6 @@ async function testImport(fileName: string, mimetype: string) {
const rootNote = becca.getNote("root");
if (!rootNote) {
reject("Missing root note.");
return;
}
const importedNote = single.importSingleFile(

View File

@@ -62,9 +62,6 @@ 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({

View File

@@ -14,7 +14,6 @@ export interface ExecOpts {
cookieJar?: CookieJar;
auth?: {
password?: string;
totpToken?: string;
};
timeout: number;
body?: string | {};

View File

@@ -35,41 +35,39 @@ function runNotesWithLabel(runAttrValue: string) {
}
}
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()));
// 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
);
}
// 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);
});
}
setInterval(() => checkProtectedSessionExpiration(), 30000);
});
function checkProtectedSessionExpiration() {
const protectedSessionTimeout = options.getOptionInt("protectedSessionTimeout");

View File

@@ -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, totpToken?: string) {
async function setupSyncFromSyncServer(syncServerHost: string, syncProxy: string, password: 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, totpToken },
auth: { password },
proxy: syncProxy,
timeout: 30000 // seed request should not take long
});
@@ -111,30 +111,10 @@ 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,
checkRemoteTotpStatus
getSyncSeedOptions
};

View File

@@ -50,7 +50,7 @@ async function initDbConnection() {
await migrationService.migrateIfNecessary();
sql.execute('CREATE TEMP TABLE IF NOT EXISTS "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
sql.execute('CREATE TEMP TABLE "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
sql.execute(`
CREATE TABLE IF NOT EXISTS "user_data"

View File

@@ -446,17 +446,15 @@ function getOutstandingPullCount() {
return outstandingPullCount;
}
export function startSyncTimer() {
becca_loader.beccaLoaded.then(() => {
setInterval(cls.wrap(sync), 60000);
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,

View File

@@ -1,7 +1,6 @@
import { generateSecret,Totp } from 'time2fa';
import totpEncryptionService from './encryption/totp_encryption.js';
import { Totp, generateSecret } from 'time2fa';
import options from './options.js';
import totpEncryptionService from './encryption/totp_encryption.js';
function isTotpEnabled(): boolean {
return options.getOptionOrNull('mfaEnabled') === "true" &&
@@ -11,7 +10,7 @@ function isTotpEnabled(): boolean {
function createSecret(): { success: boolean; message?: string } {
try {
const secret = generateSecret(20);
const secret = generateSecret();
totpEncryptionService.setTotpSecret(secret);
@@ -44,8 +43,6 @@ 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);

View File

@@ -1,6 +1,6 @@
import type { Application, NextFunction,Request, Response } from "express";
import supertest from "supertest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
@@ -23,10 +23,6 @@ describe("Share API test", () => {
});
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(() => {
cannotSetHeadersCount = 0;
});

View File

@@ -19,18 +19,16 @@ export default defineConfig(() => ({
exclude: [
"spec/build-checks/**",
],
hookTimeout: 20_000,
testTimeout: 40_000,
hookTimeout: 20000,
reporters: [
"verbose",
["html", { outputFile: "./test-output/vitest/html/index.html" }]
"verbose"
],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
reporter: [ "text", "html" ]
},
pool: "forks",
maxWorkers: 6
pool: "vmForks",
maxWorkers: 3
},
}));

View File

@@ -14,7 +14,7 @@
"preact": "10.28.4",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.6",
"react-i18next": "16.5.5"
"react-i18next": "16.5.4"
},
"devDependencies": {
"@preact/preset-vite": "2.10.3",

View File

@@ -50,7 +50,7 @@
"@triliumnext/server": "workspace:*",
"@types/express": "5.0.6",
"@types/js-yaml": "4.0.9",
"@types/node": "24.12.0",
"@types/node": "24.11.0",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"@vitest/ui": "4.0.18",

429
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff