Compare commits

..

28 Commits

Author SHA1 Message Date
Elian Doran
1b9e58a8b0 feat(client/import_preview): add possibilty to change parent note 2026-02-07 23:58:06 +02:00
Elian Doran
d179616702 feat(client/import_preview): carry on with the import 2026-02-07 23:47:29 +02:00
Elian Doran
f53c64f76d feat(client/import_preview): count attachments 2026-02-07 23:30:34 +02:00
Elian Doran
537b468714 fix(client/import_preview): off by one error when displaying status 2026-02-07 23:22:47 +02:00
Elian Doran
6dcef0b1e5 feat(client/import_preview): display number of attributes 2026-02-07 23:20:13 +02:00
Elian Doran
622fe33264 feat(client/import_preview): add an accent color for the import card 2026-02-07 23:13:32 +02:00
Elian Doran
8ee81b2607 chore(client/import_preview): display file name instead of temporary ID 2026-02-07 23:02:55 +02:00
Elian Doran
620f2e93a4 feat(client/import_preview): order badges by severity 2026-02-07 22:23:30 +02:00
Elian Doran
47cb6531ff chore(client/import_preview): use muted text for note count 2026-02-07 22:17:45 +02:00
Elian Doran
5ed13ff68c feat(client/import_preview): throttle import button for a few seconds 2026-02-07 22:15:28 +02:00
Elian Doran
0f62f864c8 feat(client/import_preview): adapt intro based on safety 2026-02-07 22:05:55 +02:00
Elian Doran
47b53335af feat(client/import_preview): display badges for 2026-02-07 21:59:02 +02:00
Elian Doran
b875924c8e feat(client/import_preview): add an intro text 2026-02-07 21:22:42 +02:00
Elian Doran
7c002e2871 feat(client/import_preview): add import options 2026-02-07 21:19:55 +02:00
Elian Doran
666d0e7c08 feat(client): add some buttons in the footer 2026-02-07 21:13:01 +02:00
Elian Doran
737ea34a24 feat(client): render import preview as card 2026-02-07 21:10:14 +02:00
Elian Doran
49bff10ac5 feat(client): add modal for import preview 2026-02-07 20:55:40 +02:00
Elian Doran
1b4c01015b feat(client/tree): basic integration with import preview for .trilium 2026-02-07 20:41:19 +02:00
Elian Doran
995cdf330e feat(server/import_preview): add a way to continue the import 2026-02-07 20:25:21 +02:00
Elian Doran
cbea727f08 chore(server/import_preview): check record in execution step 2026-02-07 20:06:14 +02:00
Elian Doran
152ec404bf fix(server/import_preview): errors after changing to disk store 2026-02-07 19:45:05 +02:00
Elian Doran
5f6b324c00 chore(server/import_preview): store import after preview on disk 2026-02-07 19:13:39 +02:00
Elian Doran
395aa410f2 chore(server/import_preview): count number of notes 2026-02-07 18:52:43 +02:00
Elian Doran
1f81e864bc chore(server/import_preview): integrate route 2026-02-07 18:51:11 +02:00
Elian Doran
8849d1f4f2 chore(server/import_preview): detect unsafe attributes 2026-02-07 18:44:52 +02:00
Elian Doran
ada530cfef chore(server/import): assign danger categories 2026-02-07 18:18:07 +02:00
Elian Doran
1057d55e36 chore(server/import): parse meta 2026-02-07 18:06:15 +02:00
Elian Doran
cafe4254f9 chore(server/import): create route for preview 2026-02-07 17:53:14 +02:00
19 changed files with 859 additions and 156 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
}
}

View File

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

View File

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

View 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;
}
}
}

View 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];
});
}

View File

@@ -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) => {

View 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>
);
}

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View 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
};
};

View 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);
}
}
}

View File

@@ -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[];
}