mirror of
https://github.com/zadam/trilium.git
synced 2026-03-20 19:01:37 +01:00
Compare commits
22 Commits
totp
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f729e5b379 | ||
|
|
6e8c4813d9 | ||
|
|
029e8469b4 | ||
|
|
715b87fb3d | ||
|
|
3d860ba2c4 | ||
|
|
8b479ac4ba | ||
|
|
dcfcc7a334 | ||
|
|
daa0109f7f | ||
|
|
f46e20aa25 | ||
|
|
e8045424b2 | ||
|
|
7e6b628f86 | ||
|
|
fc247fe790 | ||
|
|
7a2fa2e829 | ||
|
|
3e3f455b24 | ||
|
|
55dea474e9 | ||
|
|
bc74455a64 | ||
|
|
2d0b28367f | ||
|
|
7d8a3e2811 | ||
|
|
79e5d9595a | ||
|
|
0310626025 | ||
|
|
fefbb40c03 | ||
|
|
12f89078b8 |
@@ -24,6 +24,7 @@
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/rrule": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@lexical/react": "0.42.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
@@ -58,9 +59,10 @@
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.38",
|
||||
"katex": "0.16.39",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"lexical": "0.42.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.13.0",
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface MenuCommandItem<T> {
|
||||
title: string;
|
||||
command?: T;
|
||||
type?: string;
|
||||
mime?: string;
|
||||
/**
|
||||
* The icon to display in the menu item.
|
||||
*
|
||||
|
||||
@@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
|
||||
}
|
||||
|
||||
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
||||
async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
||||
const notePath = treeService.getNotePath(this.node);
|
||||
|
||||
if (utils.isMobile()) {
|
||||
@@ -305,6 +305,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
target: "after",
|
||||
targetBranchId: this.node.data.branchId,
|
||||
type,
|
||||
mime,
|
||||
isProtected,
|
||||
templateNoteId
|
||||
});
|
||||
@@ -313,6 +314,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
|
||||
noteCreateService.createNote(parentNotePath, {
|
||||
type,
|
||||
mime,
|
||||
isProtected: this.node.data.isProtected,
|
||||
templateNoteId
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
|
||||
// The default note type (always the first item)
|
||||
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
|
||||
{ type: "text", mime: "application/json", title: "Text (Lexical)", icon: "bx-note" },
|
||||
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
|
||||
|
||||
// Text notes group
|
||||
@@ -97,6 +98,7 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
|
||||
title: nt.title,
|
||||
command,
|
||||
type: nt.type,
|
||||
mime: nt.mime,
|
||||
uiIcon: `bx ${nt.icon}`,
|
||||
badges: []
|
||||
};
|
||||
|
||||
@@ -17,9 +17,6 @@ class SetupController {
|
||||
private syncServerHostInput: HTMLInputElement;
|
||||
private syncProxyInput: HTMLInputElement;
|
||||
private passwordInput: HTMLInputElement;
|
||||
private totpTokenInput: HTMLInputElement;
|
||||
private totpSection: HTMLElement;
|
||||
private totpEnabled = false;
|
||||
private sections: Record<SetupStep, HTMLElement>;
|
||||
|
||||
constructor(rootNode: HTMLElement, syncInProgress: boolean) {
|
||||
@@ -32,8 +29,6 @@ class SetupController {
|
||||
this.syncServerHostInput = mustGetElement("sync-server-host", HTMLInputElement);
|
||||
this.syncProxyInput = mustGetElement("sync-proxy", HTMLInputElement);
|
||||
this.passwordInput = mustGetElement("password", HTMLInputElement);
|
||||
this.totpTokenInput = mustGetElement("totp-token", HTMLInputElement);
|
||||
this.totpSection = mustGetElement("totp-section", HTMLElement);
|
||||
this.sections = {
|
||||
"setup-type": mustGetElement("setup-type-section", HTMLElement),
|
||||
"new-document-in-progress": mustGetElement("new-document-in-progress-section", HTMLElement),
|
||||
@@ -61,10 +56,6 @@ class SetupController {
|
||||
});
|
||||
}
|
||||
|
||||
this.syncServerHostInput.addEventListener("blur", () => {
|
||||
void this.checkTotpStatus();
|
||||
});
|
||||
|
||||
for (const backButton of document.querySelectorAll<HTMLElement>("[data-action='back']")) {
|
||||
backButton.addEventListener("click", () => {
|
||||
this.back();
|
||||
@@ -96,40 +87,9 @@ class SetupController {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkTotpStatus() {
|
||||
const syncServerHost = this.syncServerHostInput.value.trim();
|
||||
|
||||
if (!syncServerHost) {
|
||||
this.setTotpEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await $.post("api/setup/check-server-totp", {
|
||||
syncServerHost
|
||||
});
|
||||
|
||||
this.setTotpEnabled(!!resp.totpEnabled);
|
||||
} catch {
|
||||
// If we can't reach the server, don't show the TOTP field yet.
|
||||
this.setTotpEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
private setTotpEnabled(enabled: boolean) {
|
||||
this.totpEnabled = enabled;
|
||||
|
||||
if (!enabled) {
|
||||
this.totpTokenInput.value = "";
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
private back() {
|
||||
this.setStep("setup-type");
|
||||
this.setupType = "";
|
||||
this.setTotpEnabled(false);
|
||||
|
||||
for (const input of this.setupTypeInputs) {
|
||||
input.checked = false;
|
||||
@@ -153,21 +113,11 @@ class SetupController {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.checkTotpStatus();
|
||||
|
||||
const totpToken = this.totpTokenInput.value.trim();
|
||||
|
||||
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
|
||||
password
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
@@ -189,10 +139,13 @@ class SetupController {
|
||||
section.style.display = step === this.step ? "" : "none";
|
||||
}
|
||||
|
||||
this.totpSection.style.display = this.totpEnabled ? "" : "none";
|
||||
this.setupTypeNextButton.disabled = !this.setupType;
|
||||
}
|
||||
|
||||
private getSelectedSetupType(): SetupType {
|
||||
return (this.setupTypeInputs.find((input) => input.checked)?.value ?? "") as SetupType;
|
||||
}
|
||||
|
||||
private startSyncPolling() {
|
||||
if (this.syncPollIntervalId !== null) {
|
||||
return;
|
||||
@@ -243,7 +196,7 @@ function mustGetElement<T extends typeof HTMLElement>(id: string, ctor: T): Inst
|
||||
return element as InstanceType<T>;
|
||||
}
|
||||
|
||||
addEventListener("DOMContentLoaded", () => {
|
||||
addEventListener("DOMContentLoaded", (event) => {
|
||||
const rootNode = document.getElementById("setup-dialog");
|
||||
if (!rootNode || !(rootNode instanceof HTMLElement)) return;
|
||||
|
||||
|
||||
@@ -14,10 +14,11 @@ import note_create from "../../../services/note_create";
|
||||
import options from "../../../services/options";
|
||||
import toast from "../../../services/toast";
|
||||
import utils, { hasTouchBar, isMobile } from "../../../services/utils";
|
||||
import { useEditorSpacedUpdate, useLegacyImperativeHandlers, useNoteLabel, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { useEditorSpacedUpdate, useLegacyImperativeHandlers, useNoteLabel, useNoteProperty, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import TouchBar, { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl } from "../../react/TouchBar";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
import CKEditorWithWatchdog, { CKEditorApi } from "./CKEditorWithWatchdog";
|
||||
import LexicalText from "./lexical";
|
||||
import getTemplates, { updateTemplateCache } from "./snippets.js";
|
||||
import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./utils";
|
||||
|
||||
@@ -27,7 +28,15 @@ import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./util
|
||||
* - Ballon block mode, in which there is a floating toolbar for the selected text, but another floating button for the entire block (i.e. paragraph).
|
||||
* - Decoupled mode, in which the editing toolbar is actually added on the client side (in {@link ClassicEditorToolbar}), see https://ckeditor.com/docs/ckeditor5/latest/examples/framework/bottom-toolbar-editor.html for an example on how the decoupled editor works.
|
||||
*/
|
||||
export default function EditableText({ note, parentComponent, ntxId, noteContext }: TypeWidgetProps) {
|
||||
export default function EditableText(props: TypeWidgetProps) {
|
||||
const mime = useNoteProperty(props.note, "mime");
|
||||
if (mime === "application/json") {
|
||||
return <LexicalText {...props} />;
|
||||
}
|
||||
return <EditableTextCKEditor {...props} />;
|
||||
}
|
||||
|
||||
function EditableTextCKEditor({ note, parentComponent, ntxId, noteContext }: TypeWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<string>("");
|
||||
const watchdogRef = useRef<EditorWatchdog>(null);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.note-detail-editable-text {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
margin-bottom: 1px;
|
||||
background: var(--classic-toolbar-vert-layout-background-color);
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
margin: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.toolbar .divider {
|
||||
width: 1px;
|
||||
background-color: var(--main-border-color);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.toolbar .toolbar-item .text {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
width: 200px;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
text-overflow: ellipsis;
|
||||
width: 70px;
|
||||
overflow: hidden;
|
||||
height: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toolbar .toolbar-item .icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
user-select: none;
|
||||
margin-right: 8px;
|
||||
line-height: 16px;
|
||||
background-size: contain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import "./ToolbarPlugin.css";
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
CAN_REDO_COMMAND,
|
||||
CAN_UNDO_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
REDO_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
UNDO_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import ActionButton, { ActionButtonProps } from "../../../react/ActionButton";
|
||||
|
||||
function Divider() {
|
||||
return <div className="divider" />;
|
||||
}
|
||||
|
||||
export default function ToolbarPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const toolbarRef = useRef(null);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
||||
|
||||
const $updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
// Update text format
|
||||
setIsBold(selection.hasFormat('bold'));
|
||||
setIsItalic(selection.hasFormat('italic'));
|
||||
setIsUnderline(selection.hasFormat('underline'));
|
||||
setIsStrikethrough(selection.hasFormat('strikethrough'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({editorState}) => {
|
||||
editorState.read(
|
||||
() => {
|
||||
$updateToolbar();
|
||||
},
|
||||
{editor},
|
||||
);
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, _newEditor) => {
|
||||
$updateToolbar();
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CAN_UNDO_COMMAND,
|
||||
(payload) => {
|
||||
setCanUndo(payload);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CAN_REDO_COMMAND,
|
||||
(payload) => {
|
||||
setCanRedo(payload);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, $updateToolbar]);
|
||||
|
||||
return (
|
||||
<div className="toolbar" ref={toolbarRef}>
|
||||
<ToolbarButton
|
||||
disabled={!canUndo}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(UNDO_COMMAND, undefined);
|
||||
}}
|
||||
text="Undo"
|
||||
icon="bx bx-undo"
|
||||
/>
|
||||
<ToolbarButton
|
||||
disabled={!canRedo}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(REDO_COMMAND, undefined);
|
||||
}}
|
||||
text="Redo"
|
||||
icon="bx bx-redo"
|
||||
/>
|
||||
<Divider />
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
}}
|
||||
active={isBold}
|
||||
text="Format Bold"
|
||||
icon="bx bx-bold"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
||||
}}
|
||||
active={isItalic}
|
||||
text="Format Italics"
|
||||
icon="bx bx-italic"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
||||
}}
|
||||
active={isUnderline}
|
||||
text="Format Underline"
|
||||
icon="bx bx-underline"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
||||
}}
|
||||
active={isStrikethrough}
|
||||
text="Format Strikethrough"
|
||||
icon="bx bx-strikethrough"
|
||||
/>
|
||||
<Divider />
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
|
||||
}}
|
||||
text="Left Align"
|
||||
icon="bx bx-align-left"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
|
||||
}}
|
||||
text="Center Align"
|
||||
icon="bx bx-align-middle"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')}
|
||||
text="Right Align"
|
||||
icon="bx bx-align-right"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')}
|
||||
text="Justify Align"
|
||||
icon="bx bx-align-justify"
|
||||
/>{' '}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton(props: Pick<ActionButtonProps, "icon" | "disabled" | "onClick" | "text">) {
|
||||
return (
|
||||
<ActionButton
|
||||
className="toolbar-item"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
apps/client/src/widgets/type_widgets/text/lexical/index.css
Normal file
21
apps/client/src/widgets/type_widgets/text/lexical/index.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.note-detail-editable-text .lexical-wrapper {
|
||||
color: var(--main-text-color);
|
||||
font-family: var(--main-font-family);
|
||||
font-size: var(--main-font-size);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
margin-inline: var(--content-margin-inline);
|
||||
|
||||
>div[contenteditable="true"] {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.lexical-placeholder {
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
120
apps/client/src/widgets/type_widgets/text/lexical/index.tsx
Normal file
120
apps/client/src/widgets/type_widgets/text/lexical/index.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import "./index.css";
|
||||
|
||||
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import {LexicalComposer} from '@lexical/react/LexicalComposer';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {$createRangeSelection, $getRoot, $setSelection, CLEAR_HISTORY_COMMAND} from 'lexical';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { useEditorSpacedUpdate, useTriliumEvent } from '../../../react/hooks';
|
||||
import { TypeWidgetProps } from "../../type_widget";
|
||||
import ToolbarPlugin from "./ToolbarPlugin";
|
||||
|
||||
const theme = {
|
||||
// Theme styling goes here
|
||||
//...
|
||||
};
|
||||
|
||||
// Catch any errors that occur during Lexical updates and log them
|
||||
// or throw them as needed. If you don't throw them, Lexical will
|
||||
// try to recover gracefully without losing user data.
|
||||
function onError(error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
export default function LexicalText(props: TypeWidgetProps) {
|
||||
const initialConfig = {
|
||||
namespace: 'MyEditor',
|
||||
theme,
|
||||
onError,
|
||||
};
|
||||
|
||||
const placeholder = (
|
||||
<div className="lexical-placeholder">
|
||||
Enter some text...
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<ToolbarPlugin />
|
||||
<div className="lexical-wrapper">
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable /> as never}
|
||||
placeholder={placeholder as never}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
</div>
|
||||
<HistoryPlugin />
|
||||
<AutoFocusPlugin />
|
||||
<ScrollToEndPlugin />
|
||||
<CustomEditorPersistencePlugin {...props} />
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomEditorPersistencePlugin({ note, noteContext }: TypeWidgetProps) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteContext,
|
||||
noteType: "text",
|
||||
getData() {
|
||||
return {
|
||||
content: JSON.stringify(editor.toJSON().editorState)
|
||||
};
|
||||
},
|
||||
onContentChange(newContent) {
|
||||
if (!newContent) {
|
||||
editor.update(() => {
|
||||
$getRoot().clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorState = editor.parseEditorState(newContent);
|
||||
editor.setEditorState(editorState);
|
||||
} catch (err) {
|
||||
console.error("Error parsing Lexical content", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clear the history whenever note changes.
|
||||
useEffect(() => {
|
||||
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
|
||||
}, [ editor, note ]);
|
||||
|
||||
// Detect changes in content.
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(() => {
|
||||
spacedUpdate.scheduleUpdate();
|
||||
});
|
||||
}, [ spacedUpdate, editor ]);
|
||||
}
|
||||
|
||||
function ScrollToEndPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useTriliumEvent("scrollToEnd", () => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const lastChild = root.getLastDescendant();
|
||||
if (lastChild) {
|
||||
const selection = $createRangeSelection();
|
||||
selection.anchor.set(lastChild.getKey(), lastChild.getTextContentSize(), 'text');
|
||||
selection.focus.set(lastChild.getKey(), lastChild.getTextContentSize(), 'text');
|
||||
$setSelection(selection);
|
||||
}
|
||||
});
|
||||
editor.focus();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -126,7 +126,7 @@
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "8.0.0",
|
||||
"vite": "8.0.1",
|
||||
"ws": "8.19.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.1"
|
||||
|
||||
@@ -155,8 +155,6 @@
|
||||
"proxy-instruction": "如果您将代理设置留空,将使用系统代理(仅适用于桌面应用)",
|
||||
"password": "密码",
|
||||
"password-placeholder": "密码",
|
||||
"totp-token": "TOTP 验证码",
|
||||
"totp-token-placeholder": "请输入 TOTP 验证码",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成设置"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -155,8 +155,6 @@
|
||||
"proxy-instruction": "如果您將代理設定留空,將使用系統代理(僅適用於桌面版)",
|
||||
"password": "密碼",
|
||||
"password-placeholder": "密碼",
|
||||
"totp-token": "TOTP 驗證碼",
|
||||
"totp-token-placeholder": "請輸入 TOTP 驗證碼",
|
||||
"back": "返回",
|
||||
"finish-setup": "完成設定"
|
||||
},
|
||||
|
||||
@@ -141,10 +141,6 @@
|
||||
<label for="password"><%= t("setup_sync-from-server.password") %></label>
|
||||
<input type="password" id="password" class="form-control" placeholder="<%= t("setup_sync-from-server.password-placeholder") %>">
|
||||
</div>
|
||||
<div id="totp-section" class="form-group" style="margin-bottom: 8px; display: none;">
|
||||
<label for="totp-token"><%= t("setup_sync-from-server.totp-token") %></label>
|
||||
<input type="text" id="totp-token" class="form-control" placeholder="<%= t("setup_sync-from-server.totp-token-placeholder") %>" autocomplete="one-time-code">
|
||||
</div>
|
||||
|
||||
<button type="button" data-action="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,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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { OptionRow } from "@triliumnext/commons";
|
||||
export interface SetupStatusResponse {
|
||||
syncVersion: number;
|
||||
schemaExists: boolean;
|
||||
totpEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface ExecOpts {
|
||||
cookieJar?: CookieJar;
|
||||
auth?: {
|
||||
password?: string;
|
||||
totpToken?: string;
|
||||
};
|
||||
timeout: number;
|
||||
body?: string | {};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"user-agent-data-types": "0.4.2",
|
||||
"vite": "8.0.0",
|
||||
"vite": "8.0.1",
|
||||
"vitest": "4.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"upath": "2.0.1",
|
||||
"vite": "8.0.0",
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
"vitest": "4.1.0"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ckeditor5-premium-features": "47.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@smithy/middleware-retry": "4.4.43",
|
||||
"@smithy/middleware-retry": "4.4.44",
|
||||
"@types/jquery": "4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fuse.js": "7.1.0",
|
||||
"katex": "0.16.38",
|
||||
"katex": "0.16.39",
|
||||
"mermaid": "11.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
887
pnpm-lock.yaml
generated
887
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user