mirror of
https://github.com/zadam/trilium.git
synced 2026-02-07 23:19:16 +01:00
Compare commits
28 Commits
main
...
feature/tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b9e58a8b0 | ||
|
|
d179616702 | ||
|
|
f53c64f76d | ||
|
|
537b468714 | ||
|
|
6dcef0b1e5 | ||
|
|
622fe33264 | ||
|
|
8ee81b2607 | ||
|
|
620f2e93a4 | ||
|
|
47cb6531ff | ||
|
|
5ed13ff68c | ||
|
|
0f62f864c8 | ||
|
|
47b53335af | ||
|
|
b875924c8e | ||
|
|
7c002e2871 | ||
|
|
666d0e7c08 | ||
|
|
737ea34a24 | ||
|
|
49bff10ac5 | ||
|
|
1b4c01015b | ||
|
|
995cdf330e | ||
|
|
cbea727f08 | ||
|
|
152ec404bf | ||
|
|
5f6b324c00 | ||
|
|
395aa410f2 | ||
|
|
1f81e864bc | ||
|
|
8849d1f4f2 | ||
|
|
ada530cfef | ||
|
|
1057d55e36 | ||
|
|
cafe4254f9 |
@@ -19,6 +19,7 @@ import type RootContainer from "../widgets/containers/root_container.js";
|
||||
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
|
||||
import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js";
|
||||
import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js";
|
||||
import { ImportPreviewData } from "../widgets/dialogs/import_preview.jsx";
|
||||
import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx";
|
||||
import type { InfoProps } from "../widgets/dialogs/info.jsx";
|
||||
import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx";
|
||||
@@ -130,6 +131,7 @@ export type CommandMappings = {
|
||||
showConfirmDialog: ConfirmWithMessageOptions;
|
||||
showRecentChanges: CommandData & { ancestorNoteId: string };
|
||||
showImportDialog: CommandData & { noteId: string };
|
||||
showImportPreviewDialog: CommandData & ImportPreviewData;
|
||||
openNewNoteSplit: NoteCommandData;
|
||||
openInWindow: NoteCommandData;
|
||||
openInPopup: CommandData & { noteIdOrPath: string; };
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import type RootContainer from "../widgets/containers/root_container.js";
|
||||
|
||||
import AboutDialog from "../widgets/dialogs/about.js";
|
||||
import HelpDialog from "../widgets/dialogs/help.js";
|
||||
import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js";
|
||||
import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
|
||||
import PromptDialog from "../widgets/dialogs/prompt.js";
|
||||
import AddLinkDialog from "../widgets/dialogs/add_link.js";
|
||||
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
|
||||
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
|
||||
import BranchPrefixDialog from "../widgets/dialogs/branch_prefix.js";
|
||||
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
|
||||
import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js";
|
||||
import MoveToDialog from "../widgets/dialogs/move_to.js";
|
||||
import CloneToDialog from "../widgets/dialogs/clone_to.js";
|
||||
import ImportDialog from "../widgets/dialogs/import.js";
|
||||
import ExportDialog from "../widgets/dialogs/export.js";
|
||||
import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js";
|
||||
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
|
||||
import ConfirmDialog from "../widgets/dialogs/confirm.js";
|
||||
import RevisionsDialog from "../widgets/dialogs/revisions.js";
|
||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
|
||||
import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import BulkActionsDialog from "../widgets/dialogs/bulk_actions.js";
|
||||
import CallToActionDialog from "../widgets/dialogs/call_to_action.jsx";
|
||||
import CloneToDialog from "../widgets/dialogs/clone_to.js";
|
||||
import ConfirmDialog from "../widgets/dialogs/confirm.js";
|
||||
import DeleteNotesDialog from "../widgets/dialogs/delete_notes.js";
|
||||
import ExportDialog from "../widgets/dialogs/export.js";
|
||||
import HelpDialog from "../widgets/dialogs/help.js";
|
||||
import ImportDialog from "../widgets/dialogs/import.js";
|
||||
import ImportPreviewDialog from "../widgets/dialogs/import_preview.jsx";
|
||||
import IncludeNoteDialog from "../widgets/dialogs/include_note.js";
|
||||
import IncorrectCpuArchDialog from "../widgets/dialogs/incorrect_cpu_arch.js";
|
||||
import InfoDialog from "../widgets/dialogs/info.js";
|
||||
import JumpToNoteDialog from "../widgets/dialogs/jump_to_note.js";
|
||||
import MarkdownImportDialog from "../widgets/dialogs/markdown_import.js";
|
||||
import MoveToDialog from "../widgets/dialogs/move_to.js";
|
||||
import NoteTypeChooserDialog from "../widgets/dialogs/note_type_chooser.js";
|
||||
import PopupEditorDialog from "../widgets/dialogs/PopupEditor.jsx";
|
||||
import PromptDialog from "../widgets/dialogs/prompt.js";
|
||||
import ProtectedSessionPasswordDialog from "../widgets/dialogs/protected_session_password.js";
|
||||
import RecentChangesDialog from "../widgets/dialogs/recent_changes.js";
|
||||
import RevisionsDialog from "../widgets/dialogs/revisions.js";
|
||||
import SortChildNotesDialog from "../widgets/dialogs/sort_child_notes.js";
|
||||
import ToastContainer from "../widgets/Toast.jsx";
|
||||
|
||||
export function applyModals(rootContainer: RootContainer) {
|
||||
@@ -52,5 +52,6 @@ export function applyModals(rootContainer: RootContainer) {
|
||||
.child(<IncorrectCpuArchDialog />)
|
||||
.child(<PopupEditorDialog />)
|
||||
.child(<CallToActionDialog />)
|
||||
.child(<ToastContainer />);
|
||||
.child(<ToastContainer />)
|
||||
.child(<ImportPreviewDialog />);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import server from "./server.js";
|
||||
import ws from "./ws.js";
|
||||
import utils from "./utils.js";
|
||||
import { ImportPreviewResponse, WebSocketMessage } from "@triliumnext/commons";
|
||||
|
||||
import appContext from "../components/app_context.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
import server from "./server.js";
|
||||
import toastService, { type ToastOptionsWithRequiredId } from "./toast.js";
|
||||
import utils from "./utils.js";
|
||||
import ws from "./ws.js";
|
||||
|
||||
type BooleanLike = boolean | "true" | "false";
|
||||
type BooleanLike = "true" | "false";
|
||||
|
||||
export interface UploadFilesOptions {
|
||||
safeImport?: BooleanLike;
|
||||
@@ -48,7 +49,7 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file
|
||||
dataType: "json",
|
||||
type: "POST",
|
||||
timeout: 60 * 60 * 1000,
|
||||
error: function (xhr) {
|
||||
error (xhr) {
|
||||
toastService.showError(t("import.failed", { message: xhr.responseText }));
|
||||
},
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
@@ -57,6 +58,58 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFilesWithPreview(parentNoteId: string, files: string[] | File[]) {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = utils.randomString(10);
|
||||
const results: ImportPreviewResponse[] = [];
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("upload", file);
|
||||
formData.append("taskId", taskId);
|
||||
|
||||
results.push(await $.ajax({
|
||||
url: `${window.glob.baseApiUrl}notes/${parentNoteId}/preview-import`,
|
||||
headers: await server.getHeaders(),
|
||||
data: formData,
|
||||
dataType: "json",
|
||||
type: "POST",
|
||||
timeout: 60 * 60 * 1000,
|
||||
error (xhr) {
|
||||
toastService.showError(t("import.failed", { message: xhr.responseText }));
|
||||
},
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false // NEEDED, DON'T REMOVE THIS
|
||||
}));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function executeUploadWithPreview(parentNoteId: string, files: ImportPreviewResponse[], options: UploadFilesOptions) {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = utils.randomString(10);
|
||||
let counter = 0;
|
||||
|
||||
for (const file of files) {
|
||||
counter++;
|
||||
|
||||
server.post(
|
||||
`notes/${parentNoteId}/execute-import`,
|
||||
{
|
||||
...options,
|
||||
id: file.id,
|
||||
taskId,
|
||||
last: counter === files.length ? "true" : "false"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function makeToast(id: string, message: string): ToastOptionsWithRequiredId {
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -913,6 +913,10 @@ export function handleRightToLeftPlacement<T extends string>(placement: T) {
|
||||
return placement;
|
||||
}
|
||||
|
||||
export function boolToString(value: boolean) {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
export default {
|
||||
reloadFrontendApp,
|
||||
restartDesktopApp,
|
||||
|
||||
@@ -2283,5 +2283,37 @@
|
||||
},
|
||||
"bookmark_buttons": {
|
||||
"bookmarks": "Bookmarks"
|
||||
},
|
||||
"import_preview": {
|
||||
"intro_safe_one": "You are about to import an archive with no unsafe content.",
|
||||
"intro_safe_other": "You are about to import {{count}} archives with no unsafe content.",
|
||||
"intro_unsafe_one": "You are about to import an archive with active content.",
|
||||
"intro_unsafe_other": "You are about to import {{count}} archives, some of which have active content.",
|
||||
|
||||
"title": "Import preview",
|
||||
"notes_count_one": "{{count}} note",
|
||||
"notes_count_other": "{{count}} notes",
|
||||
"attributes_count_one": "{{count}} attribute",
|
||||
"attributes_count_other": "{{count}} attributes",
|
||||
"attachments_count_one": "{{count}} attachment",
|
||||
"attachments_count_other": "{{count}} attachments",
|
||||
"cancel": "Cancel",
|
||||
"import": "Import",
|
||||
"import_with_timeout": "Import ({{timeout}})",
|
||||
"import_safely": "Import safely (recommended)",
|
||||
"import_safely_description": "Scripts, widgets and icon packs will be disabled.",
|
||||
"import_trust": "Trust and enable active content",
|
||||
"import_trust_description": "Only do this if you trust the source.",
|
||||
"parent_note": "Parent note",
|
||||
"badge_client_side_scripting_title": "Client-side scripting",
|
||||
"badge_client_side_scripting_tooltip": "Can modify the application's interface and send requests to the server. Malicious scripts can read or change your notes.",
|
||||
"badge_server_side_scripting_title": "Server-side scripting",
|
||||
"badge_server_side_scripting_tooltip": "Runs on the server with access to your database and local files. Malicious scripts could read, modify, or delete your data.",
|
||||
"badge_code_execution_title": "Code execution",
|
||||
"badge_code_execution_description": "Allows running arbitrary programs on your system or server. This can fully compromise your data and environment.",
|
||||
"badge_icon_pack_title": "Icon pack",
|
||||
"badge_icon_pack_description": "Provides custom icons. Usually safe, but malformed or very large icon packs may cause performance or stability issues.",
|
||||
"badge_web_view_title": "Web view",
|
||||
"badge_web_view_description": "Displays external web pages inside Trilium. These pages may track activity or receive information about your notes."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Modal from "../react/Modal.jsx";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import { ComponentChildren } from "preact";
|
||||
import appContext, { CommandNames } from "../../components/app_context.js";
|
||||
import RawHtml from "../react/RawHtml.jsx";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import appContext, { CommandNames } from "../../components/app_context.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import keyboard_actions from "../../services/keyboard_actions.js";
|
||||
import { Card } from "../react/Card.jsx";
|
||||
import { useTriliumEvent } from "../react/hooks.jsx";
|
||||
import Modal from "../react/Modal.jsx";
|
||||
import RawHtml from "../react/RawHtml.jsx";
|
||||
|
||||
export default function HelpDialog() {
|
||||
const [ shown, setShown ] = useState(false);
|
||||
@@ -110,7 +111,7 @@ export default function HelpDialog() {
|
||||
|
||||
function KeyboardShortcut({ commands, description }: { commands: CommandNames | CommandNames[], description: string }) {
|
||||
const [ shortcuts, setShortcuts ] = useState<string[]>([]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const shortcuts: string[] = [];
|
||||
@@ -148,20 +149,6 @@ function FixedKeyboardShortcut({ keys, description }: { keys?: string[], descrip
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, children }: { title: string, children: ComponentChildren }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">{title}</h5>
|
||||
|
||||
<p className="card-text">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function editShortcuts() {
|
||||
appContext.tabManager.openContextWithNote("_optionsShortcuts", { activate: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import importService, { UploadFilesOptions } from "../../services/import";
|
||||
import tree from "../../services/tree";
|
||||
import { boolToString } from "../../services/utils";
|
||||
import Button from "../react/Button";
|
||||
import FormCheckbox from "../react/FormCheckbox";
|
||||
import FormFileUpload from "../react/FormFileUpload";
|
||||
import FormGroup, { FormMultiGroup } from "../react/FormGroup";
|
||||
import { useTriliumEvent, useTriliumOptionBool } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import importService, { UploadFilesOptions } from "../../services/import";
|
||||
import { useTriliumEvent, useTriliumOptionBool } from "../react/hooks";
|
||||
|
||||
export default function ImportDialog() {
|
||||
const [ compressImages ] = useTriliumOptionBool("compressImages");
|
||||
@@ -98,6 +100,4 @@ export default function ImportDialog() {
|
||||
);
|
||||
}
|
||||
|
||||
function boolToString(value: boolean) {
|
||||
return value ? "true" : "false";
|
||||
}
|
||||
|
||||
|
||||
47
apps/client/src/widgets/dialogs/import_preview.css
Normal file
47
apps/client/src/widgets/dialogs/import_preview.css
Normal file
@@ -0,0 +1,47 @@
|
||||
.modal.import-preview-dialog {
|
||||
.stats {
|
||||
color: var(--muted-text-color);
|
||||
font-size: 0.9em;
|
||||
|
||||
span:after {
|
||||
content: " • "
|
||||
}
|
||||
|
||||
span:last-of-type:after {
|
||||
content: unset;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.critical { --color: #ff7979; }
|
||||
.warning { --color: #e2b11d; }
|
||||
.safe { --color: var(--admonition-tip-accent-color); }
|
||||
|
||||
.card {
|
||||
margin-bottom: 1em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding-inline: 0.75em;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
.dangerous-categories {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
margin-top: 0.5em;
|
||||
--badge-radius: 1000px;
|
||||
|
||||
.ext-badge {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
216
apps/client/src/widgets/dialogs/import_preview.tsx
Normal file
216
apps/client/src/widgets/dialogs/import_preview.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import "./import_preview.css";
|
||||
|
||||
import { DangerousAttributeCategory, ImportPreviewResponse } from "@triliumnext/commons";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import { executeUploadWithPreview } from "../../services/import";
|
||||
import { boolToString } from "../../services/utils";
|
||||
import { Badge } from "../react/Badge";
|
||||
import Button from "../react/Button";
|
||||
import { Card } from "../react/Card";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import FormRadioGroup from "../react/FormRadioGroup";
|
||||
import { useTriliumEvent, useTriliumOptionBool } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
|
||||
export interface ImportPreviewData {
|
||||
parentNoteId: string;
|
||||
previews: ImportPreviewResponse[];
|
||||
}
|
||||
|
||||
type DangerousCategory = "critical" | "warning";
|
||||
const DANGEROUS_CATEGORIES_MAPPINGS: Record<DangerousAttributeCategory, {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
category: DangerousCategory;
|
||||
}> = {
|
||||
clientSideScripting: {
|
||||
icon: "bx bx-window-alt",
|
||||
title: t("import_preview.badge_client_side_scripting_title"),
|
||||
description: t("import_preview.badge_client_side_scripting_tooltip"),
|
||||
category: "critical"
|
||||
},
|
||||
serverSideScripting: {
|
||||
icon: "bx bx-server",
|
||||
title: t("import_preview.badge_server_side_scripting_title"),
|
||||
description: t("import_preview.badge_server_side_scripting_tooltip"),
|
||||
category: "critical"
|
||||
},
|
||||
codeExecution: {
|
||||
icon: "bx bx-terminal",
|
||||
title: t("import_preview.badge_code_execution_title"),
|
||||
description: t("import_preview.badge_code_execution_description"),
|
||||
category: "critical"
|
||||
},
|
||||
iconPack: {
|
||||
icon: "bx bx-package",
|
||||
title: t("import_preview.badge_icon_pack_title"),
|
||||
description: t("import_preview.badge_icon_pack_description"),
|
||||
category: "warning"
|
||||
},
|
||||
webview: {
|
||||
icon: "bx bx-globe",
|
||||
title: t("import_preview.badge_web_view_title"),
|
||||
description: t("import_preview.badge_web_view_description"),
|
||||
category: "warning"
|
||||
}
|
||||
};
|
||||
|
||||
const SEVERITY_ORDER: Record<DangerousCategory, number> = {
|
||||
critical: 0,
|
||||
warning: 1
|
||||
};
|
||||
|
||||
const IMPORT_BUTTON_TIMEOUT = 3;
|
||||
|
||||
export default function ImportPreviewDialog() {
|
||||
const [ data, setData ] = useState<ImportPreviewData | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const [ importMethod, setImportMethod ] = useState<string>("safe");
|
||||
const isDangerousImport = data?.previews.some(preview => preview.isDangerous);
|
||||
const [ importButtonTimeout, setImportButtonTimeout ] = useState(0);
|
||||
const [ compressImages ] = useTriliumOptionBool("compressImages");
|
||||
|
||||
useEffect(() => {
|
||||
// If safe → reset and do nothing
|
||||
if (!isDangerousImport) {
|
||||
setImportButtonTimeout(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start countdown
|
||||
setImportButtonTimeout(IMPORT_BUTTON_TIMEOUT);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setImportButtonTimeout(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isDangerousImport]);
|
||||
|
||||
useTriliumEvent("showImportPreviewDialog", (data) => {
|
||||
setData(data);
|
||||
setShown(true);
|
||||
setImportButtonTimeout(IMPORT_BUTTON_TIMEOUT);
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="import-preview-dialog"
|
||||
size="lg"
|
||||
title={t("import_preview.title")}
|
||||
footer={<>
|
||||
<Button text={t("import_preview.cancel")} onClick={() => setShown(false)}/>
|
||||
<Button
|
||||
text={importButtonTimeout
|
||||
? t("import_preview.import_with_timeout", { timeout: importButtonTimeout })
|
||||
: t("import_preview.import")}
|
||||
disabled={importButtonTimeout > 0}
|
||||
primary
|
||||
/>
|
||||
</>}
|
||||
show={shown}
|
||||
onSubmit={() => {
|
||||
if (!data) return;
|
||||
executeUploadWithPreview(data.parentNoteId, data.previews, {
|
||||
shrinkImages: boolToString(compressImages),
|
||||
safeImport: boolToString(importMethod === "safe")
|
||||
});
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => {
|
||||
setShown(false);
|
||||
setData(null);
|
||||
setImportButtonTimeout(3);
|
||||
}}
|
||||
>
|
||||
<p>{isDangerousImport
|
||||
? t("import_preview.intro_unsafe", { count: data?.previews.length })
|
||||
: t("import_preview.intro_safe", { count: data?.previews.length })}</p>
|
||||
|
||||
{data?.previews.map(preview => <SinglePreview key={preview.id} preview={preview} />)}
|
||||
|
||||
<div className="import-options">
|
||||
<FormGroup name="parent-note" label={t("import_preview.parent_note")}>
|
||||
<NoteAutocomplete
|
||||
noteId={data?.parentNoteId}
|
||||
noteIdChanged={noteId => {
|
||||
if (!data) return;
|
||||
setData({
|
||||
...data,
|
||||
parentNoteId: noteId
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormRadioGroup
|
||||
name="import-method"
|
||||
currentValue={importMethod} onChange={setImportMethod}
|
||||
values={[
|
||||
{ value: "safe", label: t("import_preview.import_safely"), inlineDescription: t("import_preview.import_safely_description") },
|
||||
{ value: "unsafe", label: t("import_preview.import_trust"), inlineDescription: t("import_preview.import_trust_description") }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SinglePreview({ preview }: { preview: ImportPreviewResponse }) {
|
||||
const categories = sortDangerousAttributeCategoryBySeverity(preview.dangerousAttributeCategories);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={preview.fileName}
|
||||
className={DANGEROUS_CATEGORIES_MAPPINGS[categories[0]]?.category ?? "safe"}
|
||||
>
|
||||
<div className="stats">
|
||||
<span>{t("import_preview.notes_count", { count: preview.numNotes })}</span>
|
||||
<span>{t("import_preview.attributes_count", { count: preview.numAttributes })}</span>
|
||||
<span>{t("import_preview.attachments_count", { count: preview.numAttachments })}</span>
|
||||
</div>
|
||||
|
||||
<div className="dangerous-categories">
|
||||
{categories.length > 0
|
||||
? categories.map(dangerousCategory => {
|
||||
const mapping = DANGEROUS_CATEGORIES_MAPPINGS[dangerousCategory];
|
||||
return (
|
||||
<Badge
|
||||
key={dangerousCategory}
|
||||
className={mapping.category}
|
||||
icon={mapping.icon}
|
||||
text={mapping.title}
|
||||
tooltip={mapping.description}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: (
|
||||
<Badge
|
||||
className="safe"
|
||||
icon="bx bx-check"
|
||||
text="Safe"
|
||||
tooltip="This archive has no active content such as scripts or widgets that could affect your knowledge base or access sensitive data."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function sortDangerousAttributeCategoryBySeverity(categories: string[]) {
|
||||
return categories.toSorted((a, b) => {
|
||||
const aLevel = DANGEROUS_CATEGORIES_MAPPINGS[a].category;
|
||||
const bLevel = DANGEROUS_CATEGORIES_MAPPINGS[b].category;
|
||||
return SEVERITY_ORDER[aLevel] - SEVERITY_ORDER[bLevel];
|
||||
});
|
||||
}
|
||||
@@ -523,46 +523,59 @@ export default class NoteTreeWidget extends NoteContextAwareWidget {
|
||||
const dataTransfer = data.dataTransfer;
|
||||
|
||||
if (dataTransfer && dataTransfer.files && dataTransfer.files.length > 0) {
|
||||
const files = [...dataTransfer.files]; // chrome has issue that dataTransfer.files empties after async operation
|
||||
const files: File[] = [...dataTransfer.files]; // chrome has issue that dataTransfer.files empties after async operation
|
||||
|
||||
const importService = await import("../services/import.js");
|
||||
const isTriliumImport = files.every(f => f.name.endsWith(".trilium"));
|
||||
const parentNoteId = node.data.noteId;
|
||||
|
||||
importService.uploadFiles("notes", node.data.noteId, files, {
|
||||
safeImport: true,
|
||||
shrinkImages: true,
|
||||
textImportedAsText: true,
|
||||
codeImportedAsCode: true,
|
||||
explodeArchives: true,
|
||||
replaceUnderscoresWithSpaces: true
|
||||
});
|
||||
} else {
|
||||
const jsonStr = dataTransfer.getData("text");
|
||||
let notes: BranchRow[];
|
||||
|
||||
try {
|
||||
notes = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
logError(`Cannot parse JSON '${jsonStr}' into notes for drop`);
|
||||
if (!isTriliumImport) {
|
||||
importService.uploadFiles("notes", parentNoteId, files, {
|
||||
safeImport: true,
|
||||
shrinkImages: true,
|
||||
textImportedAsText: true,
|
||||
codeImportedAsCode: true,
|
||||
explodeArchives: true,
|
||||
replaceUnderscoresWithSpaces: true
|
||||
});
|
||||
} else {
|
||||
importService.uploadFilesWithPreview(parentNoteId, files).then((previews) => {
|
||||
if (!previews) return;
|
||||
this.triggerCommand("showImportPreviewDialog", {
|
||||
parentNoteId,
|
||||
previews
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// This function MUST be defined to enable dropping of items on the tree.
|
||||
// data.hitMode is 'before', 'after', or 'over'.
|
||||
|
||||
const selectedBranchIds = notes
|
||||
.map((note) => note.branchId)
|
||||
.filter((branchId) => branchId) as string[];
|
||||
|
||||
if (data.hitMode === "before") {
|
||||
branchService.moveBeforeBranch(selectedBranchIds, node.data.branchId);
|
||||
} else if (data.hitMode === "after") {
|
||||
branchService.moveAfterBranch(selectedBranchIds, node.data.branchId);
|
||||
} else if (data.hitMode === "over") {
|
||||
branchService.moveToParentNote(selectedBranchIds, node.data.branchId, this.componentId);
|
||||
} else {
|
||||
throw new Error(`Unknown hitMode '${data.hitMode}'`);
|
||||
}
|
||||
}
|
||||
const jsonStr = dataTransfer.getData("text");
|
||||
let notes: BranchRow[];
|
||||
|
||||
try {
|
||||
notes = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
logError(`Cannot parse JSON '${jsonStr}' into notes for drop`);
|
||||
return;
|
||||
}
|
||||
|
||||
// This function MUST be defined to enable dropping of items on the tree.
|
||||
// data.hitMode is 'before', 'after', or 'over'.
|
||||
|
||||
const selectedBranchIds = notes
|
||||
.map((note) => note.branchId)
|
||||
.filter((branchId) => branchId) as string[];
|
||||
|
||||
if (data.hitMode === "before") {
|
||||
branchService.moveBeforeBranch(selectedBranchIds, node.data.branchId);
|
||||
} else if (data.hitMode === "after") {
|
||||
branchService.moveAfterBranch(selectedBranchIds, node.data.branchId);
|
||||
} else if (data.hitMode === "over") {
|
||||
branchService.moveToParentNote(selectedBranchIds, node.data.branchId, this.componentId);
|
||||
} else {
|
||||
throw new Error(`Unknown hitMode '${data.hitMode}'`);
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
lazyLoad: (event, data) => {
|
||||
|
||||
20
apps/client/src/widgets/react/Card.tsx
Normal file
20
apps/client/src/widgets/react/Card.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import clsx from "clsx";
|
||||
import type { ComponentChildren } from "preact";
|
||||
|
||||
export function Card({ title, className, children }: {
|
||||
title: string;
|
||||
children: ComponentChildren;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx("card", className)}>
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">{title}</h5>
|
||||
|
||||
<p className="card-text">
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
"use strict";
|
||||
|
||||
import enexImportService from "../../services/import/enex.js";
|
||||
import opmlImportService from "../../services/import/opml.js";
|
||||
import zipImportService from "../../services/import/zip.js";
|
||||
import singleImportService from "../../services/import/single.js";
|
||||
import cls from "../../services/cls.js";
|
||||
|
||||
import { ImportPreviewResponse } from "@triliumnext/commons";
|
||||
import type { Request } from "express";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import becca from "../../becca/becca.js";
|
||||
import beccaLoader from "../../becca/becca_loader.js";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import cls from "../../services/cls.js";
|
||||
import enexImportService from "../../services/import/enex.js";
|
||||
import opmlImportService from "../../services/import/opml.js";
|
||||
import singleImportService from "../../services/import/single.js";
|
||||
import zipImportService from "../../services/import/zip.js";
|
||||
import previewZipForImport from "../../services/import/zip_preview.js";
|
||||
import log from "../../services/log.js";
|
||||
import TaskContext from "../../services/task_context.js";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import type { Request } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
|
||||
async function importNotesToBranch(req: Request) {
|
||||
@@ -79,6 +83,10 @@ async function importNotesToBranch(req: Request) {
|
||||
return [500, message];
|
||||
}
|
||||
|
||||
onImportDone(note, last, taskContext, parentNoteId);
|
||||
}
|
||||
|
||||
function onImportDone(note: BNote | null, last: "true" | "false", taskContext: TaskContext<"importNotes">, parentNoteId: string) {
|
||||
if (!note) {
|
||||
return [500, "No note was generated as a result of the import."];
|
||||
}
|
||||
@@ -88,7 +96,7 @@ async function importNotesToBranch(req: Request) {
|
||||
setTimeout(
|
||||
() =>
|
||||
taskContext.taskSucceeded({
|
||||
parentNoteId: parentNoteId,
|
||||
parentNoteId,
|
||||
importedNoteId: note?.noteId
|
||||
}),
|
||||
1000
|
||||
@@ -138,14 +146,78 @@ function importAttachmentsToNote(req: Request) {
|
||||
setTimeout(
|
||||
() =>
|
||||
taskContext.taskSucceeded({
|
||||
parentNoteId: parentNoteId
|
||||
parentNoteId
|
||||
}),
|
||||
1000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface ImportRecord {
|
||||
path: string;
|
||||
}
|
||||
|
||||
const importStore: Record<string, ImportRecord> = {};
|
||||
|
||||
async function importPreview(req: Request) {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
throw new ValidationError("No file has been uploaded");
|
||||
}
|
||||
|
||||
if (!file.originalname.endsWith(".trilium")) {
|
||||
throw new ValidationError("Preview supports only .trilium files.");
|
||||
}
|
||||
|
||||
try {
|
||||
const previewInfo = await previewZipForImport(file.path);
|
||||
const id = file.filename;
|
||||
|
||||
importStore[id] = {
|
||||
path: file.path
|
||||
};
|
||||
|
||||
return {
|
||||
...previewInfo,
|
||||
fileName: file.originalname,
|
||||
id
|
||||
} satisfies ImportPreviewResponse;
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
throw new ValidationError("Error while generating the preview.");
|
||||
}
|
||||
}
|
||||
|
||||
async function importExecute(req: Request) {
|
||||
const { id } = req.body;
|
||||
|
||||
const importRecord = importStore[id];
|
||||
if (!importRecord) throw new ValidationError("Unable to find a record of the upload, maybe it expired or the ID is missing or incorrect.");
|
||||
|
||||
const { taskId, last } = req.body;
|
||||
const options = {
|
||||
safeImport: req.body.safeImport !== "false",
|
||||
shrinkImages: req.body.shrinkImages !== "false",
|
||||
textImportedAsText: req.body.textImportedAsText !== "false",
|
||||
codeImportedAsCode: req.body.codeImportedAsCode !== "false",
|
||||
explodeArchives: req.body.explodeArchives !== "false",
|
||||
replaceUnderscoresWithSpaces: req.body.replaceUnderscoresWithSpaces !== "false"
|
||||
};
|
||||
|
||||
const taskContext = TaskContext.getInstance(taskId, "importNotes", options);
|
||||
const { parentNoteId } = req.params;
|
||||
const parentNote = becca.getNoteOrThrow(parentNoteId);
|
||||
|
||||
const buffer = readFileSync(importRecord.path);
|
||||
const note = await zipImportService.importZip(taskContext, buffer, parentNote);
|
||||
onImportDone(note, last, taskContext, parentNoteId);
|
||||
|
||||
return importRecord;
|
||||
}
|
||||
|
||||
export default {
|
||||
importNotesToBranch,
|
||||
importAttachmentsToNote
|
||||
importAttachmentsToNote,
|
||||
importPreview,
|
||||
importExecute
|
||||
};
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import express, { type RequestHandler } from "express";
|
||||
import { mkdirSync } from "fs";
|
||||
import multer from "multer";
|
||||
import log from "../services/log.js";
|
||||
import cls from "../services/cls.js";
|
||||
import sql from "../services/sql.js";
|
||||
import entityChangesService from "../services/entity_changes.js";
|
||||
import { join } from "path";
|
||||
|
||||
import AbstractBeccaEntity from "../becca/entities/abstract_becca_entity.js";
|
||||
import NotFoundError from "../errors/not_found_error.js";
|
||||
import ValidationError from "../errors/validation_error.js";
|
||||
import auth from "../services/auth.js";
|
||||
import cls from "../services/cls.js";
|
||||
import dataDirs from "../services/data_dir.js";
|
||||
import entityChangesService from "../services/entity_changes.js";
|
||||
import log from "../services/log.js";
|
||||
import sql from "../services/sql.js";
|
||||
import { randomString, safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../services/utils.js";
|
||||
|
||||
const MAX_ALLOWED_FILE_SIZE_MB = 250;
|
||||
export const router = express.Router();
|
||||
@@ -67,9 +71,9 @@ export function apiResultHandler(req: express.Request, res: express.Response, re
|
||||
return send(res, statusCode, response);
|
||||
} else if (result === undefined) {
|
||||
return send(res, 204, "");
|
||||
} else {
|
||||
return send(res, 200, result);
|
||||
}
|
||||
return send(res, 200, result);
|
||||
|
||||
}
|
||||
|
||||
function send(res: express.Response, statusCode: number, response: unknown) {
|
||||
@@ -81,14 +85,14 @@ function send(res: express.Response, statusCode: number, response: unknown) {
|
||||
res.status(statusCode).send(response);
|
||||
|
||||
return response.length;
|
||||
} else {
|
||||
const json = JSON.stringify(response);
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.status(statusCode).send(json);
|
||||
|
||||
return json.length;
|
||||
}
|
||||
const json = JSON.stringify(response);
|
||||
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.status(statusCode).send(json);
|
||||
|
||||
return json.length;
|
||||
|
||||
}
|
||||
|
||||
export function apiRoute(method: HttpMethod, path: string, routeHandler: SyncRouteRequestHandler) {
|
||||
@@ -190,10 +194,51 @@ export function createUploadMiddleware(): RequestHandler {
|
||||
return multer(multerOptions).single("upload");
|
||||
}
|
||||
|
||||
export function createImportUploadMiddleware(): RequestHandler {
|
||||
const outDir = join(dataDirs.TMP_DIR, "upload");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const multerOptions: multer.Options = {
|
||||
storage: multer.diskStorage({
|
||||
destination(req, file, cb) {
|
||||
cb(null, outDir);
|
||||
},
|
||||
filename(req, file, cb) {
|
||||
cb(null, `${randomString(13)}.trilium`);
|
||||
}
|
||||
}),
|
||||
fileFilter: (req: express.Request, file, cb) => {
|
||||
// UTF-8 file names are not well decoded by multer/busboy, so we handle the conversion on our side.
|
||||
// See https://github.com/expressjs/multer/pull/1102.
|
||||
file.originalname = Buffer.from(file.originalname, "latin1").toString("utf-8");
|
||||
cb(null, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!process.env.TRILIUM_NO_UPLOAD_LIMIT) {
|
||||
multerOptions.limits = {
|
||||
fileSize: MAX_ALLOWED_FILE_SIZE_MB * 1024 * 1024
|
||||
};
|
||||
}
|
||||
|
||||
return multer(multerOptions).single("upload");
|
||||
}
|
||||
|
||||
const uploadMiddleware = createUploadMiddleware();
|
||||
const importUploadMiddleware = createImportUploadMiddleware();
|
||||
|
||||
export const uploadMiddlewareWithErrorHandling = function (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
uploadMiddleware(req, res, function (err) {
|
||||
uploadMiddleware(req, res, (err) => {
|
||||
if (err?.code === "LIMIT_FILE_SIZE") {
|
||||
res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it excceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const importMiddlewareWithErrorHandling = function (req: express.Request, res: express.Response, next: express.NextFunction) {
|
||||
importUploadMiddleware(req, res, (err) => {
|
||||
if (err?.code === "LIMIT_FILE_SIZE") {
|
||||
res.setHeader("Content-Type", "text/plain").status(400).send(`Cannot upload file because it excceeded max allowed file size of ${MAX_ALLOWED_FILE_SIZE_MB} MiB`);
|
||||
} else {
|
||||
|
||||
@@ -10,9 +10,9 @@ import etapiBackupRoute from "../etapi/backup.js";
|
||||
import etapiBranchRoutes from "../etapi/branches.js";
|
||||
import etapiMetricsRoute from "../etapi/metrics.js";
|
||||
import etapiNoteRoutes from "../etapi/notes.js";
|
||||
import etapiRevisionsRoutes from "../etapi/revisions.js";
|
||||
import etapiSpecRoute from "../etapi/spec.js";
|
||||
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
|
||||
import etapiRevisionsRoutes from "../etapi/revisions.js";
|
||||
import auth from "../services/auth.js";
|
||||
import openID from '../services/open_id.js';
|
||||
import { isElectron } from "../services/utils.js";
|
||||
@@ -66,7 +66,7 @@ import treeApiRoute from "./api/tree.js";
|
||||
import { doubleCsrfProtection as csrfMiddleware } from "./csrf_protection.js";
|
||||
import * as indexRoute from "./index.js";
|
||||
import loginRoute from "./login.js";
|
||||
import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js";
|
||||
import { apiResultHandler, apiRoute, asyncApiRoute, asyncRoute, importMiddlewareWithErrorHandling, route, router, uploadMiddlewareWithErrorHandling } from "./route_api.js";
|
||||
// page routes
|
||||
import setupRoute from "./setup.js";
|
||||
|
||||
@@ -195,7 +195,10 @@ function register(app: express.Application) {
|
||||
|
||||
route(GET, "/api/branches/:branchId/export/:type/:format/:version/:taskId", [auth.checkApiAuthOrElectron], exportRoute.exportBranch);
|
||||
asyncRoute(PST, "/api/notes/:parentNoteId/notes-import", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], importRoute.importNotesToBranch, apiResultHandler);
|
||||
route(PST, "/api/notes/:parentNoteId/attachments-import", [auth.checkApiAuthOrElectron, uploadMiddlewareWithErrorHandling, csrfMiddleware], importRoute.importAttachmentsToNote, apiResultHandler);
|
||||
route(PST, "/api/notes/:parentNoteId/attachments-import", [auth.checkApiAuthOrElectron,
|
||||
uploadMiddlewareWithErrorHandling, csrfMiddleware], importRoute.importAttachmentsToNote, apiResultHandler);
|
||||
asyncRoute(PST, "/api/notes/:parentNoteId/preview-import", [auth.checkApiAuthOrElectron, importMiddlewareWithErrorHandling, csrfMiddleware], importRoute.importPreview, apiResultHandler);
|
||||
asyncRoute(PST, "/api/notes/:parentNoteId/execute-import", [auth.checkApiAuthOrElectron, csrfMiddleware], importRoute.importExecute, apiResultHandler);
|
||||
|
||||
apiRoute(GET, "/api/notes/:noteId/attributes", attributesRoute.getEffectiveNoteAttributes);
|
||||
apiRoute(PST, "/api/notes/:noteId/attributes", attributesRoute.addNoteAttribute);
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
export default [
|
||||
import type { DangerousAttributeCategory } from "@triliumnext/commons";
|
||||
|
||||
type AttributeInfo = {
|
||||
type: "label" | "relation";
|
||||
name: string;
|
||||
isDangerous?: false;
|
||||
} | DangerousAttributeInfo;
|
||||
|
||||
export type DangerousAttributeInfo = {
|
||||
type: "label" | "relation";
|
||||
name: string;
|
||||
isDangerous: true;
|
||||
dangerCategory: DangerousAttributeCategory;
|
||||
};
|
||||
|
||||
const builtinAttributes: AttributeInfo[] = [
|
||||
// label names
|
||||
{ type: "label", name: "inbox" },
|
||||
{ type: "label", name: "disableVersioning" },
|
||||
@@ -15,12 +30,12 @@ export default [
|
||||
{ type: "label", name: "cssClass" },
|
||||
{ type: "label", name: "iconClass" },
|
||||
{ type: "label", name: "keyboardShortcut" },
|
||||
{ type: "label", name: "run", isDangerous: true },
|
||||
{ type: "label", name: "run", isDangerous: true, dangerCategory: "codeExecution" },
|
||||
{ type: "label", name: "runOnInstance", isDangerous: false },
|
||||
{ type: "label", name: "runAtHour", isDangerous: false },
|
||||
{ type: "label", name: "customRequestHandler", isDangerous: true },
|
||||
{ type: "label", name: "customResourceProvider", isDangerous: true },
|
||||
{ type: "label", name: "widget", isDangerous: true },
|
||||
{ type: "label", name: "customRequestHandler", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "label", name: "customResourceProvider", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "label", name: "widget", isDangerous: true, dangerCategory: "clientSideScripting" },
|
||||
{ type: "label", name: "noteInfoWidgetDisabled" },
|
||||
{ type: "label", name: "linkMapWidgetDisabled" },
|
||||
{ type: "label", name: "revisionsWidgetDisabled" },
|
||||
@@ -69,7 +84,7 @@ export default [
|
||||
{ type: "label", name: "shareHtmlLocation" },
|
||||
{ type: "label", name: "displayRelations" },
|
||||
{ type: "label", name: "hideRelations" },
|
||||
{ type: "label", name: "titleTemplate", isDangerous: true },
|
||||
{ type: "label", name: "titleTemplate", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "label", name: "template" },
|
||||
{ type: "label", name: "toc" },
|
||||
{ type: "label", name: "color" },
|
||||
@@ -78,9 +93,9 @@ export default [
|
||||
{ type: "label", name: "executeDescription" },
|
||||
{ type: "label", name: "newNotesOnTop" },
|
||||
{ type: "label", name: "clipperInbox" },
|
||||
{ type: "label", name: "webViewSrc", isDangerous: true },
|
||||
{ type: "label", name: "webViewSrc", isDangerous: true, dangerCategory: "webview" },
|
||||
{ type: "label", name: "hideHighlightWidget" },
|
||||
{ type: "label", name: "iconPack", isDangerous: true },
|
||||
{ type: "label", name: "iconPack", isDangerous: true, dangerCategory: "iconPack" },
|
||||
|
||||
{ type: "label", name: "printLandscape" },
|
||||
{ type: "label", name: "printPageSize" },
|
||||
@@ -90,24 +105,26 @@ export default [
|
||||
{ type: "relation", name: "imageLink" },
|
||||
{ type: "relation", name: "relationMapLink" },
|
||||
{ type: "relation", name: "includeMapLink" },
|
||||
{ type: "relation", name: "runOnNoteCreation", isDangerous: true },
|
||||
{ type: "relation", name: "runOnNoteTitleChange", isDangerous: true },
|
||||
{ type: "relation", name: "runOnNoteChange", isDangerous: true },
|
||||
{ type: "relation", name: "runOnNoteContentChange", isDangerous: true },
|
||||
{ type: "relation", name: "runOnNoteDeletion", isDangerous: true },
|
||||
{ type: "relation", name: "runOnBranchCreation", isDangerous: true },
|
||||
{ type: "relation", name: "runOnBranchChange", isDangerous: true },
|
||||
{ type: "relation", name: "runOnBranchDeletion", isDangerous: true },
|
||||
{ type: "relation", name: "runOnChildNoteCreation", isDangerous: true },
|
||||
{ type: "relation", name: "runOnAttributeCreation", isDangerous: true },
|
||||
{ type: "relation", name: "runOnAttributeChange", isDangerous: true },
|
||||
{ type: "relation", name: "runOnNoteCreation", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnNoteTitleChange", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnNoteChange", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnNoteContentChange", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnNoteDeletion", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnBranchCreation", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnBranchChange", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnBranchDeletion", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnChildNoteCreation", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnAttributeCreation", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "runOnAttributeChange", isDangerous: true, dangerCategory: "serverSideScripting" },
|
||||
{ type: "relation", name: "template" },
|
||||
{ type: "relation", name: "inherit" },
|
||||
{ type: "relation", name: "widget", isDangerous: true },
|
||||
{ type: "relation", name: "renderNote", isDangerous: true },
|
||||
{ type: "relation", name: "widget", isDangerous: true, dangerCategory: "clientSideScripting" },
|
||||
{ type: "relation", name: "renderNote", isDangerous: true, dangerCategory: "clientSideScripting" },
|
||||
{ type: "relation", name: "shareCss" },
|
||||
{ type: "relation", name: "shareJs" },
|
||||
{ type: "relation", name: "shareHtml" },
|
||||
{ type: "relation", name: "shareTemplate" },
|
||||
{ type: "relation", name: "shareFavicon" }
|
||||
];
|
||||
|
||||
export default builtinAttributes;
|
||||
|
||||
@@ -295,12 +295,12 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
|
||||
attachmentId: getNewAttachmentId(attachmentMeta.attachmentId),
|
||||
noteId: getNewNoteId(noteMeta.noteId)
|
||||
};
|
||||
}
|
||||
}
|
||||
// don't check for noteMeta since it's not mandatory for notes
|
||||
return {
|
||||
noteId: getNoteId(noteMeta, absUrl)
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
function processTextNoteContent(content: string, noteTitle: string, filePath: string, noteMeta?: NoteMeta) {
|
||||
@@ -313,9 +313,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
|
||||
content = content.replace(/<h1>([^<]*)<\/h1>/gi, (match, text) => {
|
||||
if (noteTitle.trim() === text.trim()) {
|
||||
return ""; // remove whole H1 tag
|
||||
}
|
||||
}
|
||||
return `<h2>${text}</h2>`;
|
||||
|
||||
|
||||
});
|
||||
|
||||
if (taskContext.data?.safeImport) {
|
||||
@@ -348,9 +348,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
|
||||
return `src="api/attachments/${target.attachmentId}/image/${path.basename(url)}"`;
|
||||
} else if (target.noteId) {
|
||||
return `src="api/images/${target.noteId}/${path.basename(url)}"`;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
|
||||
|
||||
});
|
||||
|
||||
content = content.replace(/href="([^"]*)"/g, (match, url) => {
|
||||
@@ -374,9 +374,9 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
|
||||
return `href="#root/${target.noteId}?viewMode=attachments&attachmentId=${target.attachmentId}"`;
|
||||
} else if (target.noteId) {
|
||||
return `href="#root/${target.noteId}"`;
|
||||
}
|
||||
}
|
||||
return match;
|
||||
|
||||
|
||||
});
|
||||
|
||||
if (noteMeta) {
|
||||
@@ -626,7 +626,7 @@ async function importZip(taskContext: TaskContext<"importNotes">, fileBuffer: Bu
|
||||
}
|
||||
|
||||
/** @returns path without leading or trailing slash and backslashes converted to forward ones */
|
||||
function normalizeFilePath(filePath: string): string {
|
||||
export function normalizeFilePath(filePath: string): string {
|
||||
filePath = filePath.replace(/\\/g, "/");
|
||||
|
||||
if (filePath.startsWith("/")) {
|
||||
@@ -658,22 +658,41 @@ export function readContent(zipfile: yauzl.ZipFile, entry: yauzl.Entry): Promise
|
||||
});
|
||||
}
|
||||
|
||||
export function readZipFile(buffer: Buffer, processEntryCallback: (zipfile: yauzl.ZipFile, entry: yauzl.Entry) => Promise<void>) {
|
||||
export function readZipFile(bufferOrPath: Buffer | string, processEntryCallback: (zipfile: yauzl.ZipFile, entry: yauzl.Entry) => Promise<void>) {
|
||||
return new Promise<void>((res, rej) => {
|
||||
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: false }, (err, zipfile) => {
|
||||
if (err) rej(err);
|
||||
if (!zipfile) throw new Error("Unable to read zip file.");
|
||||
const options: yauzl.Options = { lazyEntries: true, validateEntrySizes: false };
|
||||
const callback = (err, zipfile) => {
|
||||
if (err) {
|
||||
rej(err);
|
||||
return;
|
||||
}
|
||||
if (!zipfile) {
|
||||
rej("Unable to read zip file.");
|
||||
return;
|
||||
}
|
||||
|
||||
zipfile.readEntry();
|
||||
try {
|
||||
zipfile.readEntry();
|
||||
} catch (err) {
|
||||
rej(err);
|
||||
return;
|
||||
}
|
||||
zipfile.on("entry", async (entry) => {
|
||||
try {
|
||||
await processEntryCallback(zipfile, entry);
|
||||
} catch (e) {
|
||||
rej(e);
|
||||
return;
|
||||
}
|
||||
});
|
||||
zipfile.on("end", res);
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof bufferOrPath === "string") {
|
||||
yauzl.open(bufferOrPath, options, callback);
|
||||
} else {
|
||||
yauzl.fromBuffer(bufferOrPath, options, callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -692,9 +711,9 @@ function resolveNoteType(type: string | undefined): NoteType {
|
||||
|
||||
if (type && (ALLOWED_NOTE_TYPES as readonly string[]).includes(type)) {
|
||||
return type as NoteType;
|
||||
}
|
||||
}
|
||||
return "text";
|
||||
|
||||
|
||||
}
|
||||
|
||||
export function removeTriliumTags(content: string) {
|
||||
|
||||
73
apps/server/src/services/import/zip_preview.spec.ts
Normal file
73
apps/server/src/services/import/zip_preview.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import NoteMeta, { NoteMetaFile } from "../meta/note_meta";
|
||||
import { previewMeta } from "./zip_preview";
|
||||
|
||||
describe("Preview meta", () => {
|
||||
it("identifies dangerous attributes", () => {
|
||||
const meta = wrapMeta({
|
||||
title: "First unsafe note",
|
||||
attributes: [
|
||||
{
|
||||
type: "label",
|
||||
name: "widget",
|
||||
value: ""
|
||||
}
|
||||
],
|
||||
children: [
|
||||
{
|
||||
title: "Sub unsafe note",
|
||||
attributes: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "runOnBranchCreation",
|
||||
value: ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}, {
|
||||
title: "Second unsafe note",
|
||||
attributes: [
|
||||
{
|
||||
type: "label",
|
||||
name: "customRequestHandler",
|
||||
value: ""
|
||||
},
|
||||
{
|
||||
type: "label",
|
||||
name: "safe",
|
||||
value: ""
|
||||
}
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
attachmentId: "YRAEUXCDKNtn",
|
||||
title: "icon-color.svg",
|
||||
role: "image",
|
||||
mime: "image/svg+xml",
|
||||
position: 10,
|
||||
dataFileName: "Trilium Demo_icon-color.svg"
|
||||
}
|
||||
]
|
||||
});
|
||||
const result = previewMeta(meta);
|
||||
expect(result.numNotes).toBe(3);
|
||||
expect(result.numAttributes).toBe(4);
|
||||
expect(result.numAttachments).toBe(1);
|
||||
expect(result.isDangerous).toBe(true);
|
||||
expect(result.dangerousAttributes).toContain("widget");
|
||||
expect(result.dangerousAttributes).toContain("customRequestHandler");
|
||||
expect(result.dangerousAttributes).toContain("runOnBranchCreation");
|
||||
expect(result.dangerousAttributeCategories).toContain("serverSideScripting");
|
||||
expect(result.dangerousAttributeCategories).toContain("clientSideScripting");
|
||||
});
|
||||
});
|
||||
|
||||
function wrapMeta(...noteMeta: NoteMeta[]): NoteMetaFile {
|
||||
return {
|
||||
formatVersion: 2,
|
||||
appVersion: "0.101.3-test-260207-031832",
|
||||
files: noteMeta
|
||||
};
|
||||
};
|
||||
86
apps/server/src/services/import/zip_preview.ts
Normal file
86
apps/server/src/services/import/zip_preview.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { DangerousAttributeCategory, ImportPreviewResponse } from "@triliumnext/commons";
|
||||
|
||||
import ValidationError from "../../errors/validation_error";
|
||||
import { DangerousAttributeInfo } from "../builtin_attributes";
|
||||
import BUILTIN_ATTRIBUTES from "../builtin_attributes.js";
|
||||
import NoteMeta, { NoteMetaFile } from "../meta/note_meta";
|
||||
import { normalizeFilePath, readContent, readZipFile } from "./zip";
|
||||
|
||||
export default async function previewZipForImport(bufferOrPath: string | Buffer) {
|
||||
let metaFile: NoteMetaFile | null = null;
|
||||
|
||||
await readZipFile(bufferOrPath, async (zipfile, entry) => {
|
||||
const filePath = normalizeFilePath(entry.fileName);
|
||||
|
||||
if (filePath === "!!!meta.json") {
|
||||
const content = await readContent(zipfile, entry);
|
||||
metaFile = JSON.parse(content.toString("utf-8")) as NoteMetaFile;
|
||||
}
|
||||
|
||||
zipfile.readEntry();
|
||||
});
|
||||
|
||||
if (!metaFile) {
|
||||
throw new ValidationError("Missing meta file.");
|
||||
}
|
||||
|
||||
const previewResults = previewMeta(metaFile);
|
||||
return previewResults;
|
||||
}
|
||||
|
||||
interface PreviewContext {
|
||||
dangerousAttributes: Set<string>;
|
||||
dangerousAttributeCategories: Set<DangerousAttributeCategory>;
|
||||
numNotes: number;
|
||||
numAttributes: number;
|
||||
numAttachments: number;
|
||||
}
|
||||
|
||||
export function previewMeta(meta: NoteMetaFile): Omit<ImportPreviewResponse, "id" | "fileName"> {
|
||||
const context: PreviewContext = {
|
||||
dangerousAttributes: new Set<string>(),
|
||||
dangerousAttributeCategories: new Set<DangerousAttributeCategory>(),
|
||||
numNotes: 0,
|
||||
numAttributes: 0,
|
||||
numAttachments: 0
|
||||
};
|
||||
previewMetaInternal(meta.files, context);
|
||||
|
||||
return {
|
||||
isDangerous: context.dangerousAttributes.size > 0,
|
||||
dangerousAttributes: Array.from(context.dangerousAttributes),
|
||||
dangerousAttributeCategories: Array.from(context.dangerousAttributeCategories),
|
||||
numNotes: context.numNotes,
|
||||
numAttributes: context.numAttributes,
|
||||
numAttachments: context.numAttachments
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function previewMetaInternal(metaFiles: NoteMeta[], context: PreviewContext) {
|
||||
for (const metaFile of metaFiles) {
|
||||
context.numNotes++;
|
||||
|
||||
// Look through the attributes for dangerous ones.
|
||||
if (metaFile.attributes) {
|
||||
for (const { name, type } of metaFile.attributes) {
|
||||
context.numAttributes++;
|
||||
const dangerousAttribute = BUILTIN_ATTRIBUTES.find((attr) =>
|
||||
attr.type === type &&
|
||||
attr.name.toLowerCase() === name.trim().toLowerCase() && attr.isDangerous) as DangerousAttributeInfo | undefined;
|
||||
if (!dangerousAttribute) continue;
|
||||
|
||||
context.dangerousAttributes.add(name);
|
||||
context.dangerousAttributeCategories.add(dangerousAttribute.dangerCategory);
|
||||
}
|
||||
}
|
||||
|
||||
if (metaFile.attachments) {
|
||||
context.numAttachments += metaFile.attachments.length;
|
||||
}
|
||||
|
||||
if (metaFile.children) {
|
||||
previewMetaInternal(metaFile.children, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,3 +298,16 @@ export interface IconRegistry {
|
||||
}[]
|
||||
}[];
|
||||
}
|
||||
|
||||
export type DangerousAttributeCategory = "codeExecution" | "serverSideScripting" | "clientSideScripting" | "iconPack" | "webview";
|
||||
|
||||
export interface ImportPreviewResponse {
|
||||
id: string;
|
||||
fileName: string;
|
||||
numNotes: number;
|
||||
numAttributes: number;
|
||||
numAttachments: number;
|
||||
isDangerous: boolean;
|
||||
dangerousAttributeCategories: string[];
|
||||
dangerousAttributes: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user