You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-03 23:50:33 +02:00
Merge branch 'release-3.1' into dev
This commit is contained in:
@ -352,4 +352,12 @@ describe('MdToHtml', () => {
|
|||||||
expect(html).toContain('Inline</span>');
|
expect(html).toContain('Inline</span>');
|
||||||
expect(html).toContain('Block</span>');
|
expect(html).toContain('Block</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should sanitize KaTeX errors', async () => {
|
||||||
|
const markdown = '$\\a<svg>$';
|
||||||
|
const renderResult = await newTestMdToHtml().render(markdown, null, { bodyOnly: true });
|
||||||
|
|
||||||
|
// Should not contain the HTML in unsanitized form
|
||||||
|
expect(renderResult.html).not.toContain('<svg>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -5,7 +5,7 @@ import type ShimType from '@joplin/lib/shim';
|
|||||||
const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
const shim: typeof ShimType = require('@joplin/lib/shim').default;
|
||||||
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||||
|
|
||||||
import { BrowserWindow, Tray, screen } from 'electron';
|
import { BrowserWindow, Tray, WebContents, screen } from 'electron';
|
||||||
import bridge from './bridge';
|
import bridge from './bridge';
|
||||||
const url = require('url');
|
const url = require('url');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@ -232,14 +232,35 @@ export default class ElectronAppWrapper {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
|
const addWindowEventHandlers = (webContents: WebContents) => {
|
||||||
this.win_.webContents.on('will-frame-navigate', event => {
|
// will-frame-navigate is fired by clicking on a link within the BrowserWindow.
|
||||||
// If the link changes the URL of the browser window,
|
webContents.on('will-frame-navigate', event => {
|
||||||
if (event.isMainFrame) {
|
// If the link changes the URL of the browser window,
|
||||||
event.preventDefault();
|
if (event.isMainFrame) {
|
||||||
void bridge().openExternal(event.url);
|
event.preventDefault();
|
||||||
}
|
void bridge().openExternal(event.url);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override calls to window.open and links with target="_blank": Open most in a browser instead
|
||||||
|
// of Electron:
|
||||||
|
webContents.setWindowOpenHandler((event) => {
|
||||||
|
if (event.url === 'about:blank') {
|
||||||
|
// Script-controlled pages: Used for opening notes in new windows
|
||||||
|
return {
|
||||||
|
action: 'allow',
|
||||||
|
};
|
||||||
|
} else if (event.url.match(/^https?:\/\//)) {
|
||||||
|
void bridge().openExternal(event.url);
|
||||||
|
}
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
webContents.on('did-create-window', (event) => {
|
||||||
|
addWindowEventHandlers(event.webContents);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
addWindowEventHandlers(this.win_.webContents);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
this.win_.on('close', (event: any) => {
|
this.win_.on('close', (event: any) => {
|
||||||
|
@ -137,6 +137,12 @@ class Application extends BaseApplication {
|
|||||||
this.updateLanguage();
|
this.updateLanguage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'renderer.fileUrls' || action.type === 'SETTING_UPDATE_ALL') {
|
||||||
|
bridge().electronApp().getCustomProtocolHandler().setMediaAccessEnabled(
|
||||||
|
Setting.value('renderer.fileUrls'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'showTrayIcon' || action.type === 'SETTING_UPDATE_ALL') {
|
if (action.type === 'SETTING_UPDATE_ONE' && action.key === 'showTrayIcon' || action.type === 'SETTING_UPDATE_ALL') {
|
||||||
this.updateTray();
|
this.updateTray();
|
||||||
}
|
}
|
||||||
|
@ -234,7 +234,7 @@ export default function(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<CellFooter>
|
<CellFooter>
|
||||||
<NeedUpgradeMessage>
|
<NeedUpgradeMessage>
|
||||||
{PluginService.instance().describeIncompatibility(props.manifest)}
|
{PluginService.instance().describeIncompatibility(item.manifest)}
|
||||||
</NeedUpgradeMessage>
|
</NeedUpgradeMessage>
|
||||||
</CellFooter>
|
</CellFooter>
|
||||||
);
|
);
|
||||||
|
@ -378,6 +378,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
|||||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||||
themeData: {
|
themeData: {
|
||||||
...styles.globalTheme,
|
...styles.globalTheme,
|
||||||
|
marginLeft: 0,
|
||||||
|
marginRight: 0,
|
||||||
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
|
monospaceFont: Setting.value('style.editor.monospaceFontFamily'),
|
||||||
},
|
},
|
||||||
automatchBraces: Setting.value('editor.autoMatchingBraces'),
|
automatchBraces: Setting.value('editor.autoMatchingBraces'),
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
|
|
||||||
import { RefObject, useMemo } from 'react';
|
import { RefObject, useMemo } from 'react';
|
||||||
import { CommandValue } from '../../../utils/types';
|
import { CommandValue, DropCommandValue } from '../../../utils/types';
|
||||||
import { commandAttachFileToBody } from '../../../utils/resourceHandling';
|
import { commandAttachFileToBody } from '../../../utils/resourceHandling';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import dialogs from '../../../../dialogs';
|
import dialogs from '../../../../dialogs';
|
||||||
import { EditorCommandType } from '@joplin/editor/types';
|
import { EditorCommandType, UserEventSource } from '@joplin/editor/types';
|
||||||
import Logger from '@joplin/utils/Logger';
|
import Logger from '@joplin/utils/Logger';
|
||||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||||
import { MarkupLanguage } from '@joplin/renderer';
|
import { MarkupLanguage } from '@joplin/renderer';
|
||||||
@ -38,13 +38,22 @@ const useEditorCommands = (props: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
dropItems: async (cmd: DropCommandValue) => {
|
||||||
dropItems: async (cmd: any) => {
|
let pos = cmd.pos && editorRef.current.editor.posAtCoords({ x: cmd.pos.clientX, y: cmd.pos.clientY });
|
||||||
if (cmd.type === 'notes') {
|
if (cmd.type === 'notes') {
|
||||||
editorRef.current.insertText(cmd.markdownTags.join('\n'));
|
const text = cmd.markdownTags.join('\n');
|
||||||
|
if ((pos ?? null) !== null) {
|
||||||
|
editorRef.current.select(pos, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
editorRef.current.insertText(text, UserEventSource.Drop);
|
||||||
} else if (cmd.type === 'files') {
|
} else if (cmd.type === 'files') {
|
||||||
const pos = props.selectionRange.from;
|
pos ??= props.selectionRange.from;
|
||||||
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, { createFileURL: !!cmd.createFileURL, position: pos, markupLanguage: props.contentMarkupLanguage });
|
const newBody = await commandAttachFileToBody(props.editorContent, cmd.paths, {
|
||||||
|
createFileURL: !!cmd.createFileURL,
|
||||||
|
position: pos,
|
||||||
|
markupLanguage: props.contentMarkupLanguage,
|
||||||
|
});
|
||||||
editorRef.current.updateBody(newBody);
|
editorRef.current.updateBody(newBody);
|
||||||
} else {
|
} else {
|
||||||
logger.warn('CodeMirror: unsupported drop item: ', cmd);
|
logger.warn('CodeMirror: unsupported drop item: ', cmd);
|
||||||
|
@ -252,3 +252,19 @@ export interface CommandValue {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
value?: any; // For TinyMCE only
|
value?: any; // For TinyMCE only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DropCommandBase = {
|
||||||
|
pos: {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}|undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DropCommandValue = ({
|
||||||
|
type: 'notes';
|
||||||
|
markdownTags: string[];
|
||||||
|
}|{
|
||||||
|
type: 'files';
|
||||||
|
paths: string[];
|
||||||
|
createFileURL: boolean;
|
||||||
|
}) & DropCommandBase;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import Note from '@joplin/lib/models/Note';
|
import Note from '@joplin/lib/models/Note';
|
||||||
import { DragEvent as ReactDragEvent } from 'react';
|
import { DragEvent as ReactDragEvent } from 'react';
|
||||||
|
import { DropCommandValue } from './types';
|
||||||
|
|
||||||
interface HookDependencies {
|
interface HookDependencies {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
@ -19,6 +20,11 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
|
|||||||
const dt = event.dataTransfer;
|
const dt = event.dataTransfer;
|
||||||
const createFileURL = event.altKey;
|
const createFileURL = event.altKey;
|
||||||
|
|
||||||
|
const eventPosition = {
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
};
|
||||||
|
|
||||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||||
|
|
||||||
@ -29,12 +35,15 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
|
|||||||
noteMarkdownTags.push(Note.markdownTag(note));
|
noteMarkdownTags.push(Note.markdownTag(note));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const props: DropCommandValue = {
|
||||||
|
type: 'notes',
|
||||||
|
pos: eventPosition,
|
||||||
|
markdownTags: noteMarkdownTags,
|
||||||
|
};
|
||||||
|
|
||||||
editorRef.current.execCommand({
|
editorRef.current.execCommand({
|
||||||
name: 'dropItems',
|
name: 'dropItems',
|
||||||
value: {
|
value: props,
|
||||||
type: 'notes',
|
|
||||||
markdownTags: noteMarkdownTags,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
void dropNotes();
|
void dropNotes();
|
||||||
@ -51,13 +60,16 @@ export default function useDropHandler(dependencies: HookDependencies): DropHand
|
|||||||
paths.push(file.path);
|
paths.push(file.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const props: DropCommandValue = {
|
||||||
|
type: 'files',
|
||||||
|
pos: eventPosition,
|
||||||
|
paths: paths,
|
||||||
|
createFileURL: createFileURL,
|
||||||
|
};
|
||||||
|
|
||||||
editorRef.current.execCommand({
|
editorRef.current.execCommand({
|
||||||
name: 'dropItems',
|
name: 'dropItems',
|
||||||
value: {
|
value: props,
|
||||||
type: 'files',
|
|
||||||
paths: paths,
|
|
||||||
createFileURL: createFileURL,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
|
|||||||
private webviewRef_: React.RefObject<HTMLIFrameElement>;
|
private webviewRef_: React.RefObject<HTMLIFrameElement>;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
private webviewListeners_: any = null;
|
private webviewListeners_: any = null;
|
||||||
|
|
||||||
private removePluginAssetsCallback_: RemovePluginAssetsCallback|null = null;
|
private removePluginAssetsCallback_: RemovePluginAssetsCallback|null = null;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
@ -110,7 +111,7 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
|
|||||||
window.addEventListener('message', this.webview_message);
|
window.addEventListener('message', this.webview_message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroyWebview() {
|
private destroyWebview() {
|
||||||
const wv = this.webviewRef_.current;
|
const wv = this.webviewRef_.current;
|
||||||
if (!wv || !this.initialized_) return;
|
if (!wv || !this.initialized_) return;
|
||||||
|
|
||||||
@ -194,14 +195,13 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
public setHtml(html: string, options: SetHtmlOptions) {
|
public setHtml(html: string, options: SetHtmlOptions) {
|
||||||
|
const protocolHandler = bridge().electronApp().getCustomProtocolHandler();
|
||||||
|
|
||||||
// Grant & remove asset access.
|
// Grant & remove asset access.
|
||||||
if (options.pluginAssets) {
|
if (options.pluginAssets) {
|
||||||
this.removePluginAssetsCallback_?.();
|
this.removePluginAssetsCallback_?.();
|
||||||
|
|
||||||
const protocolHandler = bridge().electronApp().getCustomProtocolHandler();
|
|
||||||
|
|
||||||
const pluginAssetPaths: string[] = options.pluginAssets.map((asset) => asset.path);
|
const pluginAssetPaths: string[] = options.pluginAssets.map((asset) => asset.path);
|
||||||
const assetAccesses = pluginAssetPaths.map(
|
const assetAccesses = pluginAssetPaths.map(
|
||||||
path => protocolHandler.allowReadAccessToFile(path),
|
path => protocolHandler.allowReadAccessToFile(path),
|
||||||
@ -216,7 +216,10 @@ export default class NoteTextViewerComponent extends React.Component<Props, any>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.send('setHtml', html, options);
|
this.send('setHtml', html, {
|
||||||
|
...options,
|
||||||
|
mediaAccessKey: protocolHandler.getMediaAccessKey(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
@ -377,6 +377,20 @@
|
|||||||
contentElement.scrollTop = scrollTop;
|
contentElement.scrollTop = scrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rewriteFileUrls = (accessKey) => {
|
||||||
|
if (!accessKey) return;
|
||||||
|
|
||||||
|
// To allow accessing local files from the viewer's non-file URL, file:// URLs are re-written
|
||||||
|
// to joplin-content:// URLs:
|
||||||
|
const mediaElements = document.querySelectorAll('video[src], audio[src], source[src], img[src]');
|
||||||
|
for (const element of mediaElements) {
|
||||||
|
if (element.src?.startsWith('file:')) {
|
||||||
|
const newUrl = element.src.replace(/^file:\/\//, 'joplin-content://file-media/');
|
||||||
|
element.src = `${newUrl}?access-key=${accessKey}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
ipc.setHtml = (event) => {
|
ipc.setHtml = (event) => {
|
||||||
const html = event.html;
|
const html = event.html;
|
||||||
|
|
||||||
@ -388,6 +402,10 @@
|
|||||||
|
|
||||||
contentElement.innerHTML = html;
|
contentElement.innerHTML = html;
|
||||||
|
|
||||||
|
if (html.includes('file://')) {
|
||||||
|
rewriteFileUrls(event.options.mediaAccessKey);
|
||||||
|
}
|
||||||
|
|
||||||
scrollmap.create(event.options.markupLineCount);
|
scrollmap.create(event.options.markupLineCount);
|
||||||
if (typeof event.options.percent !== 'number') {
|
if (typeof event.options.percent !== 'number') {
|
||||||
restorePercentScroll(); // First, a quick treatment is applied.
|
restorePercentScroll(); // First, a quick treatment is applied.
|
||||||
|
@ -136,50 +136,55 @@ test.describe('main', () => {
|
|||||||
expect(fullSize[0] / resizedSize[0]).toBeCloseTo(fullSize[1] / resizedSize[1]);
|
expect(fullSize[0] / resizedSize[0]).toBeCloseTo(fullSize[1] / resizedSize[1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking on an external link should try to launch a browser', async ({ electronApp, mainWindow }) => {
|
for (const target of ['', '_blank']) {
|
||||||
const mainScreen = new MainScreen(mainWindow);
|
test(`clicking on an external link with target=${JSON.stringify(target)} should try to launch a browser`, async ({ electronApp, mainWindow }) => {
|
||||||
await mainScreen.waitFor();
|
const mainScreen = new MainScreen(mainWindow);
|
||||||
|
await mainScreen.waitFor();
|
||||||
|
|
||||||
// Mock openExternal
|
// Mock openExternal
|
||||||
const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => {
|
const nextExternalUrlPromise = electronApp.evaluate(({ shell }) => {
|
||||||
return new Promise<string>(resolve => {
|
return new Promise<string>(resolve => {
|
||||||
const openExternal = async (url: string) => {
|
const openExternal = async (url: string) => {
|
||||||
resolve(url);
|
resolve(url);
|
||||||
};
|
};
|
||||||
shell.openExternal = openExternal;
|
shell.openExternal = openExternal;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a test link
|
||||||
|
const testLinkTitle = 'This is a test link!';
|
||||||
|
const linkHref = 'https://joplinapp.org/';
|
||||||
|
|
||||||
|
await mainWindow.evaluate(({ testLinkTitle, linkHref, target }) => {
|
||||||
|
const testLink = document.createElement('a');
|
||||||
|
testLink.textContent = testLinkTitle;
|
||||||
|
testLink.onclick = () => {
|
||||||
|
// We need to navigate by setting location.href -- clicking on a link
|
||||||
|
// directly within the main window (i.e. not in a PDF viewer) doesn't
|
||||||
|
// navigate.
|
||||||
|
location.href = linkHref;
|
||||||
|
};
|
||||||
|
testLink.href = '#';
|
||||||
|
|
||||||
|
// Display on top of everything
|
||||||
|
testLink.style.zIndex = '99999';
|
||||||
|
testLink.style.position = 'fixed';
|
||||||
|
testLink.style.top = '0';
|
||||||
|
testLink.style.left = '0';
|
||||||
|
if (target) {
|
||||||
|
testLink.target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(testLink);
|
||||||
|
}, { testLinkTitle, linkHref, target });
|
||||||
|
|
||||||
|
const testLink = mainWindow.getByText(testLinkTitle);
|
||||||
|
await expect(testLink).toBeVisible();
|
||||||
|
await testLink.click({ noWaitAfter: true });
|
||||||
|
|
||||||
|
expect(await nextExternalUrlPromise).toBe(linkHref);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Create a test link
|
|
||||||
const testLinkTitle = 'This is a test link!';
|
|
||||||
const linkHref = 'https://joplinapp.org/';
|
|
||||||
|
|
||||||
await mainWindow.evaluate(({ testLinkTitle, linkHref }) => {
|
|
||||||
const testLink = document.createElement('a');
|
|
||||||
testLink.textContent = testLinkTitle;
|
|
||||||
testLink.onclick = () => {
|
|
||||||
// We need to navigate by setting location.href -- clicking on a link
|
|
||||||
// directly within the main window (i.e. not in a PDF viewer) doesn't
|
|
||||||
// navigate.
|
|
||||||
location.href = linkHref;
|
|
||||||
};
|
|
||||||
testLink.href = '#';
|
|
||||||
|
|
||||||
// Display on top of everything
|
|
||||||
testLink.style.zIndex = '99999';
|
|
||||||
testLink.style.position = 'fixed';
|
|
||||||
testLink.style.top = '0';
|
|
||||||
testLink.style.left = '0';
|
|
||||||
|
|
||||||
document.body.appendChild(testLink);
|
|
||||||
}, { testLinkTitle, linkHref });
|
|
||||||
|
|
||||||
const testLink = mainWindow.getByText(testLinkTitle);
|
|
||||||
await expect(testLink).toBeVisible();
|
|
||||||
await testLink.click({ noWaitAfter: true });
|
|
||||||
|
|
||||||
expect(await nextExternalUrlPromise).toBe(linkHref);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should start in safe mode if profile-dir/force-safe-mode-on-next-start exists', async ({ profileDirectory }) => {
|
test('should start in safe mode if profile-dir/force-safe-mode-on-next-start exists', async ({ profileDirectory }) => {
|
||||||
await writeFile(join(profileDirectory, 'force-safe-mode-on-next-start'), 'true', 'utf8');
|
await writeFile(join(profileDirectory, 'force-safe-mode-on-next-start'), 'true', 'utf8');
|
||||||
|
@ -42,21 +42,29 @@ const setUpProtocolHandler = () => {
|
|||||||
return { protocolHandler, onRequestListener };
|
return { protocolHandler, onRequestListener };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ExpectBlockedOptions {
|
||||||
|
host?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Although none of the paths in this test suite point to real files, file paths must be in
|
// Although none of the paths in this test suite point to real files, file paths must be in
|
||||||
// a certain format on Windows to avoid invalid path exceptions.
|
// a certain format on Windows to avoid invalid path exceptions.
|
||||||
const toPlatformPath = (path: string) => process.platform === 'win32' ? `C:/${path}` : path;
|
const toPlatformPath = (path: string) => process.platform === 'win32' ? `C:/${path}` : path;
|
||||||
|
|
||||||
const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string) => {
|
const toAccessUrl = (path: string, { host = 'note-viewer' }: ExpectBlockedOptions = {}) => {
|
||||||
const url = `joplin-content://note-viewer/${toPlatformPath(filePath)}`;
|
return `joplin-content://${host}/${toPlatformPath(path)}`;
|
||||||
|
|
||||||
await expect(
|
|
||||||
async () => await onRequestListener(new Request(url)),
|
|
||||||
).rejects.toThrowError('Read access not granted for URL');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string) => {
|
const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
|
||||||
|
const url = toAccessUrl(filePath, options);
|
||||||
|
await expect(
|
||||||
|
async () => await onRequestListener(new Request(url)),
|
||||||
|
).rejects.toThrow(/Read access not granted for URL|Invalid or missing media access key|Media access denied/);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
|
||||||
|
const url = toAccessUrl(filePath, options);
|
||||||
const handleRequestResult = await onRequestListener(
|
const handleRequestResult = await onRequestListener(
|
||||||
new Request(`joplin-content://note-viewer/${toPlatformPath(filePath)}`),
|
new Request(url),
|
||||||
);
|
);
|
||||||
expect(handleRequestResult.body).toBeTruthy();
|
expect(handleRequestResult.body).toBeTruthy();
|
||||||
};
|
};
|
||||||
@ -107,6 +115,34 @@ describe('handleCustomProtocols', () => {
|
|||||||
await expectPathToBeBlocked(onRequestListener, '/test/path/a.txt');
|
await expectPathToBeBlocked(onRequestListener, '/test/path/a.txt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should only allow access to file-media/ URLs when given the correct access key', async () => {
|
||||||
|
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
|
||||||
|
const expectBlocked = (path: string) => {
|
||||||
|
return expectPathToBeBlocked(onRequestListener, path, { host: 'file-media' });
|
||||||
|
};
|
||||||
|
const expectUnblocked = (path: string) => {
|
||||||
|
return expectPathToBeUnblocked(onRequestListener, path, { host: 'file-media' });
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMock.mockImplementation(async (_url: string) => {
|
||||||
|
return new Response('', { headers: { 'Content-Type': 'image/jpeg' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const testPath = join(supportDir, 'photo.jpg');
|
||||||
|
await expectBlocked(testPath);
|
||||||
|
await expectBlocked(`${testPath}?access-key=wrongKey`);
|
||||||
|
await expectBlocked(`${testPath}?access-key=false`);
|
||||||
|
|
||||||
|
protocolHandler.setMediaAccessEnabled(true);
|
||||||
|
const key = protocolHandler.getMediaAccessKey();
|
||||||
|
await expectUnblocked(`${testPath}?access-key=${key}`);
|
||||||
|
await expectBlocked(`${testPath}?access-key=null`);
|
||||||
|
protocolHandler.setMediaAccessEnabled(false);
|
||||||
|
|
||||||
|
await expectBlocked(`${testPath}?access-key=${key}`);
|
||||||
|
});
|
||||||
|
|
||||||
test('should allow requesting part of a file', async () => {
|
test('should allow requesting part of a file', async () => {
|
||||||
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
|
const { protocolHandler, onRequestListener } = setUpProtocolHandler();
|
||||||
|
|
||||||
|
@ -7,10 +7,20 @@ import { LoggerWrapper } from '@joplin/utils/Logger';
|
|||||||
import * as fs from 'fs-extra';
|
import * as fs from 'fs-extra';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import { fromFilename } from '@joplin/lib/mime-utils';
|
import { fromFilename } from '@joplin/lib/mime-utils';
|
||||||
|
import { createSecureRandom } from '@joplin/lib/uuid';
|
||||||
|
|
||||||
|
export interface AccessController {
|
||||||
|
remove(): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CustomProtocolHandler {
|
export interface CustomProtocolHandler {
|
||||||
|
// note-viewer/ URLs
|
||||||
allowReadAccessToDirectory(path: string): void;
|
allowReadAccessToDirectory(path: string): void;
|
||||||
allowReadAccessToFile(path: string): { remove(): void };
|
allowReadAccessToFile(path: string): AccessController;
|
||||||
|
|
||||||
|
// file-media/ URLs
|
||||||
|
setMediaAccessEnabled(enabled: boolean): void;
|
||||||
|
getMediaAccessKey(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -130,8 +140,11 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||||||
debug: () => {},
|
debug: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Allow-listed files/directories for joplin-content://note-viewer/
|
||||||
const readableDirectories: string[] = [];
|
const readableDirectories: string[] = [];
|
||||||
const readableFiles = new Map<string, number>();
|
const readableFiles = new Map<string, number>();
|
||||||
|
// Access for joplin-content://file-media/
|
||||||
|
let mediaAccessKey: string|false = false;
|
||||||
|
|
||||||
// See also the protocol.handle example: https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler
|
// See also the protocol.handle example: https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler
|
||||||
protocol.handle(contentProtocolName, async request => {
|
protocol.handle(contentProtocolName, async request => {
|
||||||
@ -147,10 +160,9 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||||||
|
|
||||||
pathname = resolve(appBundleDirectory, pathname);
|
pathname = resolve(appBundleDirectory, pathname);
|
||||||
|
|
||||||
const allowedHosts = ['note-viewer'];
|
|
||||||
|
|
||||||
let canRead = false;
|
let canRead = false;
|
||||||
if (allowedHosts.includes(host)) {
|
let mediaOnly = true;
|
||||||
|
if (host === 'note-viewer') {
|
||||||
if (readableFiles.has(pathname)) {
|
if (readableFiles.has(pathname)) {
|
||||||
canRead = true;
|
canRead = true;
|
||||||
} else {
|
} else {
|
||||||
@ -161,6 +173,20 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mediaOnly = false;
|
||||||
|
} else if (host === 'file-media') {
|
||||||
|
if (!mediaAccessKey) {
|
||||||
|
throw new Error('Media access denied. This must be enabled with .setMediaAccessEnabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
canRead = true;
|
||||||
|
mediaOnly = true;
|
||||||
|
|
||||||
|
const accessKey = url.searchParams.get('access-key');
|
||||||
|
if (accessKey !== mediaAccessKey) {
|
||||||
|
throw new Error(`Invalid or missing media access key (was ${accessKey}). An allow-listed ?access-key= parameter must be provided.`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Invalid URL ${request.url}`);
|
throw new Error(`Invalid URL ${request.url}`);
|
||||||
}
|
}
|
||||||
@ -173,12 +199,26 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||||||
logger.debug('protocol handler: Fetch file URL', asFileUrl);
|
logger.debug('protocol handler: Fetch file URL', asFileUrl);
|
||||||
|
|
||||||
const rangeHeader = request.headers.get('Range');
|
const rangeHeader = request.headers.get('Range');
|
||||||
|
let response;
|
||||||
if (!rangeHeader) {
|
if (!rangeHeader) {
|
||||||
const response = await net.fetch(asFileUrl);
|
response = await net.fetch(asFileUrl);
|
||||||
return response;
|
|
||||||
} else {
|
} else {
|
||||||
return handleRangeRequest(request, pathname);
|
response = await handleRangeRequest(request, pathname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mediaOnly) {
|
||||||
|
// Tells the browser to avoid MIME confusion attacks. See
|
||||||
|
// https://blog.mozilla.org/security/2016/08/26/mitigating-mime-confusion-attacks-in-firefox/
|
||||||
|
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
|
// This is an extra check to prevent loading text/html and arbitrary non-media content from the URL.
|
||||||
|
const contentType = response.headers.get('Content-Type');
|
||||||
|
if (!contentType || !contentType.match(/^(image|video|audio)\//)) {
|
||||||
|
throw new Error(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
const appBundleDirectory = dirname(dirname(__dirname));
|
const appBundleDirectory = dirname(dirname(__dirname));
|
||||||
@ -210,6 +250,18 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
setMediaAccessEnabled: (enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
mediaAccessKey ||= createSecureRandom();
|
||||||
|
} else {
|
||||||
|
mediaAccessKey = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Allows access to all local media files, provided a matching ?access-key=<key> is added
|
||||||
|
// to the request URL.
|
||||||
|
getMediaAccessKey: () => {
|
||||||
|
return mediaAccessKey || null;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ android {
|
|||||||
applicationId "net.cozic.joplin"
|
applicationId "net.cozic.joplin"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 2097753
|
versionCode 2097755
|
||||||
versionName "3.2.0"
|
versionName "3.2.0"
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||||
|
@ -167,6 +167,8 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (source === props.notesSource) return;
|
if (source === props.notesSource) return;
|
||||||
|
// For now, search refresh is handled by the search screen.
|
||||||
|
if (props.notesParentType === 'Search') return;
|
||||||
|
|
||||||
let notes: NoteEntity[] = [];
|
let notes: NoteEntity[] = [];
|
||||||
if (props.notesParentType === 'Folder') {
|
if (props.notesParentType === 'Folder') {
|
||||||
|
@ -551,7 +551,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 126;
|
CURRENT_PROJECT_VERSION = 127;
|
||||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Joplin/Info.plist;
|
INFOPLIST_FILE = Joplin/Info.plist;
|
||||||
@ -583,7 +583,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 126;
|
CURRENT_PROJECT_VERSION = 127;
|
||||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||||
INFOPLIST_FILE = Joplin/Info.plist;
|
INFOPLIST_FILE = Joplin/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.4;
|
||||||
@ -774,7 +774,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 126;
|
CURRENT_PROJECT_VERSION = 127;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
@ -813,7 +813,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 126;
|
CURRENT_PROJECT_VERSION = 127;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
DEVELOPMENT_TEAM = A9BXAFS6CT;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
@ -308,6 +308,10 @@ const appReducer = (state = appDefaultState, action: any) => {
|
|||||||
|
|
||||||
newState.selectedNoteHash = '';
|
newState.selectedNoteHash = '';
|
||||||
|
|
||||||
|
if (action.routeName === 'Search') {
|
||||||
|
newState.notesParentType = 'Search';
|
||||||
|
}
|
||||||
|
|
||||||
if ('noteId' in action) {
|
if ('noteId' in action) {
|
||||||
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
|
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
|
||||||
}
|
}
|
||||||
@ -344,6 +348,8 @@ const appReducer = (state = appDefaultState, action: any) => {
|
|||||||
|
|
||||||
newState.route = action;
|
newState.route = action;
|
||||||
newState.historyCanGoBack = !!navHistory.length;
|
newState.historyCanGoBack = !!navHistory.length;
|
||||||
|
|
||||||
|
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import { classHighlighter } from '@lezer/highlight';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection,
|
EditorView, drawSelection, highlightSpecialChars, ViewUpdate, Command, rectangularSelection,
|
||||||
|
dropCursor,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { history, undoDepth, redoDepth, standardKeymap, insertTab } from '@codemirror/commands';
|
import { history, undoDepth, redoDepth, standardKeymap, insertTab } from '@codemirror/commands';
|
||||||
|
|
||||||
@ -270,6 +271,7 @@ const createEditor = (
|
|||||||
|
|
||||||
// Apply styles to entire lines (block-display decorations)
|
// Apply styles to entire lines (block-display decorations)
|
||||||
decoratorExtension,
|
decoratorExtension,
|
||||||
|
dropCursor(),
|
||||||
|
|
||||||
biDirectionalTextExtension,
|
biDirectionalTextExtension,
|
||||||
overwriteModeExtension,
|
overwriteModeExtension,
|
||||||
|
@ -84,6 +84,18 @@ const tableDelimiterDecoration = Decoration.line({
|
|||||||
attributes: { class: 'cm-tableDelimiter' },
|
attributes: { class: 'cm-tableDelimiter' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const orderedListDecoration = Decoration.line({
|
||||||
|
attributes: { class: 'cm-orderedList' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const unorderedListDecoration = Decoration.line({
|
||||||
|
attributes: { class: 'cm-unorderedList' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const listItemDecoration = Decoration.line({
|
||||||
|
attributes: { class: 'cm-listItem' },
|
||||||
|
});
|
||||||
|
|
||||||
const horizontalRuleDecoration = Decoration.mark({
|
const horizontalRuleDecoration = Decoration.mark({
|
||||||
attributes: { class: 'cm-hr' },
|
attributes: { class: 'cm-hr' },
|
||||||
});
|
});
|
||||||
@ -97,6 +109,10 @@ const nodeNameToLineDecoration: Record<string, Decoration> = {
|
|||||||
'CodeBlock': codeBlockDecoration,
|
'CodeBlock': codeBlockDecoration,
|
||||||
'BlockMath': mathBlockDecoration,
|
'BlockMath': mathBlockDecoration,
|
||||||
'Blockquote': blockQuoteDecoration,
|
'Blockquote': blockQuoteDecoration,
|
||||||
|
'OrderedList': orderedListDecoration,
|
||||||
|
'BulletList': unorderedListDecoration,
|
||||||
|
|
||||||
|
'ListItem': listItemDecoration,
|
||||||
|
|
||||||
'SetextHeading1': header1LineDecoration,
|
'SetextHeading1': header1LineDecoration,
|
||||||
'ATXHeading1': header1LineDecoration,
|
'ATXHeading1': header1LineDecoration,
|
||||||
@ -122,6 +138,14 @@ const nodeNameToMarkDecoration: Record<string, Decoration> = {
|
|||||||
'TaskMarker': taskMarkerDecoration,
|
'TaskMarker': taskMarkerDecoration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const multilineNodes = {
|
||||||
|
'FencedCode': true,
|
||||||
|
'CodeBlock': true,
|
||||||
|
'BlockMath': true,
|
||||||
|
'Blockquote': true,
|
||||||
|
'OrderedList': true,
|
||||||
|
'BulletList': true,
|
||||||
|
};
|
||||||
|
|
||||||
type DecorationDescription = { pos: number; length: number; decoration: Decoration };
|
type DecorationDescription = { pos: number; length: number; decoration: Decoration };
|
||||||
|
|
||||||
@ -179,8 +203,8 @@ const computeDecorations = (view: EditorView) => {
|
|||||||
addDecorationToRange(viewFrom, viewTo, decoration);
|
addDecorationToRange(viewFrom, viewTo, decoration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only block decorations will have differing first and last lines
|
// Only certain block decorations will have differing first and last lines
|
||||||
if (blockDecorated) {
|
if (blockDecorated && multilineNodes.hasOwnProperty(node.name)) {
|
||||||
// Allow different styles for the first, last lines in a block.
|
// Allow different styles for the first, last lines in a block.
|
||||||
if (viewFrom === node.from) {
|
if (viewFrom === node.from) {
|
||||||
addDecorationToLines(viewFrom, viewFrom, regionStartDecoration);
|
addDecorationToLines(viewFrom, viewFrom, regionStartDecoration);
|
||||||
|
10
packages/editor/CodeMirror/theme.ts
vendored
10
packages/editor/CodeMirror/theme.ts
vendored
@ -79,6 +79,10 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
|||||||
// be at least this specific.
|
// be at least this specific.
|
||||||
const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground';
|
const selectionBackgroundSelector = '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground';
|
||||||
|
|
||||||
|
// Matches the editor only when there are no gutters (e.g. line numbers) added by
|
||||||
|
// plugins
|
||||||
|
const editorNoGuttersSelector = '&:not(:has(> .cm-scroller > .cm-gutters))';
|
||||||
|
|
||||||
const baseHeadingStyle = {
|
const baseHeadingStyle = {
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
fontFamily: theme.fontFamily,
|
fontFamily: theme.fontFamily,
|
||||||
@ -180,6 +184,12 @@ const createTheme = (theme: EditorTheme): Extension[] => {
|
|||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
} : undefined,
|
} : undefined,
|
||||||
|
|
||||||
|
// Allows editor content to be left-aligned with the toolbar on desktop.
|
||||||
|
// See https://github.com/laurent22/joplin/issues/11279
|
||||||
|
[`${editorNoGuttersSelector} .cm-line`]: theme.isDesktop ? {
|
||||||
|
paddingLeft: 0,
|
||||||
|
} : undefined,
|
||||||
|
|
||||||
// Override the default URL style when the URL is within a link
|
// Override the default URL style when the URL is within a link
|
||||||
'& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': {
|
'& .tok-url.tok-link, & .tok-link.tok-meta, & .tok-link.tok-string': {
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
|
@ -90,6 +90,7 @@ export interface ContentScriptData {
|
|||||||
// Intended to correspond with https://codemirror.net/docs/ref/#state.Transaction%5EuserEvent
|
// Intended to correspond with https://codemirror.net/docs/ref/#state.Transaction%5EuserEvent
|
||||||
export enum UserEventSource {
|
export enum UserEventSource {
|
||||||
Paste = 'input.paste',
|
Paste = 'input.paste',
|
||||||
|
Drop = 'input.drop',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorControl {
|
export interface EditorControl {
|
||||||
|
@ -943,6 +943,18 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||||||
'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
|
'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
|
||||||
'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
|
'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
|
||||||
|
|
||||||
|
// For now, applies only to the Markdown viewer
|
||||||
|
'renderer.fileUrls': {
|
||||||
|
storage: SettingStorage.File,
|
||||||
|
isGlobal: true,
|
||||||
|
value: false,
|
||||||
|
type: SettingItemType.Bool,
|
||||||
|
section: 'markdownPlugins',
|
||||||
|
public: true,
|
||||||
|
appTypes: [AppType.Desktop],
|
||||||
|
label: () => `${_('Enable file:// URLs for images and videos')}${wysiwygYes}`,
|
||||||
|
},
|
||||||
|
|
||||||
// Tray icon (called AppIndicator) doesn't work in Ubuntu
|
// Tray icon (called AppIndicator) doesn't work in Ubuntu
|
||||||
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
|
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
|
||||||
// Might be fixed in Electron 18.x but no non-beta release yet. So for now
|
// Might be fixed in Electron 18.x but no non-beta release yet. So for now
|
||||||
@ -1593,6 +1605,20 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'featureFlag.linuxKeychain': {
|
||||||
|
value: false,
|
||||||
|
type: SettingItemType.Bool,
|
||||||
|
public: true,
|
||||||
|
storage: SettingStorage.File,
|
||||||
|
appTypes: [AppType.Desktop],
|
||||||
|
label: () => 'Enable keychain support',
|
||||||
|
description: () => 'This is an experimental setting to enable keychain support on Linux',
|
||||||
|
show: () => shim.isLinux(),
|
||||||
|
section: 'general',
|
||||||
|
isGlobal: true,
|
||||||
|
advanced: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// 'featureFlag.syncAccurateTimestamps': {
|
// 'featureFlag.syncAccurateTimestamps': {
|
||||||
// value: false,
|
// value: false,
|
||||||
|
@ -34,6 +34,13 @@ export async function loadKeychainServiceAndSettings(keychainServiceDrivers: Key
|
|||||||
Setting.setKeychainService(KeychainService.instance());
|
Setting.setKeychainService(KeychainService.instance());
|
||||||
await Setting.load();
|
await Setting.load();
|
||||||
|
|
||||||
|
// Using Linux with the keychain has been observed to cause all secure settings to be lost
|
||||||
|
// on Fedora 40 + GNOME. (This may have been related to running multiple Joplin instances).
|
||||||
|
// For now, make saving to the keychain opt-in until more feedback is received.
|
||||||
|
if (shim.isLinux() && !Setting.value('featureFlag.linuxKeychain')) {
|
||||||
|
KeychainService.instance().readOnly = true;
|
||||||
|
}
|
||||||
|
|
||||||
// This is part of the migration to the new sync target info. It needs to be
|
// This is part of the migration to the new sync target info. It needs to be
|
||||||
// set as early as possible since it's used to tell if E2EE is enabled, it
|
// set as early as possible since it's used to tell if E2EE is enabled, it
|
||||||
// contains the master keys, etc. Once it has been set, it becomes a noop
|
// contains the master keys, etc. Once it has been set, it becomes a noop
|
||||||
|
@ -4,7 +4,7 @@ import Logger from '@joplin/utils/Logger';
|
|||||||
import KvStore from '../KvStore';
|
import KvStore from '../KvStore';
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
|
|
||||||
const logger = Logger.create('KeychainServiceDriver.node');
|
const logger = Logger.create('KeychainServiceDriver.electron');
|
||||||
|
|
||||||
const canUseSafeStorage = () => {
|
const canUseSafeStorage = () => {
|
||||||
return !!shim.electronBridge?.()?.safeStorage?.isEncryptionAvailable();
|
return !!shim.electronBridge?.()?.safeStorage?.isEncryptionAvailable();
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
|
import Logger from '@joplin/utils/Logger';
|
||||||
import { _ } from './locale';
|
import { _ } from './locale';
|
||||||
import Setting from './models/Setting';
|
import Setting from './models/Setting';
|
||||||
import { reg } from './registry';
|
import { reg } from './registry';
|
||||||
|
import KeychainService from './services/keychain/KeychainService';
|
||||||
import { Plugins } from './services/plugins/PluginService';
|
import { Plugins } from './services/plugins/PluginService';
|
||||||
import shim from './shim';
|
import shim from './shim';
|
||||||
|
|
||||||
|
const logger = Logger.create('versionInfo');
|
||||||
|
|
||||||
export interface PackageInfo {
|
export interface PackageInfo {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
@ -70,15 +74,21 @@ export default function versionInfo(packageInfo: PackageInfo, plugins: Plugins)
|
|||||||
copyrightText.replace('YYYY', `${now.getFullYear()}`),
|
copyrightText.replace('YYYY', `${now.getFullYear()}`),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let keychainSupported = false;
|
||||||
|
try {
|
||||||
|
// To allow old keys to be read, certain apps allow read-only keychain access:
|
||||||
|
keychainSupported = Setting.value('keychain.supported') >= 1 && !KeychainService.instance().readOnly;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to determine if keychain is supported', error);
|
||||||
|
}
|
||||||
|
|
||||||
const body = [
|
const body = [
|
||||||
_('%s %s (%s, %s)', p.name, p.version, Setting.value('env'), shim.platformName()),
|
_('%s %s (%s, %s)', p.name, p.version, Setting.value('env'), shim.platformName()),
|
||||||
'',
|
'',
|
||||||
_('Client ID: %s', Setting.value('clientId')),
|
_('Client ID: %s', Setting.value('clientId')),
|
||||||
_('Sync Version: %s', Setting.value('syncVersion')),
|
_('Sync Version: %s', Setting.value('syncVersion')),
|
||||||
_('Profile Version: %s', reg.db().version()),
|
_('Profile Version: %s', reg.db().version()),
|
||||||
// The portable app temporarily supports read-only keychain access (but disallows
|
_('Keychain Supported: %s', keychainSupported ? _('Yes') : _('No')),
|
||||||
// write).
|
|
||||||
_('Keychain Supported: %s', (Setting.value('keychain.supported') >= 1 && !shim.isPortable()) ? _('Yes') : _('No')),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (gitInfo) {
|
if (gitInfo) {
|
||||||
|
@ -310,12 +310,6 @@ function renderToStringWithCache(latex: string, katexOptions: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
||||||
function renderKatexError(latex: string, error: any): string {
|
|
||||||
console.error('Katex error for:', latex, error);
|
|
||||||
return `<div class="inline-code">${error.message}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||||
plugin: function(markdownIt: any, options: RuleOptions) {
|
plugin: function(markdownIt: any, options: RuleOptions) {
|
||||||
@ -329,6 +323,10 @@ export default {
|
|||||||
katexOptions.macros = options.context.userData.__katex.macros;
|
katexOptions.macros = options.context.userData.__katex.macros;
|
||||||
katexOptions.trust = true;
|
katexOptions.trust = true;
|
||||||
|
|
||||||
|
const renderKatexError = (error: Error): string => {
|
||||||
|
return `<div class="inline-code">${markdownIt.utils.escapeHtml(error.message)}</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
// set KaTeX as the renderer for markdown-it-simplemath
|
// set KaTeX as the renderer for markdown-it-simplemath
|
||||||
const katexInline = function(latex: string) {
|
const katexInline = function(latex: string) {
|
||||||
katexOptions.displayMode = false;
|
katexOptions.displayMode = false;
|
||||||
@ -336,7 +334,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
outputHtml = renderToStringWithCache(latex, katexOptions);
|
outputHtml = renderToStringWithCache(latex, katexOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outputHtml = renderKatexError(latex, error);
|
outputHtml = renderKatexError(error);
|
||||||
}
|
}
|
||||||
return `<span class="joplin-editable"><span class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$" data-joplin-source-close="$">${markdownIt.utils.escapeHtml(latex)}</span>${outputHtml}</span>`;
|
return `<span class="joplin-editable"><span class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$" data-joplin-source-close="$">${markdownIt.utils.escapeHtml(latex)}</span>${outputHtml}</span>`;
|
||||||
};
|
};
|
||||||
@ -353,7 +351,7 @@ export default {
|
|||||||
try {
|
try {
|
||||||
outputHtml = renderToStringWithCache(latex, katexOptions);
|
outputHtml = renderToStringWithCache(latex, katexOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outputHtml = renderKatexError(latex, error);
|
outputHtml = renderKatexError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$$ " data-joplin-source-close=" $$ ">${markdownIt.utils.escapeHtml(latex)}</pre>${outputHtml}</div>`;
|
return `<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$$ " data-joplin-source-close=" $$ ">${markdownIt.utils.escapeHtml(latex)}</pre>${outputHtml}</div>`;
|
||||||
|
@ -139,3 +139,4 @@ treeitem
|
|||||||
qrcode
|
qrcode
|
||||||
Rocketbook
|
Rocketbook
|
||||||
datamatrix
|
datamatrix
|
||||||
|
nosniff
|
||||||
|
@ -1,5 +1,19 @@
|
|||||||
# Joplin Android Changelog
|
# Joplin Android Changelog
|
||||||
|
|
||||||
|
## [android-v3.1.7](https://github.com/laurent22/joplin/releases/tag/android-v3.1.7) (Pre-release) - 2024-11-04T20:27:52Z
|
||||||
|
|
||||||
|
- Fixed: Fix search result note hidden after powering on device (#11297) (#11197 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
|
||||||
|
## [android-v3.1.6](https://github.com/laurent22/joplin/releases/tag/android-v3.1.6) (Pre-release) - 2024-10-17T22:13:06Z
|
||||||
|
|
||||||
|
- Improved: Downgrade CodeMirror packages to fix various Android regressions (#11170 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Improved: Plugins: Name webview root attribute so that it can be styled (75b8caf)
|
||||||
|
- Improved: Updated packages @react-native/babel-preset (v0.74.85), @react-native/metro-config (v0.74.85), glob (v10.4.5), katex (v0.16.11), react-native-document-picker (v9.3.0), react-native-safe-area-context (v4.10.7), stream (v0.0.3)
|
||||||
|
- Fixed: Fix automatic resource download mode (#11144) (#11134 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix incorrect list switching behavior (#11137) (#11135 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix new note/edit buttons only work if pressed quickly (#11185) (#11183 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix regression: Search screen not hidden when cached for search result navigation (#11131) (#11130 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
|
||||||
## [android-v3.1.5](https://github.com/laurent22/joplin/releases/tag/android-v3.1.5) (Pre-release) - 2024-10-11T22:11:20Z
|
## [android-v3.1.5](https://github.com/laurent22/joplin/releases/tag/android-v3.1.5) (Pre-release) - 2024-10-11T22:11:20Z
|
||||||
|
|
||||||
- Improved: Downgrade CodeMirror packages to fix various Android regressions (#11170 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
- Improved: Downgrade CodeMirror packages to fix various Android regressions (#11170 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
@ -1,5 +1,37 @@
|
|||||||
# Joplin iOS Changelog
|
# Joplin iOS Changelog
|
||||||
|
|
||||||
|
## [ios-v13.1.6](https://github.com/laurent22/joplin/releases/tag/ios-v13.1.6) - 2024-10-17T22:16:20Z
|
||||||
|
|
||||||
|
- Improved: Added feature flag to disable sync lock support (#10925) (#10407)
|
||||||
|
- Improved: Automatically detect and use operating system theme by default (5beb80b)
|
||||||
|
- Improved: Downgrade CodeMirror packages to fix various Android regressions (#11170 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Improved: Make feature flags advanced settings by default (700ffa2)
|
||||||
|
- Improved: Make pressing "back" navigate to the previous note after following a link (#11086) (#11082 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Improved: Plugins: Name webview root attribute so that it can be styled (75b8caf)
|
||||||
|
- Improved: Scroll dropdown to selected value when first opened (#11091 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Improved: Show loading indicator while loading search results (#11104 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Improved: Support permanent note deletion on mobile (#10786) (#10763 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Improved: Updated packages @bam.tech/react-native-image-resizer (v3.0.10), @js-draw/material-icons (v1.20.3), @react-native-clipboard/clipboard (v1.14.1), @react-native-community/datetimepicker (v8.0.1), @react-native/babel-preset (v0.74.85), @react-native/metro-config (v0.74.85), @rollup/plugin-commonjs (v25.0.8), @rollup/plugin-replace (v5.0.7), async-mutex (v0.5.0), dayjs (v1.11.11), glob (v10.4.5), js-draw (v1.20.3), jsdom (v24.1.0), katex (v0.16.11), markdown-it-ins (v4), markdown-it-sup (v2), react, react-native-device-info (v10.14.0), react-native-document-picker (v9.3.0), react-native-localize (v3.1.0), react-native-safe-area-context (v4.10.7), react-native-share (v10.2.1), react-native-webview (v13.8.7), react-native-zip-archive (v6.1.2), sass (v1.77.6), sharp (v0.33.4), stream (v0.0.3), tesseract.js (v5.1.0), turndown (v7.2.0)
|
||||||
|
- Improved: Upgrade CodeMirror packages (#11034 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Improved: Use fade animation for edit link dialog (#11090 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Accessibility: Fix sidebar broken in right-to-left mode, improve screen reader accessibility (#11056) (#11028 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Decrypt master keys only as needed (#10990) (#10856 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Delete revisions on the sync target when deleted locally (#11035) (#11017 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Drawing: Fix clicking "cancel" after starting a new drawing in editing mode creates an empty resource (#10986 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix "Enable auto-updates" enabled by default and visible on unsupported platforms (#10897) (#10896 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix BMP image rendering in the Markdown viewer (#10915) (#10914 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix automatic resource download mode (#11144) (#11134 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix incorrect list switching behavior (#11137) (#11135 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix new note/edit buttons only work if pressed quickly (#11185) (#11183 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix regression: Search screen not hidden when cached for search result navigation (#11131) (#11130 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix toolbar overflow menu is invisible (#10871) (#10867 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fix unable to change incorrect decryption password if the same as the master password (#11026 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Fixed italic support in Fountain documents (5fdd088)
|
||||||
|
- Fixed: Improve performance when there are many selected items (#11067) (#11065 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Markdown editor: Fix toggling bulleted lists when items start with asterisks (#10902) (#10891 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: Move accessibility focus to the first note action menu item on open (#11031) (#10253 by [@personalizedrefrigerator](https://github.com/personalizedrefrigerator))
|
||||||
|
- Fixed: WebDAV synchronisation not working because of URL encoding differences (#11076) (#10608 by [@pedr](https://github.com/pedr))
|
||||||
|
|
||||||
## [ios-v13.1.5](https://github.com/laurent22/joplin/releases/tag/ios-v13.1.5) - 2024-10-11T22:29:29Z
|
## [ios-v13.1.5](https://github.com/laurent22/joplin/releases/tag/ios-v13.1.5) - 2024-10-11T22:29:29Z
|
||||||
|
|
||||||
- Improved: Added feature flag to disable sync lock support (#10925) (#10407)
|
- Improved: Added feature flag to disable sync lock support (#10925) (#10407)
|
||||||
|
@ -31,7 +31,7 @@ Here's an example:
|
|||||||
- Joplin checks to make sure the `joplin-content://` protocol has access to `/home/user/.config/joplin-desktop/path/here.css`. If it does, it fetches and returns the file.
|
- Joplin checks to make sure the `joplin-content://` protocol has access to `/home/user/.config/joplin-desktop/path/here.css`. If it does, it fetches and returns the file.
|
||||||
|
|
||||||
|
|
||||||
## `joplin-content://` only has access to specific directories
|
## `joplin-content://note-viewer/` only has access to specific directories
|
||||||
|
|
||||||
When `handleCustomProtocols` creates a handler for the `joplin-content://` protocol, it returns an object that allows certain directories to be marked as readable.
|
When `handleCustomProtocols` creates a handler for the `joplin-content://` protocol, it returns an object that allows certain directories to be marked as readable.
|
||||||
|
|
||||||
@ -41,6 +41,13 @@ By default, the list of readable directories includes:
|
|||||||
- The resource directory
|
- The resource directory
|
||||||
- The profile directory
|
- The profile directory
|
||||||
|
|
||||||
|
## `joplin-content://file-media/` can only load specific file types
|
||||||
|
|
||||||
|
To allow images and videos with `file://` URLs, Joplin maps `file://` URIs to `joplin-content://file-media/`. The `file-media/` host has the following restrictions:
|
||||||
|
- Only files with certain extensions/content-types can be loaded.
|
||||||
|
- For example, `text/html` is disallowed but `image/png` is allowed.
|
||||||
|
- A valid `?access-key=<...>` parameter must be provided with the request.
|
||||||
|
- A new access key is created for each render and old access keys are revoked.
|
||||||
|
|
||||||
## Why not the [`sandbox`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) property?
|
## Why not the [`sandbox`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox) property?
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user