Compare commits

..
1 Commits
Author SHA1 Message Date
Laurent Cozic 318cfb5729 update 2026-11-08 18:28:18 +01:00
39 changed files with 480 additions and 16647 deletions

No files matched your search

+4 -3
View File
@@ -686,6 +686,7 @@ packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
packages/app-desktop/services/plugins/hooks/useThemeCss.js
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
packages/app-desktop/services/plugins/startBlockedPluginsCheck.js
packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
@@ -1664,10 +1665,8 @@ packages/lib/services/ai/providers/JoplinCloud.js
packages/lib/services/ai/providers/OpenAiCompatible.test.js
packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/providers/openAiResponses.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/testing/mockOpenAiServer.js
packages/lib/services/ai/tools/ToolIndex.js
packages/lib/services/ai/tools/buildEditorTools.js
packages/lib/services/ai/tools/global/createNote.js
@@ -1696,7 +1695,6 @@ packages/lib/services/ai/types.js
packages/lib/services/ai/utils/extractReasoning.test.js
packages/lib/services/ai/utils/extractReasoning.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/ai/utils/parseToolArguments.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
@@ -1881,8 +1879,11 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
packages/lib/services/plugins/loadPlugins.test.js
packages/lib/services/plugins/loadPlugins.js
packages/lib/services/plugins/reducer.js
packages/lib/services/plugins/refreshBlockedPluginVersions.js
packages/lib/services/plugins/testing/MockPlatformImplementation.js
packages/lib/services/plugins/testing/MockPluginRunner.js
packages/lib/services/plugins/utils/blockedVersions.test.js
packages/lib/services/plugins/utils/blockedVersions.js
packages/lib/services/plugins/utils/createViewHandle.js
packages/lib/services/plugins/utils/executeSandboxCall.js
packages/lib/services/plugins/utils/getActivePluginEditorView.js
+4 -3
View File
@@ -712,6 +712,7 @@ packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
packages/app-desktop/services/plugins/hooks/useThemeCss.js
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
packages/app-desktop/services/plugins/startBlockedPluginsCheck.js
packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js
@@ -1690,10 +1691,8 @@ packages/lib/services/ai/providers/JoplinCloud.js
packages/lib/services/ai/providers/OpenAiCompatible.test.js
packages/lib/services/ai/providers/OpenAiCompatible.js
packages/lib/services/ai/providers/TestProvider.js
packages/lib/services/ai/providers/openAiResponses.js
packages/lib/services/ai/testing/TestEmbeddingProvider.test.js
packages/lib/services/ai/testing/TestEmbeddingProvider.js
packages/lib/services/ai/testing/mockOpenAiServer.js
packages/lib/services/ai/tools/ToolIndex.js
packages/lib/services/ai/tools/buildEditorTools.js
packages/lib/services/ai/tools/global/createNote.js
@@ -1722,7 +1721,6 @@ packages/lib/services/ai/types.js
packages/lib/services/ai/utils/extractReasoning.test.js
packages/lib/services/ai/utils/extractReasoning.js
packages/lib/services/ai/utils/findFencedBlock.js
packages/lib/services/ai/utils/parseToolArguments.js
packages/lib/services/commands/MenuUtils.js
packages/lib/services/commands/ToolbarButtonUtils.test.js
packages/lib/services/commands/ToolbarButtonUtils.js
@@ -1907,8 +1905,11 @@ packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.js
packages/lib/services/plugins/loadPlugins.test.js
packages/lib/services/plugins/loadPlugins.js
packages/lib/services/plugins/reducer.js
packages/lib/services/plugins/refreshBlockedPluginVersions.js
packages/lib/services/plugins/testing/MockPlatformImplementation.js
packages/lib/services/plugins/testing/MockPluginRunner.js
packages/lib/services/plugins/utils/blockedVersions.test.js
packages/lib/services/plugins/utils/blockedVersions.js
packages/lib/services/plugins/utils/createViewHandle.js
packages/lib/services/plugins/utils/executeSandboxCall.js
packages/lib/services/plugins/utils/getActivePluginEditorView.js
+23 -19
View File
@@ -1,21 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><item><title><![CDATA[A full security review of Joplin Server and Joplin Cloud]]></title><description><![CDATA[<p>October 2026 has been nicknamed &quot;Black October&quot;: within weeks, critical vulnerabilities were disclosed in widely used open source software such as PHP, Node.js and GitLab, many of them found with the help of AI and exploited within a day. The same tools that help defenders find bugs now help attackers find them, and Joplin's code is public. The responsible thing to do was to get there first.</p>
<h2>The review<a name="the-review" href="#the-review" class="heading-anchor">🔗</a></h2>
<p>Anthropic gave Joplin access to the Defense Access tier of its Cyber Verification Program, which lets open source maintainers use Claude's full security capabilities on software they maintain. We used it for a complete review of Joplin Server, which also powers Joplin Cloud: every route of the server, and every flow end to end, from signing in to sharing, publishing and email-to-note. Each finding was verified independently, and each fix was reviewed again before release.</p>
<p>No critical issues were found, but there were a number of high and medium severity ones, and many smaller ones. We have fixed more than 50 of them. Joplin Cloud is already updated, and the fixes are now in a new Joplin Server release. If you use Joplin Cloud, there's nothing you need to do.</p>
<h2>What was addressed<a name="what-was-addressed" href="#what-was-addressed" class="heading-anchor">🔗</a></h2>
<p>Most fixes concern <strong>account security</strong>: signing in, password resets, email changes and two-factor authentication were all tightened. Changing your password now shuts out anyone who had access to your account, and the server no longer reveals which email addresses have an account. Password guessing is now limited per account, without locking out the real owner.</p>
<p>Others concern <strong>sharing and publishing</strong>. The server trusted shared content a little too much, so someone you shared a notebook with could in some cases affect notes outside the share. It now checks that shared content really belongs where it claims, and published pages show only what was meant to be published.</p>
<p>The rest make the server more <strong>robust</strong>, so that oversized requests or specially crafted content can't slow it down for everyone, and improve <strong>LDAP and SAML</strong> sign-in for self-hosted servers.</p>
<h2>If you self-host<a name="if-you-self-host" href="#if-you-self-host" class="heading-anchor">🔗</a></h2>
<p>Please update to <strong>version 3.7.4</strong> or greater. Note these changes:</p>
<ul>
<li>With <strong>LDAP</strong>, an account matching a directory entry is managed by the directory after its first LDAP sign-in, and its local password stops working.</li>
<li>Sign-up is disabled when <strong>SAML or LDAP</strong> is enabled, and the SAML Audience must match your server's entity ID.</li>
<li>Email confirmation is only required for sharing when your server can send emails.</li>
<li>Your reverse proxy must set the <code>X-Real-IP</code> header for rate limits to work.</li>
</ul>
<p>Details are in the changelog and the security advisory. Thanks to Anthropic for making this review possible, and to everyone who reports security issues responsibly.</p>
]]></description><link>https://joplinapp.org/news/20261102-joplin-server-security-review</link><guid isPermaLink="false">20261102-joplin-server-security-review</guid><pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate><twitter-text></twitter-text></item><item><title><![CDATA[How we protect your privacy with Joplin Cloud AI]]></title><description><![CDATA[<p>Joplin Cloud AI features work on your notes. Notes are often personal, so we've set up the AI service so that your content goes to as few places as possible and stays there for as short a time as possible.</p>
<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Joplin]]></title><description><![CDATA[Joplin, the open source note-taking application]]></description><link>https://joplinapp.org</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://joplinapp.org/rss.xml" rel="self" type="application/rss+xml"/><pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate><item><title><![CDATA[How we protect your privacy with Joplin Cloud AI]]></title><description><![CDATA[<p>Joplin Cloud AI features work on your notes. Notes are often personal, so we've set up the AI service so that your content goes to as few places as possible and stays there for as short a time as possible.</p>
<p><strong>A European AI provider, processing in Europe.</strong> Joplin Cloud AI uses models from Mistral AI, a French company. We send requests to Mistral's EU regional endpoint, which processes data only in data centres in the European Union and EFTA countries. Your notes are not sent to the United States for processing.</p>
<p><strong>Zero Data Retention.</strong> Mistral has enabled Zero Data Retention on our account. Once a response has been generated, Mistral doesn't store or log the content of the request or the response. Only basic billing information, such as how many tokens were used, is kept.</p>
<p><strong>No training on your notes.</strong> Your content isn't used to train or improve AI models. Mistral's commercial terms rule this out for our account, and training is switched off in our account settings.</p>
@@ -560,4 +543,25 @@ sys 0m38.013s</p>
<li>Android: <a href="https://joplinapp.org/help/about/changelog/android/">https://joplinapp.org/help/about/changelog/android/</a></li>
<li>iOS: <a href="https://joplinapp.org/help/about/changelog/ios/">https://joplinapp.org/help/about/changelog/ios/</a></li>
</ul>
]]></description><link>https://joplinapp.org/news/20231204-release-2-13</link><guid isPermaLink="false">20231204-release-2-13</guid><pubDate>Mon, 04 Dec 2023 00:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.13</twitter-text></item></channel></rss>
]]></description><link>https://joplinapp.org/news/20231204-release-2-13</link><guid isPermaLink="false">20231204-release-2-13</guid><pubDate>Mon, 04 Dec 2023 00:00:00 GMT</pubDate><twitter-text>What&apos;s new in Joplin 2.13</twitter-text></item><item><title><![CDATA[Working in the shadows with white-hat hackers]]></title><description><![CDATA[<p>The majority of Joplin's development is carried out in the public domain. This includes the discussion of issues on GitHub, as well as the submission of pull requests and related discussions. The transparency of these processes allows for collaborative problem-solving and shared insights.</p>
<p>However, there is one aspect that operates behind closed doors, and for good reason: addressing cybersecurity vulnerabilities. It is imperative that these issues remain undisclosed until they have been resolved. Once a solution is implemented, it is usually accompanied by discreet commits and a message in the changelog to signify the progress made.</p>
<p>Typically, the process begins with an email from a security researcher. They provide valuable insights, such as a specially crafted note that triggers a bug, or an API call, along with an explanation of how the application's security can be circumvented. We examine the vulnerability, create a fix, and create automated test units to prevent any accidental reintroduction of the vulnerability in future code updates. An example of such a commit is: <a href="https://github.com/laurent22/joplin/commit/9e90d9016daf79b5414646a93fd369aedb035071">9e90d9016daf79b5414646a93fd369aedb035071</a></p>
<p>We then share our fix with the researcher for validation. Additionally, we often apply the fix to previous versions of Joplin, depending on the severity of the vulnerability.</p>
<p>The contribution of security researchers in this regard is immeasurable. They employ their ingenuity to identify inventive methods of bypassing existing security measures and often discover subtle flaws in the code that might otherwise go unnoticed.</p>
<p>We would like to express our sincere gratitude to the security researchers who have assisted us throughout the years in identifying and rectifying security vulnerabilities!</p>
<ul>
<li><a href="https://github.com/a1ise">@Alise</a></li>
<li>@hexodotsh</li>
<li><a href="https://github.com/ly1g3">@ly1g3</a></li>
<li><a href="https://twitter.com/maple3142">@maple3142</a></li>
<li>Ademar Nowasky Junior</li>
<li><a href="mailto:ben@mayhem.sg">Benjamin Harris</a></li>
<li><a href="https://github.com/JavierOlmedo">Javier Olmedo</a></li>
<li><a href="https://twitter.com/newfolderj">Jubair Rehman Yousafzai</a></li>
<li>lin@UCCU Hacker</li>
<li><a href="https://github.com/personalizedrefrigerator">personalizedrefrigerator</a></li>
<li><a href="https://twitter.com/fhlipZero">Phil Holbrook</a></li>
<li><a href="https://ryotak.net/">RyotaK</a></li>
<li><a href="https://twitter.com/YNizry">Yaniv Nizry</a></li>
</ul>
]]></description><link>https://joplinapp.org/news/20231023-white-hat-hackers</link><guid isPermaLink="false">20231023-white-hat-hackers</guid><pubDate>Mon, 23 Oct 2023 00:00:00 GMT</pubDate><twitter-text>Working in the shadows with white-hat hackers</twitter-text></item></channel></rss>
@@ -1,14 +1,26 @@
import { AppType } from '@joplin/lib/models/Setting';
import RepositoryApi, { AppInfo, InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
import Setting from '@joplin/lib/models/Setting';
import shim from '@joplin/lib/shim';
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
import { setupDatabaseAndSynchronizer, switchClient, supportDir, createTempDir } from '@joplin/lib/testing/test-utils';
import { remove } from 'fs-extra';
import { copy, mkdirp, readJson, remove, writeFile, writeJson } from 'fs-extra';
let tempDirs: string[] = [];
async function newRepoApi(appInfo: AppInfo = { type: AppType.Desktop, version: '3.0.0' }): Promise<RepositoryApi> {
async function newRepoApi(appInfo: AppInfo = { type: AppType.Desktop, version: '3.0.0' }, blockedVersions: Record<string, string[]> = null): Promise<RepositoryApi> {
const tempDir = await createTempDir();
tempDirs.push(tempDir);
const repo = new RepositoryApi(`${supportDir}/pluginRepo`, tempDir, appInfo, InstallMode.Default);
let repoDir = `${supportDir}/pluginRepo`;
if (blockedVersions) {
repoDir = `${tempDir}/pluginRepo`;
await copy(`${supportDir}/pluginRepo`, repoDir);
const manifests = await readJson(`${repoDir}/manifests.json`);
for (const [id, versions] of Object.entries(blockedVersions)) manifests[id]._blocked_versions = versions;
await writeJson(`${repoDir}/manifests.json`, manifests);
}
const repo = new RepositoryApi(repoDir, tempDir, appInfo, InstallMode.Default);
await repo.initialize();
return repo;
}
@@ -67,4 +79,33 @@ describe('services_plugins_RepositoryApi', () => {
expect(await api.pluginCanBeUpdated(id, installedVersion)).toBe(shouldBeUpdatable);
}));
it.each([
['1.0.2'],
['>=1.0.1'],
['*'],
])('should not update to or download a blocked version (%s)', (async (blockedVersion) => {
const id = 'org.joplinapp.plugins.ToggleSidebars';
const api = await newRepoApi(undefined, { [id]: [blockedVersion] });
expect(await api.pluginCanBeUpdated(id, '1.0.0')).toBe(false);
await expect(api.downloadPlugin(id)).rejects.toThrow('has been blocked');
}));
it('should cache the blocked versions only when plugins are installed', (async () => {
const blockedVersions = { 'org.joplinapp.plugins.ToggleSidebars': ['1.0.1', '1.0.2'] };
const api = await newRepoApi(undefined, blockedVersions);
await refreshBlockedPluginVersions(api);
expect(Setting.value('plugins.blockedVersions')).toEqual({});
const pluginPath = `${Setting.value('pluginDir')}/test.jpl`;
await mkdirp(Setting.value('pluginDir'));
await writeFile(pluginPath, '');
try {
await refreshBlockedPluginVersions(api);
expect(Setting.value('plugins.blockedVersions')).toEqual(blockedVersions);
} finally {
await remove(pluginPath);
}
}));
});
+3
View File
@@ -18,6 +18,7 @@ import { _, setLocale } from '@joplin/lib/locale';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
import bridge from './services/bridge';
import startBlockedPluginsCheck from './services/plugins/startBlockedPluginsCheck';
import menuCommandNames from './gui/menuCommandNames';
import ResourceService from '@joplin/lib/services/ResourceService';
import ExternalEditWatcher from '@joplin/lib/services/ExternalEditWatcher';
@@ -385,6 +386,8 @@ class Application extends BaseApplication {
ipcRenderer.send('startup-plugins-loaded');
void afterDefaultPluginsLoaded(service.plugins, getDefaultPluginsInfo(), pluginSettings);
startBlockedPluginsCheck(this.store());
}
}, 500);
}
@@ -0,0 +1,60 @@
import Setting, { AppType } from '@joplin/lib/models/Setting';
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
import { State, stateUtils } from '@joplin/lib/reducer';
import shim from '@joplin/lib/shim';
import time from '@joplin/lib/time';
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/utils/Logger';
import { Store } from 'redux';
import bridge from '../bridge';
import restart from '../restart';
const logger = Logger.create('startBlockedPluginsCheck');
const waitForNotesSaved = async (store: Store<State>) => {
const startTime = Date.now();
while (stateUtils.hasNotesBeingSaved(store.getState()) && Date.now() - startTime < 5000) {
await time.msleep(100);
}
};
// A blocked plugin can't be fully unloaded on desktop since its content scripts run in the main
// window, so we restart straight away rather than wait for the user, who may not be at their computer.
const restartForBlockedPlugins = async (store: Store<State>, pluginIds: string[]) => {
logger.warn('Restarting because these running plugins have been blocked:', pluginIds);
const service = PluginService.instance();
Setting.setValue('plugins.blockedRestartNotice', pluginIds.map(id => service.pluginById(id).manifest.name));
await waitForNotesSaved(store);
await restart();
};
const showBlockedRestartNotice = () => {
const pluginNames: string[] = Setting.value('plugins.blockedRestartNotice');
if (!pluginNames.length) return;
Setting.setValue('plugins.blockedRestartNotice', []);
bridge().showInfoMessageBox(_('Joplin was restarted because the following plugins have been disabled by the Joplin team for security reasons: %s\n\nWe recommend uninstalling them.', pluginNames.join(', ')));
};
const startBlockedPluginsCheck = (store: Store<State>) => {
showBlockedRestartNotice();
const repoApi = RepositoryApi.ofDefaultJoplinRepo(
Setting.value('tempDir'), { type: AppType.Desktop, version: PluginService.instance().appVersion }, InstallMode.Default,
);
const check = async () => {
try {
const blockedRunningPluginIds = await refreshBlockedPluginVersions(repoApi);
if (blockedRunningPluginIds.length) await restartForBlockedPlugins(store, blockedRunningPluginIds);
} catch (error) {
logger.warn('Could not refresh blocked plugin versions:', error);
}
};
void check();
shim.setInterval(() => { void check(); }, 60 * 60 * 1000);
};
export default startBlockedPluginsCheck;
@@ -9,6 +9,7 @@ import { connect, useStore } from 'react-redux';
import Logger from '@joplin/utils/Logger';
import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService';
import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer';
import { BlockedPluginVersions } from '@joplin/lib/services/plugins/utils/blockedVersions';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import PluginDialogManager from './dialogs/PluginDialogManager';
import { AppState } from '../../utils/types';
@@ -33,6 +34,7 @@ const usePlugins = (
pluginSettings: PluginSettings,
pluginSupportEnabled: boolean,
devPluginPath: string,
blockedPluginVersions: BlockedPluginVersions,
) => {
const store = useStore<AppState>();
const lastPluginRunner = usePrevious(pluginRunner);
@@ -71,7 +73,7 @@ const usePlugins = (
if (!event.cancelled) {
reloadAllRef.current = false;
}
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter]);
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter, blockedPluginVersions]);
};
const useUnloadPluginsOnGlobalDisable = (
@@ -95,6 +97,7 @@ interface Props {
pluginSupportEnabled: boolean;
pluginStates: PluginStates;
devPluginPath: string;
blockedPluginVersions: BlockedPluginVersions;
pluginHtmlContents: PluginHtmlContents;
themeId: number;
}
@@ -125,7 +128,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
}, [webviewReloadCounter]);
const pluginSettings = usePluginSettings(props.serializedPluginSettings);
usePlugins(pluginRunner, webviewLoaded, pluginSettings, props.pluginSupportEnabled, props.devPluginPath);
usePlugins(pluginRunner, webviewLoaded, pluginSettings, props.pluginSupportEnabled, props.devPluginPath, props.blockedPluginVersions);
useUnloadPluginsOnGlobalDisable(props.pluginStates, props.pluginSupportEnabled);
const onLoadStart = useCallback(() => {
@@ -213,6 +216,7 @@ export default connect((state: AppState) => {
serializedPluginSettings: state.settings['plugins.states'],
pluginSupportEnabled: state.settings['plugins.pluginSupportEnabled'],
devPluginPath: state.settings['plugins.devPluginPaths'],
blockedPluginVersions: state.settings['plugins.blockedVersions'],
pluginStates: state.pluginService.plugins,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
themeId: state.settings.theme,
@@ -8,6 +8,8 @@ import ResourceService from '@joplin/lib/services/ResourceService';
import KvStore from '@joplin/lib/services/KvStore';
import Setting, { AppType, Env } from '@joplin/lib/models/Setting';
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi';
import refreshBlockedPluginVersions from '@joplin/lib/services/plugins/refreshBlockedPluginVersions';
import { parseNotesParent } from '@joplin/lib/reducer';
import uuid from '@joplin/lib/uuid';
import { loadKeychainServiceAndSettings } from '@joplin/lib/services/SettingUtils';
@@ -515,6 +517,21 @@ const buildStartupTasks = (
const updatedSettings = pluginService.clearUpdateState(pluginSettings);
Setting.setValue('plugins.states', updatedSettings);
// Running plugins that are now blocked are unloaded by PluginRunnerWebView when the setting changes
const repoApi = RepositoryApi.ofDefaultJoplinRepo(
Setting.value('tempDir'), { type: AppType.Mobile, version: platformImplementation.versionInfo.version }, InstallMode.Default,
);
const checkBlockedPlugins = async () => {
if (!Setting.value('plugins.pluginSupportEnabled')) return;
try {
await refreshBlockedPluginVersions(repoApi);
} catch (error) {
logger.warn('Could not refresh blocked plugin versions:', error);
}
};
void checkBlockedPlugins();
PoorManIntervals.setInterval(() => { void checkBlockedPlugins(); }, 4 * 60 * 60 * 1000);
});
addTask('buildStartupTasks/run startup tests', async () => {
// ----------------------------------------------------------------------------
@@ -7,6 +7,7 @@ import type SettingType from '../Setting';
import { AppType, SettingItemSubType, SettingItemType, SettingStorage, SyncStartupOperation, SettingItem, SettingButtonStyle } from './types';
import { defaultListColumns } from '../../services/plugins/api/noteListType';
import type { PluginSettings } from '../../services/plugins/PluginService';
import type { BlockedPluginVersions } from '../../services/plugins/utils/blockedVersions';
import type { PublicPrivateKeyPair } from '../../services/e2ee/ppk/ppk';
import type { NoteLockKeyConflict } from '../../services/synchronizer/syncInfoUtils';
import { EmptyObject } from '@joplin/utils/types';
@@ -1447,6 +1448,20 @@ const builtInMetadata = (Setting: typeof SettingType) => {
autoSave: true,
},
'plugins.blockedVersions': {
value: {} as BlockedPluginVersions,
type: SettingItemType.Object,
public: false,
appTypes: [AppType.Desktop, AppType.Mobile],
},
'plugins.blockedRestartNotice': {
value: [] as string[],
type: SettingItemType.Array,
public: false,
appTypes: [AppType.Desktop],
},
'plugins.enableWebviewDebugging': {
value: false,
type: SettingItemType.Bool,
+1 -1
View File
@@ -59,7 +59,7 @@
"@joplin/turndown": "^4.0.88",
"@joplin/turndown-plugin-gfm": "^1.0.70",
"@joplin/utils": "^3.7.3",
"adm-zip": "0.6.0",
"adm-zip": "0.5.18",
"async-mutex": "0.5.0",
"base-64": "1.0.0",
"base64-stream": "1.0.0",
@@ -6,7 +6,6 @@ import Note from '../../models/Note';
import Tag from '../../models/Tag';
import { NoteEntity } from '../database/types';
import { NoteContext, ToolDefinition } from './tools/types';
import mockOpenAiServer, { jsonResponse } from './testing/mockOpenAiServer';
const runChatForNote = (note: NoteEntity, history: ChatMessage[], message: string) => {
const getContext = async () => {
@@ -118,30 +117,4 @@ describe('noteChat.tools', () => {
expect(infoMessage).toBeTruthy();
expect(infoMessage.content).toContain('Tool `manage_tags` is disabled in Joplin\'s settings');
});
test('should keep the model reasoning between the requests of a tool loop', async () => {
const baseUrl = 'http://localhost:1234/v1';
Setting.setValue('ai.chat.providerType', 'openai-compatible');
Setting.setValue('ai.chat.baseUrl', baseUrl);
Setting.setValue('ai.chat.model', 'test-model');
const reasoningItem = { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: 'Thinking...' }], encrypted_content: 'encrypted' };
const functionCallItem = { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'editor_readNoteBody', arguments: '{}' };
const requests = mockOpenAiServer(baseUrl, {
'/responses': () => {
const output = requests.length === 1 ? [reasoningItem, functionCallItem] : [{ type: 'message', content: [{ type: 'output_text', text: 'Done' }] }];
return jsonResponse({ status: 'completed', output });
},
});
try {
const note = await Note.save({ title: 'test' });
await runChatForNote(note, [{ role: ChatRole.User, content: 'testing' }], 'Read the note');
} finally {
jest.restoreAllMocks();
}
expect(requests).toHaveLength(2);
expect(requests[1].body.input).toEqual(expect.arrayContaining([reasoningItem, functionCallItem]));
});
});
-1
View File
@@ -203,7 +203,6 @@ const stepNoteChat = async ({
role: ChatRole.Assistant,
content: chatResult.text ?? '',
toolCalls: allowTools ? chatResult.toolCalls : [],
responsesState: allowTools ? chatResult.responsesState : undefined,
});
onHistoryChanged([...messages]);
@@ -85,22 +85,6 @@ describe('ai/providers/JoplinCloud', () => {
expect(error.message).toMatch(/reached your AI usage budget/i);
});
it('should only use the Chat Completions API', async () => {
const urls: string[] = [];
const mock = mockFetch((request) => {
urls.push(request.url);
return new Response(request.url.endsWith('ai/chat/completions') ? '{ "choices": [] }' : '{ }', { status: 200 });
});
try {
await new JoplinCloudProvider().chat([]);
} finally {
mock.reset();
}
expect(urls.filter(url => url.endsWith('ai/chat/completions'))).toHaveLength(1);
expect(urls.filter(url => url.includes('responses'))).toHaveLength(0);
});
it('should retry rate-limited requests', async () => {
const { mock, requestCount } = mockRateLimitedChatApi();
try {
@@ -87,10 +87,6 @@ export default class JoplinCloudProvider extends OpenAiCompatibleProvider {
});
}
protected override canTryResponsesApi() {
return false;
}
private async api() {
// Fetch the API from the registry, rather than caching the API locally.
// This avoids authentication drift if the user changes the sync target credentials.
@@ -1,51 +1,21 @@
import { setupDatabaseAndSynchronizer, switchClient } from '../../../testing/test-utils';
import { mockFetch, setupDatabaseAndSynchronizer, switchClient } from '../../../testing/test-utils';
import OpenAiCompatibleProvider from './OpenAiCompatible';
import { ChatMessage, ChatResult, ChatRole } from '../types';
import { ToolImageResponse } from '../tools/types';
import mockOpenAiServer, { jsonResponse } from '../testing/mockOpenAiServer';
const baseUrl = 'http://localhost:1234/v1';
import { ChatRole } from '../types';
const mockChatResponse = (message: Record<string, unknown>, finishReason: string|undefined) => {
return mockOpenAiServer(baseUrl, {
'/chat/completions': () => jsonResponse({
return mockFetch(() => {
return new Response(JSON.stringify({
choices: [{ message, finish_reason: finishReason }],
usage: { prompt_tokens: 10, completion_tokens: 20 },
}),
}), { status: 200 });
});
};
const responsesBody = (output: unknown[], extra: Record<string, unknown> = {}) => {
return {
status: 'completed',
output,
usage: { input_tokens: 10, output_tokens: 20 },
...extra,
};
};
const mockResponsesOutput = (output: unknown[], extra: Record<string, unknown> = {}) => {
return mockOpenAiServer(baseUrl, {
'/responses': () => jsonResponse(responsesBody(output, extra)),
});
};
const messageItem = (text: string) => ({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] });
const reasoningItem = { type: 'reasoning', id: 'rs_1', summary: [{ type: 'summary_text', text: 'Thinking...' }], encrypted_content: 'encrypted' };
const reasoningContentItem = { type: 'reasoning', id: 'rs_1', summary: [] as unknown[], content: [{ type: 'reasoning_text', text: 'Thinking...' }] };
const functionCallItem = { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'get_weather', arguments: '{"city":"Paris"}' };
const weatherTool = {
id: 'get_weather',
description: 'Gets the weather',
inputSchema: { type: 'object', properties: { city: { type: 'string' } } },
};
const newProvider = (model = 'test-model', apiKey = 'test-key') => {
const newProvider = () => {
return new OpenAiCompatibleProvider({
baseUrl,
apiKey,
model,
baseUrl: 'http://localhost:1234/v1',
apiKey: 'test-key',
model: 'test-model',
classification: 'local',
});
};
@@ -54,22 +24,6 @@ const chat = async () => {
return newProvider().chat([{ role: ChatRole.User, content: 'Hello' }]);
};
const toolLoopMessages = (firstResult: ChatResult, toolOutput: ChatMessage['content']): ChatMessage[] => {
return [
{ role: ChatRole.User, content: 'Weather in Paris?' },
{ role: ChatRole.Assistant, content: firstResult.text, toolCalls: firstResult.toolCalls, responsesState: firstResult.responsesState },
{
role: ChatRole.Tool,
content: toolOutput,
toolName: 'get_weather',
toolCallId: 'call_1',
isError: false,
userDescription: '',
isEdit: false,
},
];
};
describe('ai/providers/OpenAiCompatible', () => {
beforeEach(async () => {
@@ -77,10 +31,6 @@ describe('ai/providers/OpenAiCompatible', () => {
await switchClient(0);
});
afterEach(() => {
jest.restoreAllMocks();
});
test.each([
['length', 'length'],
['max_tokens', 'length'],
@@ -111,188 +61,4 @@ describe('ai/providers/OpenAiCompatible', () => {
expect(result.reasoningText).toBeUndefined();
});
it('should use the Responses API when the server supports it', async () => {
const requests = mockResponsesOutput([reasoningItem, messageItem('Hi!')]);
const provider = newProvider();
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
expect(result).toMatchObject({
text: 'Hi!',
toolCalls: [],
usage: { inputTokens: 10, outputTokens: 20 },
finishReason: 'stop',
reasoningText: 'Thinking...',
responsesState: undefined,
});
});
test.each([
['content', [reasoningContentItem, messageItem('Hi!')], 'Hi!'],
['content without a message', [reasoningContentItem], ''],
])('should read the reasoning text from %s', async (_label, output, expectedText) => {
mockResponsesOutput(output);
const result = await chat();
expect(result.text).toBe(expectedText);
expect(result.reasoningText).toBe('Thinking...');
});
test.each([
['returns 404', () => new Response('404 page not found', { status: 404 })],
// Older LM Studio versions answer unknown endpoints like this
['returns 200 without output', () => jsonResponse({ error: 'Unexpected endpoint or method. (POST /v1/responses)' })],
])('should fall back to Chat Completions once when the Responses API %s', async (_label, responsesHandler) => {
const requests = mockOpenAiServer(baseUrl, {
'/responses': responsesHandler,
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' }, finish_reason: 'stop' }] }),
});
const provider = newProvider();
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
expect(requests.map(request => request.path)).toEqual(['/responses', '/chat/completions', '/chat/completions']);
expect(result.text).toBe('OK');
});
it('should try the Responses API again when the fallback also fails', async () => {
let modelInstalled = false;
const modelNotFound = () => jsonResponse({ error: { message: 'model \'test-model\' not found' } }, 404);
const requests = mockOpenAiServer(baseUrl, {
'/responses': () => modelInstalled ? jsonResponse(responsesBody([messageItem('Hi!')])) : modelNotFound(),
'/chat/completions': modelNotFound,
});
const provider = newProvider();
await expect(provider.chat([{ role: ChatRole.User, content: 'Hello' }])).rejects.toThrow('AI provider returned 404: model \'test-model\' not found');
modelInstalled = true;
const result = await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
expect(requests.map(request => request.path)).toEqual(['/responses', '/chat/completions', '/responses']);
expect(result.text).toBe('Hi!');
});
test.each([400, 401])('should not fall back to Chat Completions on error %s', async (status) => {
const requests = mockOpenAiServer(baseUrl, {
'/responses': () => jsonResponse({ error: { message: 'Something failed' } }, status),
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' } }] }),
});
await expect(chat()).rejects.toThrow(`AI provider returned ${status}: Something failed`);
expect(requests.map(request => request.path)).toEqual(['/responses']);
});
it('should not fall back to Chat Completions after the Responses API has worked', async () => {
let responsesCallCount = 0;
const requests = mockOpenAiServer(baseUrl, {
'/responses': () => {
responsesCallCount++;
return responsesCallCount === 1 ? jsonResponse(responsesBody([messageItem('Hi!')])) : new Response('', { status: 404 });
},
'/chat/completions': () => jsonResponse({ choices: [{ message: { content: 'OK' } }] }),
});
const provider = newProvider();
await provider.chat([{ role: ChatRole.User, content: 'Hello' }]);
await expect(provider.chat([{ role: ChatRole.User, content: 'Hello' }])).rejects.toThrow('AI provider returned 404');
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
});
it('should map the request options to the Responses API', async () => {
const requests = mockResponsesOutput([messageItem('{"answer":"Paris"}')]);
await newProvider().chat([
{ role: ChatRole.System, content: 'Be brief' },
{ role: ChatRole.User, content: 'Capital of France?' },
], {
maxTokens: 100,
tools: [weatherTool],
responseFormat: { type: 'json_schema', json_schema: { name: 'answer', strict: true, schema: { type: 'object' } } },
});
expect(requests[0].body).toEqual({
model: 'test-model',
input: [
{ role: 'system', content: 'Be brief' },
{ role: 'user', content: 'Capital of France?' },
],
stream: false,
store: false,
include: ['reasoning.encrypted_content'],
max_output_tokens: 100,
text: { format: { type: 'json_schema', name: 'answer', strict: true, schema: { type: 'object' } } },
tools: [{ type: 'function', name: 'get_weather', description: 'Gets the weather', parameters: weatherTool.inputSchema }],
});
});
it('should retry without the schema when the model rejects structured output', async () => {
const requests = mockOpenAiServer(baseUrl, {
'/responses': body => body.text
? jsonResponse({ error: { message: 'Invalid parameter: \'text.format\' of type \'json_schema\' is not supported with this model.' } }, 400)
: jsonResponse(responsesBody([messageItem('Hi!')])),
});
const result = await newProvider().chat([{ role: ChatRole.User, content: 'Hello' }], {
responseFormat: { type: 'json_schema', json_schema: { name: 'answer', strict: true, schema: { type: 'object' } } },
});
expect(requests.map(request => request.path)).toEqual(['/responses', '/responses']);
expect(requests[1].body.text).toBeUndefined();
expect(result.text).toBe('Hi!');
});
test.each([
[{ status: 'completed' }, 'stop'],
[{ status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } }, 'length'],
[{ status: 'incomplete', incomplete_details: { reason: 'content_filter' } }, 'other'],
])('should map Responses API status %j to finish reason %s', async (status, expected) => {
mockResponsesOutput([messageItem('Hi!')], status);
const result = await chat();
expect(result.finishReason).toBe(expected);
});
it('should send the output items back in their original order to the same model and API key only', async () => {
const textItem = messageItem('Let me check.');
const requests = mockResponsesOutput([reasoningItem, textItem, functionCallItem]);
const provider = newProvider();
const firstResult = await provider.chat([{ role: ChatRole.User, content: 'Weather in Paris?' }], { tools: [weatherTool] });
expect(firstResult.toolCalls).toEqual([{ toolName: 'get_weather', callId: 'call_1', arguments: { city: 'Paris' }, parseError: null }]);
expect(firstResult.finishReason).toBe('tool_calls');
const messages = toolLoopMessages(firstResult, 'Sunny');
await provider.chat(messages, { tools: [weatherTool] });
const toolOutputItem = { type: 'function_call_output', call_id: 'call_1', output: 'Sunny' };
expect(requests[1].body.input).toEqual([
{ role: 'user', content: 'Weather in Paris?' },
reasoningItem,
textItem,
functionCallItem,
toolOutputItem,
]);
await newProvider('other-model').chat(messages, { tools: [weatherTool] });
await newProvider('test-model', 'other-key').chat(messages, { tools: [weatherTool] });
expect(requests).toHaveLength(4);
for (const request of requests.slice(2)) {
expect(request.body.input).toEqual([
{ role: 'user', content: 'Weather in Paris?' },
{ role: 'assistant', content: 'Let me check.' },
{ type: 'function_call', call_id: 'call_1', name: 'get_weather', arguments: '{"city":"Paris"}' },
toolOutputItem,
]);
}
});
it('should send image tool results as images', async () => {
const requests = mockResponsesOutput([functionCallItem]);
const provider = newProvider();
const firstResult = await provider.chat([{ role: ChatRole.User, content: 'Weather in Paris?' }], { tools: [weatherTool] });
const dataUrl = 'data:image/png;base64,AAAA';
const image = new ToolImageResponse({ dataUrl, mimeType: 'image/png', id: '0123456789abcdef0123456789abcdef' });
await provider.chat(toolLoopMessages(firstResult, image), { tools: [weatherTool] });
expect(requests[1].body.input).toContainEqual({
type: 'function_call_output',
call_id: 'call_1',
output: [{ type: 'input_image', image_url: dataUrl }],
});
});
});
@@ -6,9 +6,6 @@ import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatToolCall, P
import ChatProviderBase from './ChatProviderBase';
import { ToolSpec } from '../tools/types';
import extractReasoning from '../utils/extractReasoning';
import parseToolArguments from '../utils/parseToolArguments';
import { fromResponsesJson, ResponsesApiResponse, toResponsesBody } from './openAiResponses';
const md5 = require('md5');
const logger = Logger.create('OpenAiCompatibleProvider');
@@ -121,6 +118,19 @@ const convertMessage = (message: ChatMessage) => {
}
};
const describeJsonParseFailure = (rawContent: string) => {
const parseError = ['Failed to parse JSON.'];
// With certain providers (e.g. Joplin Cloud), long messages are truncated. Include this information in the error message so that
// the model knows to retry with a shorter message:
const suggestedRetryLengthLimit = 1000;
if (rawContent.startsWith('{') && rawContent.length > suggestedRetryLengthLimit) {
parseError.push(`It's likely that the tool call JSON is too long. Please try again with a message shorter than ${suggestedRetryLengthLimit} characters.`);
}
return parseError.join(' ');
};
export interface ChatRequestOptions {
signal?: AbortSignal;
}
@@ -130,8 +140,6 @@ export interface OpenAiChatResponse {
json: OpenAiResponse;
}
type ChatApi = 'responses' | 'chat-completions';
export default class OpenAiCompatibleProvider extends ChatProviderBase {
public id = 'openai-compatible';
@@ -139,7 +147,6 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
private baseUrl_: string;
private apiKey_: string;
private model_: string;
private api_: ChatApi|null = null;
public constructor(options: Options) {
super();
@@ -149,72 +156,9 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
this.classification = options.classification;
}
protected canTryResponsesApi() {
return true;
}
protected async doChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
if (!this.model_) throw new JoplinError('OpenAI-compatible provider has no model configured', 'aiProviderNotConfigured');
if (this.canTryResponsesApi() && this.api_ !== 'chat-completions') {
const result = await this.doResponsesChat(messages, options);
if (result) {
this.api_ = 'responses';
return result;
}
}
const chatCompletionsResult = await this.doChatCompletions(messages, options);
// Some servers like Ollama also return 404 when the model is not found so we only
// remember the fallback after /chat/completions works
this.api_ = 'chat-completions';
return chatCompletionsResult;
}
private async doResponsesChat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult|null> {
// Encrypted reasoning only works with the account that created it. The key is
// hashed because the replay key is saved with the chat history
const replayKey = `${this.baseUrl_}|${this.model_}|${md5(this.apiKey_)}`;
const body = toResponsesBody(this.model_, messages, options, replayKey);
let { status, json } = await this.sendResponsesRequest(body, { signal: options?.signal });
if (status === 404 && this.api_ === null) {
logger.info(`${this.baseUrl_}/responses returned 404. Using /chat/completions instead.`);
return null;
}
// Same compatibility retry as `response_format` in doChatCompletions
if (status === 400 && 'text' in body && /json_schema|response_format/i.test(json?.error?.message ?? '')) {
logger.warn(`Model ${this.model_} rejected text.format; retrying without structured output schema.`);
delete body.text;
({ status, json } = await this.sendResponsesRequest(body, { signal: options?.signal }));
}
if (status >= 400) {
const detail = json?.error?.message ? `: ${json.error.message}` : '';
throw new JoplinError(`AI provider returned ${status}${detail}`, status);
}
if (!Array.isArray(json?.output)) {
// Older LM Studio versions return 200 with an error body for unknown endpoints
if (this.api_ === null) {
logger.info(`${this.baseUrl_}/responses returned ${status} with no output. Using /chat/completions instead.`);
return null;
}
throw this.badResponseShapeError();
}
return fromResponsesJson(json, replayKey);
}
private badResponseShapeError() {
return new JoplinError(
`AI provider returned an unexpected response shape. The base URL is likely wrong — for OpenAI, Ollama, and LM Studio the URL must end with "/v1" (got ${this.baseUrl_}).`,
'aiProviderBadResponse',
);
}
private async doChatCompletions(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
const body: Record<string, unknown> = {
model: this.model_,
messages: messages.flatMap((message): unknown[] => convertMessage(message)),
@@ -266,7 +210,12 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
// is wrong (e.g. user forgot the `/v1` suffix on a local server) — many
// such servers reply 200 with an empty body rather than 404. Surface
// this rather than returning an empty string the caller can't diagnose.
if (!Array.isArray(json.choices)) throw this.badResponseShapeError();
if (!Array.isArray(json.choices)) {
throw new JoplinError(
`AI provider returned an unexpected response shape. The base URL is likely wrong — for OpenAI, Ollama, and LM Studio the URL must end with "/v1" (got ${this.baseUrl_}).`,
'aiProviderBadResponse',
);
}
const choice = json.choices?.[0];
const responseMessage = choice?.message;
@@ -280,7 +229,16 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
const toolCalls: ChatToolCall[] = (responseMessage?.tool_calls ?? []).map(call => {
if (!call.function) return null;
const { args, parseError } = parseToolArguments(call.function.arguments);
let args;
let parseError: string|null = null;
const argumentString = call.function.arguments;
try {
args = JSON.parse(argumentString);
} catch (error) {
args = {};
parseError = describeJsonParseFailure(argumentString);
logger.error('JSON parse failed', error, parseError);
}
return {
toolName: call.function.name,
@@ -299,44 +257,29 @@ export default class OpenAiCompatibleProvider extends ChatProviderBase {
};
}
private async post(path: string, body: Record<string, unknown>, options: ChatRequestOptions) {
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions): Promise<OpenAiChatResponse> {
if (!this.baseUrl_) throw new JoplinError('OpenAI-compatible provider has no base URL configured', 'aiProviderNotConfigured');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.apiKey_) headers['Authorization'] = `Bearer ${this.apiKey_}`;
const response = await shim.fetch(`${this.baseUrl_}${path}`, {
const response = await shim.fetch(`${this.baseUrl_}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: options.signal,
});
return { status: response.status, text: await response.text() };
}
protected async sendChatRequest(body: Record<string, unknown>, options: ChatRequestOptions): Promise<OpenAiChatResponse> {
const { status, text } = await this.post('/chat/completions', body, options);
const text = await response.text();
let json: OpenAiResponse;
try {
json = JSON.parse(text) as OpenAiResponse;
} catch {
throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, status);
throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, response.status);
}
return {
response: { status },
response: { status: response.status },
json,
};
}
private async sendResponsesRequest(body: Record<string, unknown>, options: ChatRequestOptions) {
const { status, text } = await this.post('/responses', body, options);
let json: ResponsesApiResponse|null = null;
try {
json = JSON.parse(text) as ResponsesApiResponse;
} catch {
if (status !== 404) throw new JoplinError(`AI provider returned non-JSON response: ${text.slice(0, 200)}`, status);
}
return { status, json };
}
}
@@ -1,123 +0,0 @@
import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall } from '../types';
import { ToolSpec } from '../tools/types';
import extractReasoning from '../utils/extractReasoning';
import parseToolArguments from '../utils/parseToolArguments';
interface ResponsesContentPart {
type: string;
text?: string;
}
interface ResponsesOutputItem {
type: string;
content?: ResponsesContentPart[];
summary?: ResponsesContentPart[];
call_id?: string;
name?: string;
arguments?: string;
}
export interface ResponsesApiResponse {
output?: ResponsesOutputItem[];
status?: string;
incomplete_details?: { reason?: string }|null;
usage?: { input_tokens?: number; output_tokens?: number };
error?: { message?: string }|null;
}
const convertTool = (tool: ToolSpec) => {
return {
type: 'function',
name: tool.id,
description: tool.description,
parameters: tool.inputSchema,
};
};
const convertMessage = (message: ChatMessage, replayKey: string): unknown[] => {
if (message.role === ChatRole.Tool) {
const content = message.content;
return [{
type: 'function_call_output',
call_id: message.toolCallId,
output: typeof content === 'string' ? content : [{ type: 'input_image', image_url: content.dataUrl }],
}];
}
if (message.responsesState?.key === replayKey) {
return message.responsesState.items;
}
const textItems = message.content || message.role !== ChatRole.Assistant ? [{ role: message.role, content: message.content }] : [];
return [
...textItems,
...(message.toolCalls ?? []).map(call => ({
type: 'function_call',
call_id: call.callId,
name: call.toolName,
arguments: JSON.stringify(call.arguments),
})),
];
};
export const toResponsesBody = (model: string, messages: ChatMessage[], options: ChatOptions|undefined, replayKey: string) => {
const body: Record<string, unknown> = {
model,
input: messages.flatMap(message => convertMessage(message, replayKey)),
stream: false,
store: false,
include: ['reasoning.encrypted_content'],
};
if (options?.temperature !== undefined) body.temperature = options.temperature;
if (options?.maxTokens !== undefined) body.max_output_tokens = options.maxTokens;
if (options?.responseFormat !== undefined) body.text = { format: { type: 'json_schema', ...options.responseFormat.json_schema } };
if (options?.tools?.length) body.tools = options.tools.map(convertTool);
return body;
};
const toChatFinishReason = (json: ResponsesApiResponse, hasToolCalls: boolean): ChatFinishReason|undefined => {
if (json.status === 'incomplete') return json.incomplete_details?.reason === 'max_output_tokens' ? 'length' : 'other';
if (hasToolCalls) return 'tool_calls';
if (json.status === 'completed') return 'stop';
return json.status ? 'other' : undefined;
};
const partsText = (parts: ResponsesContentPart[], type: string) => {
return parts.filter(part => part.type === type).map(part => part.text ?? '');
};
export const fromResponsesJson = (json: ResponsesApiResponse, replayKey: string): ChatResult => {
const output = json.output ?? [];
const rawText = partsText(output.filter(item => item.type === 'message').flatMap(item => item.content ?? []), 'output_text').join('');
const { text, reasoning: inlineReasoning } = extractReasoning(rawText);
const reasoningItems = output.filter(item => item.type === 'reasoning');
const summaryText = partsText(reasoningItems.flatMap(item => item.summary ?? []), 'summary_text').join('\n\n');
// LM Studio sends the reasoning in `content` instead of `summary`
const reasoningContentText = partsText(reasoningItems.flatMap(item => item.content ?? []), 'reasoning_text').join('\n\n');
const toolCalls: ChatToolCall[] = output.filter(item => item.type === 'function_call').map(call => {
const { args, parseError } = parseToolArguments(call.arguments ?? '');
return {
toolName: call.name,
callId: call.call_id,
arguments: args,
parseError,
};
});
const replayItems = output.filter(item => item.type === 'reasoning' || item.type === 'message' || item.type === 'function_call');
return {
text,
toolCalls,
usage: {
inputTokens: json.usage?.input_tokens ?? 0,
outputTokens: json.usage?.output_tokens ?? 0,
},
finishReason: toChatFinishReason(json, toolCalls.length > 0),
reasoningText: summaryText || reasoningContentText || inlineReasoning || undefined,
responsesState: toolCalls.length ? { key: replayKey, items: replayItems } : undefined,
};
};
@@ -1,27 +0,0 @@
import shim from '../../../shim';
type RequestBody = Record<string, unknown>;
type RequestHandler = (body: RequestBody)=> Response;
export interface MockServerRequest {
path: string;
body: RequestBody;
}
export const jsonResponse = (json: unknown, status = 200) => {
return new Response(JSON.stringify(json), { status });
};
const mockOpenAiServer = (baseUrl: string, handlers: Record<string, RequestHandler>) => {
const requests: MockServerRequest[] = [];
jest.spyOn(shim, 'fetch').mockImplementation(async (url, options) => {
const path = url.substring(baseUrl.length);
const body: RequestBody = JSON.parse(options.body);
requests.push({ path, body });
const handler = handlers[path];
return handler ? handler(body) : new Response('404 page not found', { status: 404 });
});
return requests;
};
export default mockOpenAiServer;
-7
View File
@@ -15,7 +15,6 @@ export interface ChatStandardMessage extends ChatBaseMessage {
content: string;
hide?: boolean;
toolCalls?: ChatToolCall[];
responsesState?: ResponsesReplayState;
}
export interface ChatToolMessage extends ChatBaseMessage {
@@ -68,11 +67,6 @@ export interface ChatToolCall {
parseError: string|null;
}
export interface ResponsesReplayState {
key: string;
items: unknown[];
}
export type ChatFinishReason = 'stop' | 'length' | 'tool_calls' | 'other';
export interface ChatResult {
@@ -85,7 +79,6 @@ export interface ChatResult {
// Model reasoning, from a dedicated provider field or extracted from inline
// markers. Kept separate so it doesn't pollute the assistant reply.
reasoningText?: string;
responsesState?: ResponsesReplayState;
// Joplin Cloud degradation / budget signals. Populated only by the
// joplin-cloud provider; other providers leave them undefined. Consumed
// internally to drive the aiStatus Redux slice — plugins receive only
@@ -1,29 +0,0 @@
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('ai/parseToolArguments');
const describeJsonParseFailure = (rawContent: string) => {
const parseError = ['Failed to parse JSON.'];
// With certain providers (e.g. Joplin Cloud), long messages are truncated. Include this information in the error message so that
// the model knows to retry with a shorter message:
const suggestedRetryLengthLimit = 1000;
if (rawContent.startsWith('{') && rawContent.length > suggestedRetryLengthLimit) {
parseError.push(`It's likely that the tool call JSON is too long. Please try again with a message shorter than ${suggestedRetryLengthLimit} characters.`);
}
return parseError.join(' ');
};
const parseToolArguments = (argumentString: string) => {
try {
const args: Record<string, unknown> = JSON.parse(argumentString);
return { args, parseError: null as string|null };
} catch (error) {
const parseError = describeJsonParseFailure(argumentString);
logger.error('JSON parse failed', error, parseError);
return { args: {}, parseError };
}
};
export default parseToolArguments;
+12 -2
View File
@@ -10,6 +10,7 @@ import Logger from '@joplin/utils/Logger';
import RepositoryApi from './RepositoryApi';
import { produce } from 'immer';
import { PluginManifest } from './utils/types';
import { BlockedPluginVersions, isRepoManifestBlocked, isVersionBlocked } from './utils/blockedVersions';
import isCompatible from './utils/isCompatible';
import { AppType } from './api/types';
import minVersionForPlatform from './utils/isCompatible/minVersionForPlatform';
@@ -511,7 +512,9 @@ export default class PluginService extends BaseService {
// Load only the manifest first to check if the plugin is
// enabled before doing the expensive full load.
let plugin = await this.loadPluginFromPath(pluginPath, true);
const enabled = this.pluginEnabled(settings, plugin.id);
const blocked = this.isBlocked(plugin.manifest);
if (blocked) logger.warn(`Plugin version has been blocked: ${plugin.id}@${plugin.manifest.version}`);
const enabled = this.pluginEnabled(settings, plugin.id) && !blocked;
if (enabled) {
logger.info(`Loading full plugin: ${plugin.id}`);
plugin = await this.loadPluginFromPath(pluginPath, false);
@@ -583,8 +586,13 @@ export default class PluginService extends BaseService {
return shim.mobilePlatform() ? AppType.Mobile : AppType.Desktop;
}
public isBlocked(manifest: PluginManifest) {
const blockedVersions: BlockedPluginVersions = Setting.value('plugins.blockedVersions');
return isRepoManifestBlocked(manifest) || isVersionBlocked(blockedVersions[manifest.id], manifest.version);
}
public isCompatible(manifest: PluginManifest): boolean {
return isCompatible(this.appVersion_, this.appType_, manifest);
return isCompatible(this.appVersion_, this.appType_, manifest) && !this.isBlocked(manifest);
}
private validateManifest(manifest: unknown): void {
@@ -600,6 +608,8 @@ export default class PluginService extends BaseService {
return _('Invalid plugin manifest: %s', message);
}
if (this.isBlocked(manifest)) return _('This version of the plugin has been disabled by the Joplin team for security reasons.');
if (this.isCompatible(manifest)) return null;
const minVersion = minVersionForPlatform(this.appType_, manifest);
+11 -4
View File
@@ -4,6 +4,7 @@ import { PluginManifest } from './utils/types';
const md5 = require('md5');
import { compareVersions } from 'compare-versions';
import isCompatible from './utils/isCompatible';
import { isRepoManifestBlocked } from './utils/blockedVersions';
import { AppType } from '../../models/Setting';
const logger = Logger.create('RepositoryApi');
@@ -92,6 +93,14 @@ export default class RepositoryApi {
}
public async initialize() {
await this.initializeManifestsOnly();
await this.loadRelease();
this.lastInitializedTime_ = Date.now();
}
// Skips the GitHub release API call, which is rate-limited and only needed to download plugins
public async initializeManifestsOnly() {
// https://github.com/joplin/plugins
// https://api.github.com/repos/joplin/plugins/releases
this.githubApiUrl_ = this.baseUrl_.replace(/^(https:\/\/)(github\.com\/)(.*)$/, '$1api.$2repos/$3');
@@ -102,9 +111,6 @@ export default class RepositoryApi {
this.isUsingDefaultContentUrl_ = this.contentBaseUrl_ === defaultContentBaseUrl;
await this.loadManifests();
await this.loadRelease();
this.lastInitializedTime_ = Date.now();
}
public async reinitialize() {
@@ -245,6 +251,7 @@ export default class RepositoryApi {
const manifest = manifests.find(m => m.id === pluginId);
if (!manifest) throw new Error(`No manifest for plugin ID "${pluginId}"`);
if (this.isBlockedByInstallMode(manifest)) throw new Error(`Plugin is blocked by intstallation policy. ID "${pluginId}"`);
if (isRepoManifestBlocked(manifest)) throw new Error(`This version of the plugin has been blocked: ${pluginId}@${manifest.version}`);
const fileUrl = this.assetFileUrl(manifest.id); // this.repoFileUrl(`plugins/${manifest.id}/plugin.jpl`);
const hash = md5(Date.now() + Math.random());
@@ -284,7 +291,7 @@ export default class RepositoryApi {
if (!manifest) return false;
const supportsCurrentAppVersion = compareVersions(installedVersion, manifest.version) < 0 && isCompatible(this.appVersion_, this.appType_, manifest);
return supportsCurrentAppVersion && !this.isBlockedByInstallMode(manifest);
return supportsCurrentAppVersion && !this.isBlockedByInstallMode(manifest) && !isRepoManifestBlocked(manifest);
}
}
@@ -254,4 +254,34 @@ describe('loadPlugins', () => {
// The bad plugin should not be running
expect(failingPluginRunner.runningPluginIds).not.toContain(badPluginId);
});
test('should stop and not run blocked plugin versions', async () => {
const blockedId = 'joplin.test.plugin.blocked';
const otherId = 'joplin.test.plugin.other';
await createTestPlugin({ ...defaultManifestProperties, id: blockedId, name: 'Blocked' });
await createTestPlugin({ ...defaultManifestProperties, id: otherId, name: 'Other' });
const pluginRunner = new MockPluginRunner();
const loadPluginsOptions: LoadPluginsProps = {
pluginRunner,
pluginSettings: Setting.value('plugins.states'),
platformImplementation,
store: createMockReduxStore(),
reloadAll: false,
cancelEvent: { cancelled: false },
};
await loadPlugins(loadPluginsOptions);
await pluginRunner.waitForAllToBeRunning([blockedId, otherId]);
Setting.setValue('plugins.blockedVersions', { [blockedId]: [defaultManifestProperties.version] });
await withWarningSilenced(/Plugin version has been blocked/, async () => {
await loadPlugins(loadPluginsOptions);
});
expect(pluginRunner.runningPluginIds).toEqual([otherId]);
const blockedManifest = PluginService.instance().pluginById(blockedId).manifest;
expect(PluginService.instance().isCompatible(blockedManifest)).toBe(false);
expect(PluginService.instance().describeIncompatibility(blockedManifest)).toContain('security reasons');
});
});
@@ -0,0 +1,27 @@
import Setting from '../../models/Setting';
import shim from '../../shim';
import PluginService from './PluginService';
import RepositoryApi from './RepositoryApi';
import { blockedVersionsFromManifests } from './utils/blockedVersions';
const hasUserInstalledPlugins = async () => {
const pluginDir = Setting.value('pluginDir');
if (!(await shim.fsDriver().exists(pluginDir))) return false;
return (await shim.fsDriver().readDirStats(pluginDir)).length > 0;
};
// Returns the IDs of the running plugins that are now blocked
const refreshBlockedPluginVersions = async (repoApi: RepositoryApi) => {
if (!(await hasUserInstalledPlugins())) return [];
await repoApi.initializeManifestsOnly();
Setting.setValue('plugins.blockedVersions', blockedVersionsFromManifests(await repoApi.manifests()));
const service = PluginService.instance();
return service.pluginIds.filter(id => {
const plugin = service.pluginById(id);
return plugin.running && service.isBlocked(plugin.manifest);
});
};
export default refreshBlockedPluginVersions;
@@ -0,0 +1,35 @@
import { isValidBlockedVersionEntry, isVersionBlocked } from './blockedVersions';
describe('blockedVersions', () => {
test.each([
[['1.2.3'], '1.2.3', true],
[['1.2.3'], '1.2.4', false],
[['>=1.2.3'], '1.2.2', false],
[['>=1.2.3'], '1.2.4', true],
[['>=1.2.3'], '2.0.0', true],
[['1.2.3', '1.2.4'], '1.2.5', false],
[['*'], '0.0.1', true],
[['not a version'], '1.2.3', true],
[[], '1.2.3', false],
[undefined, '1.2.3', false],
])('should tell if a version is blocked (%j, %s)', (blockedVersions, version, expected) => {
expect(isVersionBlocked(blockedVersions, version)).toBe(expected);
});
test.each([
['*', true],
['1.2.3', true],
['>=1.2.3', true],
['>=1.2.3 <1.2.5', true],
['1.2.3 - 1.2.4', true],
['1.2', false],
['v1.2.3', false],
['>=x', false],
['', false],
[123, false],
])('should validate blocked version entries (%j)', (entry, expected) => {
expect(isValidBlockedVersionEntry(entry)).toBe(expected);
});
});
@@ -0,0 +1,48 @@
import { satisfies, validateStrict } from 'compare-versions';
import { PluginManifest } from './types';
// Maps a plugin ID to its blocked versions. Each entry is "*", an exact version, or a semver range
// such as ">=1.2.3", so that versions published by an attacker after the block are also covered.
export type BlockedPluginVersions = Record<string, string[]>;
const entryMatches = (entry: string, version: string) => {
if (entry === '*') return true;
try {
return satisfies(version, entry);
} catch (error) {
// Fail closed: a malformed entry still blocks the plugin
return true;
}
};
export const isVersionBlocked = (blockedVersions: string[] | undefined, version: string) => {
if (!Array.isArray(blockedVersions)) return false;
return blockedVersions.some(entry => entryMatches(entry, version));
};
export const isValidBlockedVersionEntry = (entry: unknown) => {
if (typeof entry !== 'string') return false;
if (entry === '*' || validateStrict(entry)) return true;
// Partial versions such as "1.2" are accepted by satisfies() but silently match nothing
if (!/[<>=^~|]|\s-\s/.test(entry)) return false;
try {
satisfies('0.0.0', entry);
return true;
} catch (error) {
return false;
}
};
export const isRepoManifestBlocked = (manifest: PluginManifest) => {
return isVersionBlocked(manifest._blocked_versions, manifest.version);
};
export const blockedVersionsFromManifests = (manifests: PluginManifest[]) => {
const output: BlockedPluginVersions = {};
for (const manifest of manifests) {
if (Array.isArray(manifest._blocked_versions) && manifest._blocked_versions.length) {
output[manifest.id] = manifest._blocked_versions.filter(v => typeof v === 'string');
}
}
return output;
};
@@ -40,4 +40,5 @@ export interface PluginManifest {
_npm_package_name?: string;
_obsolete?: boolean;
_recommended?: boolean;
_blocked_versions?: string[];
}
+1 -2
View File
@@ -36,7 +36,6 @@ describe('compactCss', () => {
])('should compact many %s quickly', (_case, css) => {
const startTime = Date.now();
compactCss(css);
// Takes ~50ms normally. A quadratic implementation would take minutes, so a generous limit avoids failing on a loaded CI machine.
expect(Date.now() - startTime).toBeLessThan(10_000);
expect(Date.now() - startTime).toBeLessThan(1000);
});
});
+1 -1
View File
@@ -44,7 +44,7 @@
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.1.2",
"@fortawesome/free-solid-svg-icons": "6.1.2",
"@fortawesome/react-fontawesome": "3.4.0",
"@fortawesome/react-fontawesome": "3.3.1",
"@joplin/lib": "~3.7",
"async-mutex": "0.5.0",
"pdfjs-dist": "2.16.105",
@@ -1,4 +1,4 @@
import { applyManifestOverrides, getObsoleteManifests, getSupersededPackages, ManifestOverrides } from './overrideUtils';
import { applyManifestOverrides, getObsoleteManifests, getSupersededPackages, ManifestOverrides, validateManifestOverrides } from './overrideUtils';
describe('overrideUtils', () => {
@@ -104,4 +104,18 @@ describe('overrideUtils', () => {
const supersededPackages = getSupersededPackages(manifestOverrides);
expect(supersededPackages).toMatchObject(['joplin-plugin-benji-favorites']);
});
test.each([
[['>=1.2.3', '1.0.0'], true],
[['1.2'], false],
[[] as string[], false],
['1.2.3', false],
])('should validate blocked versions (%j)', (blockedVersions, isValid) => {
const overrides = { 'org.joplinapp.plugins.ToggleSidebars': { _blocked_versions: blockedVersions } } as ManifestOverrides;
if (isValid) {
expect(() => validateManifestOverrides(overrides)).not.toThrow();
} else {
expect(() => validateManifestOverrides(overrides)).toThrow('Invalid _blocked_versions');
}
});
});
+15 -1
View File
@@ -1,11 +1,13 @@
import * as path from 'path';
import { readJsonFile } from './utils';
import { PluginManifest } from '@joplin/lib/services/plugins/utils/types';
import { isValidBlockedVersionEntry } from '@joplin/lib/services/plugins/utils/blockedVersions';
export interface ManifestOverride {
_obsolete?: boolean;
_recommended?: boolean;
_superseded_package?: string;
_blocked_versions?: string[];
}
export type ManifestOverrides = Record<string, ManifestOverride>;
@@ -49,8 +51,20 @@ function pluginManifestOverridesPath(repoDir: string): string {
return path.resolve(repoDir, 'manifestOverrides.json');
}
export const validateManifestOverrides = (overrides: ManifestOverrides) => {
for (const [pluginId, override] of Object.entries(overrides)) {
const blockedVersions = override._blocked_versions;
if (blockedVersions === undefined) continue;
if (!Array.isArray(blockedVersions) || !blockedVersions.length || !blockedVersions.every(isValidBlockedVersionEntry)) {
throw new Error(`Invalid _blocked_versions for plugin ${pluginId}: ${JSON.stringify(blockedVersions)}. It must be a non-empty array of "*", exact versions or semver ranges.`);
}
}
};
export async function readManifestOverrides(repoDir: string): Promise<ManifestOverrides> {
return readJsonFile(pluginManifestOverridesPath(repoDir), {});
const overrides: ManifestOverrides = await readJsonFile(pluginManifestOverridesPath(repoDir), {});
validateManifestOverrides(overrides);
return overrides;
}
export const getSupersededPackages = (manifestOverrides: ManifestOverrides): string[] => {
@@ -105,6 +105,19 @@ describe('validateUntrustedManifest', () => {
).toThrow('mark itself as recommended');
});
test('should not allow plugin authors to set their own blocked versions', () => {
const newManifest = {
id: 'joplin-plugin.this.is.another.test',
_blocked_versions: [] as string[],
_npm_package_name: 'joplin-plugin-another-test',
version: '1.2.3',
};
expect(
() => validateUntrustedManifest(newManifest as unknown as PluginManifest, originalManifests, { source: 'npm' }),
).toThrow('cannot set its own blocked versions');
});
test('should not allow changing the NPM package for an existing plugin', () => {
const newManifest = {
id: 'joplin-plugin.this.is.a.test',
@@ -26,6 +26,10 @@ const validateUntrustedManifest = (
throw new Error(`Plugin ${manifest.id} cannot mark itself as recommended.`);
}
if (typeof manifest._blocked_versions !== 'undefined') {
throw new Error(`Plugin ${manifest.id} cannot set its own blocked versions.`);
}
if (options.source === 'repository') {
if (!manifest.repository_url?.trim()) {
throw new Error(`Plugin ${manifest.id} must specify repository_url when published from a repository.`);
+1 -1
View File
@@ -84,7 +84,7 @@
"jest": "29.7.0",
"jest-expect-message": "1.1.3",
"jsdom": "26.1.0",
"node-mocks-http": "1.18.1",
"node-mocks-http": "1.17.2",
"typescript": "5.9.3"
}
}
File diff suppressed because it is too large. Load diff
+29 -29
View File
@@ -1365,7 +1365,7 @@ msgid ""
"Cannot share encrypted notebook with recipient %s because they have not enabled end-to-end "
"encryption. They may do so from the screen Configuration > Encryption."
msgstr ""
"Ne eblas kunhavigi ĉifritan notlibron kun ricevonto %s, ĉar tiu ne ŝaltis ĉifradon de fino al "
"Ne eblas kunhavigi ĉifritan notlibron kun ricevonto %s, ĉar tiu ne ebligis ĉifradon de fino al "
"fino. Tion eblas fari en la ekrano Agordo > Ĉifrado."
#: packages/app-mobile/components/NoteEditor/SearchPanel.tsx:340
@@ -1437,7 +1437,7 @@ msgstr "Kontroli ĝisdatigojn"
#: packages/app-desktop/gui/MenuBar.tsx:598 packages/app-desktop/gui/MenuBar.tsx:923
msgid "Check for updates..."
msgstr "Kontroli ĝisdatigojn..."
msgstr "Kontrolu ĝisdatigojn..."
#: packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx:357
#: packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx:488
@@ -1504,7 +1504,7 @@ msgid ""
"Click \"%s\" to restore the note. It will be copied in the notebook named \"%s\". The current "
"version of the note will not be replaced or modified."
msgstr ""
"Alklaku \"%s\" por restaŭri la noton. Ĝi estos kopiita en la notlibron nomatan \"%s\". La nuna "
"Alklaku \"%s\" por restarigi la noton. Ĝi estos kopiita en la notlibron nomatan \"%s\". La nuna "
"versio de la noto ne estos anstataŭigita aŭ modifita."
#: packages/app-mobile/components/voiceTyping/AudioRecordingBanner.tsx:239
@@ -2441,7 +2441,7 @@ msgstr "Malŝalti la servon Web Clipper"
#: packages/app-mobile/components/screens/encryption-config.tsx:384
#: packages/lib/models/settings/builtInMetadata.ts:1845
msgid "Disabled"
msgstr "Malŝaltita"
msgstr "Malebligita"
#: packages/app-mobile/components/screens/encryption-config.tsx:353
msgid "Disabled keys"
@@ -2539,7 +2539,7 @@ msgid ""
"data! To enable encryption, please enter your password below."
msgstr ""
"Ne perdu la pasvorton, ĉar pro sekurecaj kialoj ĉi tio estos la *nura* maniero malĉifri la "
"datumojn! Por ŝalti ĉifradon, bonvolu enigi vian pasvorton sube."
"datumojn! Por ebligi ĉifradon, bonvolu enigi vian pasvorton sube."
#: packages/lib/models/settings/builtInMetadata.ts:2499
msgid "Document scanner: Title template"
@@ -2659,7 +2659,7 @@ msgstr "Dropbox"
#: packages/app-desktop/gui/Root.tsx:164
msgid "Dropbox Login"
msgstr "Ensaluto al Dropbox"
msgstr "Dropbox-salutnomo"
#: packages/app-desktop/gui/NoteListHeader/utils/getColumnTitle.ts:14
msgid "Due"
@@ -2839,7 +2839,7 @@ msgstr "Malplenigi rubujon"
#: packages/app-mobile/components/biometrics/BiometricPopup.tsx:87
#: packages/app-mobile/components/screens/encryption-config.tsx:228
msgid "Enable"
msgstr "Ŝalti"
msgstr "Ebligi"
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.tsx:175
#, python-format
@@ -2903,7 +2903,7 @@ msgstr "Permesi file://-URL-ojn por bildoj kaj videoj"
#: packages/lib/models/settings/builtInMetadata.ts:1499
msgid "Enable footnotes"
msgstr "Ŝalti piednotojn"
msgstr "Ebligi piednotojn"
#: packages/lib/models/settings/builtInMetadata.ts:1491
msgid "Enable Fountain syntax support"
@@ -2943,7 +2943,7 @@ msgstr "Ŝalti la plurtabelan Markdown-etendaĵon"
#: packages/lib/models/settings/builtInMetadata.ts:2144
msgid "Enable note history"
msgstr "Ŝalti nothistorion"
msgstr "Ebligi nothistorion"
#: packages/lib/models/Setting.ts:1415
msgid "Enable or disable plugins"
@@ -2960,7 +2960,7 @@ msgstr "Ŝalti subtenon por kromprogramoj"
#: packages/lib/models/settings/builtInMetadata.ts:1486
msgid "Enable soft breaks"
msgstr "Ŝalti molajn linisaltojn"
msgstr "Ŝalti molajn linisalton"
#: packages/lib/models/settings/builtInMetadata.ts:1927
msgid "Enable spell checking in Markdown editor"
@@ -2995,7 +2995,7 @@ msgstr "Ŝalti la servon Web Clipper"
#: packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.tsx:93
#: packages/app-mobile/components/screens/encryption-config.tsx:384
msgid "Enabled"
msgstr "Ŝaltita"
msgstr "Ebligita"
#: packages/lib/models/settings/builtInMetadata.ts:1127
msgid "Enables Markdown list continuation, auto-closing HTML tags, and other markup autocompletions."
@@ -3183,7 +3183,7 @@ msgstr "Evernote-eksportdosieroj (dosierujo, kiel Markdown)"
#: packages/app-cli/app/command-exit.ts:11
msgid "Exits the application."
msgstr "Eliras el la aplikaĵo."
msgstr "Eliri la aplikaĵon."
#: packages/app-mobile/components/side-menu-content.tsx:219
#, python-format
@@ -3455,7 +3455,7 @@ msgstr "Trovita: %d."
#: packages/app-mobile/utils/getVersionInfoText.ts:47
#, python-format
msgid "FTS enabled: %d"
msgstr "FTS ŝaltita: %d"
msgstr "FTS ebligita: %d"
#: packages/app-desktop/checkForUpdates.ts:116
msgid "Full changelog"
@@ -3620,7 +3620,7 @@ msgstr "Kaŝas averton"
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.ts:14
msgid "Highlight"
msgstr "Elstarigi"
msgstr "Emfazi"
#: packages/server/src/services/MustacheService.ts:149
#: packages/server/src/services/MustacheService.ts:282
@@ -4015,12 +4015,12 @@ msgstr "Nevalida %s: %s."
#: packages/app-cli/app/cli-utils.ts:199
#, python-format
msgid "Invalid answer: %s"
msgstr "Nevalida respondo: %s"
msgstr "Malvalida respondo: %s"
#: packages/app-cli/app/command-tag.ts:94
#, python-format
msgid "Invalid command: \"%s\""
msgstr "Nevalida komando: \"%s\""
msgstr "Malvalida komando: \"%s\""
#: packages/app-desktop/gui/NotePropertiesDialog.tsx:364
msgid "Invalid format. E.g.: 48.8581372, 2.2926735"
@@ -4275,7 +4275,7 @@ msgstr "Ŝlosiloj kiuj bezonas ĝisdatigon"
#: packages/app-desktop/gui/NoteEditor/NoteBody/WhiteboardEditor/WhiteboardSurface.tsx:494
#: packages/editor/ProseMirror/plugins/imagePlugin.ts:265
msgid "Label"
msgstr "Surskribo"
msgstr "Etikedo"
#: packages/lib/models/settings/builtInMetadata.ts:1885
msgid "Landscape"
@@ -4576,7 +4576,7 @@ msgstr "Markdown-redaktilo"
#: packages/lib/models/settings/builtInMetadata.ts:1968
msgid "Markdown editor: Highlight active line"
msgstr "Markdown-redaktilo: elstarigi aktivan linion"
msgstr "Markdown-redaktilo: emfazi aktivan linion"
#: packages/lib/models/settings/builtInMetadata.ts:1947
msgid "Markdown editor: Interactive table editing"
@@ -4584,7 +4584,7 @@ msgstr "Markdown-redaktilo: interaga tabelredaktado"
#: packages/lib/models/settings/builtInMetadata.ts:1957
msgid "Markdown editor: Render images"
msgstr "Markdown-redaktilo: montri bildojn"
msgstr "Markdown-redaktilo: bildigi bildojn"
#: packages/lib/models/settings/builtInMetadata.ts:1937
msgid "Markdown editor: Render markup in editor"
@@ -5314,7 +5314,7 @@ msgstr "OCR: Vakigi kaŝmemoron kaj reelŝuti lingvajn datumdosierojn"
#: packages/lib/models/settings/builtInMetadata.ts:573
msgid "OCR: Enable optical character recognition"
msgstr "OCR: Ŝalti optikan signorekonon"
msgstr "OCR: Ebligi optikan signorekonon"
#: packages/lib/models/settings/builtInMetadata.ts:613
msgid "OCR: Language data URL or path"
@@ -5391,7 +5391,7 @@ msgstr "OneDrive"
#: packages/app-desktop/gui/Root.tsx:163
msgid "OneDrive Login"
msgstr "Ensaluto al OneDrive"
msgstr "OneDrive-salutnomo"
#: packages/lib/services/interop/InteropService.ts:159
msgid "OneNote Notebook"
@@ -6187,11 +6187,11 @@ msgstr "Forigi %s el kunhavigo"
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:164
#, python-format
msgid "Remove tag \"%s\" from all notes?"
msgstr "Ĉu forigi etikedon \"%s\" el ĉiuj notoj?"
msgstr "Forigi etikedon \"%s\" el ĉiuj notoj?"
#: packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.tsx:169
msgid "Remove this search from the sidebar?"
msgstr "Ĉu forigi ĉi tiun serĉon el la flankbreto?"
msgstr "Forigi ĉi tiun serĉon el la flankbreto?"
#: packages/lib/services/ai/tools/buildEditorTools.ts:51
#, python-format
@@ -6379,11 +6379,11 @@ msgstr "Restarigi defaŭltojn"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreNote.ts:10
msgid "Restore note"
msgstr "Restaŭri noton"
msgstr "Restarigi noton"
#: packages/app-desktop/gui/WindowCommandsAndDialogs/commands/restoreFolder.ts:9
msgid "Restore notebook"
msgstr "Restaŭri notlibron"
msgstr "Restarigi notlibron"
#: packages/app-cli/app/command-restore.ts:12
msgid "Restore the items matching <pattern> from the trash."
@@ -6391,7 +6391,7 @@ msgstr "Restarigas la erojn kongruantajn kun <pattern> el la rubujo."
#: packages/lib/services/trash/index.ts:88
msgid "Restored items"
msgstr "Restaŭritaj eroj"
msgstr "Restarigitaj eroj"
#: packages/lib/services/RevisionService.ts:264
msgid "Restored Notes"
@@ -7229,7 +7229,7 @@ msgstr "Ŝanĝi al antaŭa fotilo"
#: packages/app-desktop/gui/utils/NoteListUtils.ts:89
msgid "Switch to note type"
msgstr "Ŝanĝi al nota tipo"
msgstr "Ŝalti al nota tipo"
#: packages/app-desktop/commands/switchProfile1.ts:7 packages/app-desktop/commands/switchProfile2.ts:7
#: packages/app-desktop/commands/switchProfile3.ts:7
@@ -7252,7 +7252,7 @@ msgstr "Ŝanĝi al la nova redaktilo"
#: packages/app-desktop/gui/utils/NoteListUtils.ts:98
msgid "Switch to to-do type"
msgstr "Ŝanĝi al taska tipo"
msgstr "Ŝalti al taska tipo"
#: packages/app-desktop/gui/ChatPanel/ChatPanel.tsx:152
#, python-format
@@ -8153,7 +8153,7 @@ msgstr "Por forigi etikedon, forigu ĝin de la asociitaj notoj."
#: packages/lib/services/ReportService.ts:367
#, python-format
msgid "To delete: %d"
msgstr "Forigotaj: %d"
msgstr "Forigi: %d"
#: packages/app-cli/app/command-help.ts:82
msgid "To enter command line mode, press \":\""
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,3 @@
---
forum_url: https://discourse.joplinapp.org/t/51403
---
# A full security review of Joplin Server and Joplin Cloud
October 2026 has been nicknamed "Black October": within weeks, critical vulnerabilities were disclosed in widely used open source software such as PHP, Node.js and GitLab, many of them found with the help of AI and exploited within a day. The same tools that help defenders find bugs now help attackers find them, and Joplin's code is public. The responsible thing to do was to get there first.
+17 -16
View File
@@ -11552,13 +11552,13 @@ __metadata:
languageName: node
linkType: hard
"@fortawesome/react-fontawesome@npm:3.4.0":
version: 3.4.0
resolution: "@fortawesome/react-fontawesome@npm:3.4.0"
"@fortawesome/react-fontawesome@npm:3.3.1":
version: 3.3.1
resolution: "@fortawesome/react-fontawesome@npm:3.3.1"
peerDependencies:
"@fortawesome/fontawesome-svg-core": ~6 || ~7
react: ^18.0.0 || ^19.0.0
checksum: 10/64e57e04a7cd98352503edc8fa890420e75d8953262363579388b718656716c10ef2652c62af76edda4d1d1b35205c80ba68af7dc7b32ee4bf0d4c7093afc69e
checksum: 10/e099a5721099f1cf59d61fa21ce0ad6473be782470781574aa3c21136263f44d067e15dae4a4e273876ca3b3f16df6a099f93a4ed9c0a717223dffa66bb57da7
languageName: node
linkType: hard
@@ -12979,7 +12979,7 @@ __metadata:
"@types/react": "npm:19.1.10"
"@types/react-dom": "npm:19.1.7"
"@types/uuid": "npm:11.0.0"
adm-zip: "npm:0.6.0"
adm-zip: "npm:0.5.18"
async-mutex: "npm:0.5.0"
base-64: "npm:1.0.0"
base64-stream: "npm:1.0.0"
@@ -13058,7 +13058,7 @@ __metadata:
dependencies:
"@fortawesome/fontawesome-svg-core": "npm:6.1.2"
"@fortawesome/free-solid-svg-icons": "npm:6.1.2"
"@fortawesome/react-fontawesome": "npm:3.4.0"
"@fortawesome/react-fontawesome": "npm:3.3.1"
"@joplin/lib": "npm:~3.7"
"@types/jest": "npm:29.5.14"
"@types/pdfjs-dist": "npm:2.10.378"
@@ -13224,7 +13224,7 @@ __metadata:
markdown-it: "npm:13.0.2"
mustache: "npm:4.2.0"
node-cron: "npm:4.5.0"
node-mocks-http: "npm:1.18.1"
node-mocks-http: "npm:1.17.2"
nodemailer: "npm:6.10.1"
nodemon: "npm:3.1.14"
otplib: "npm:12.0.1"
@@ -21414,10 +21414,10 @@ __metadata:
languageName: node
linkType: hard
"adm-zip@npm:0.6.0":
version: 0.6.0
resolution: "adm-zip@npm:0.6.0"
checksum: 10/6a49ab39055d8f716958d3a2055491ab86fe3359c4afeed9b14bbce78fb847bcd3cbaa0ff4921aaa19824aaa29d06691def1f53263db5224d06c068f922c5ba2
"adm-zip@npm:0.5.18":
version: 0.5.18
resolution: "adm-zip@npm:0.5.18"
checksum: 10/6eaa298828b92e543241fa24d01af774ad8709b80539b3fa60d4ba6f69fd5a15a19d8177e6d739f18f4e0933610ed555215dc6553b51c532534414702c255167
languageName: node
linkType: hard
@@ -44584,9 +44584,9 @@ __metadata:
languageName: node
linkType: hard
"node-mocks-http@npm:1.18.1":
version: 1.18.1
resolution: "node-mocks-http@npm:1.18.1"
"node-mocks-http@npm:1.17.2":
version: 1.17.2
resolution: "node-mocks-http@npm:1.17.2"
dependencies:
accepts: "npm:^1.3.7"
content-disposition: "npm:^0.5.3"
@@ -44595,6 +44595,7 @@ __metadata:
merge-descriptors: "npm:^1.0.1"
methods: "npm:^1.1.2"
mime: "npm:^1.3.4"
parseurl: "npm:^1.3.3"
range-parser: "npm:^1.2.0"
type-is: "npm:^1.6.18"
peerDependencies:
@@ -44605,7 +44606,7 @@ __metadata:
optional: true
"@types/node":
optional: true
checksum: 10/7a7062de9b16c1ff8e90cd2a824fd74ab4e0c1552837ead87d54e4bc821324c1287a84aee6394d6c229c4fdc64bd2c249ce3d17b9e8e102d0f5142fe8e332958
checksum: 10/3ab97b5a7b2dba0032fc5090983e4ca4be0597f1c645b34dff99d465feb17ef1ee7ea4829a9b4303495399c1ac0f12f8964b878e0dda43cda798bb97254ad571
languageName: node
linkType: hard
@@ -46463,7 +46464,7 @@ __metadata:
languageName: node
linkType: hard
"parseurl@npm:^1.3.2, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
"parseurl@npm:^1.3.2, parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3":
version: 1.3.3
resolution: "parseurl@npm:1.3.3"
checksum: 10/407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2