feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,395 @@
|
||||
import { net, Session } from 'electron';
|
||||
import { dirname, resolve, normalize } from 'path';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import { contentProtocolName, pluginProtocolName } from './constants';
|
||||
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
||||
import * as fs from 'fs-extra';
|
||||
import { createReadStream } from 'fs';
|
||||
import { fromFilename } from '@joplin/lib/mime-utils';
|
||||
import { createSecureRandom } from '@joplin/lib/uuid';
|
||||
|
||||
export interface AccessController {
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export interface CustomContentProtocolHandler {
|
||||
// note-viewer/ URLs
|
||||
allowReadAccessToDirectory(path: string): void;
|
||||
allowReadAccessToFile(path: string): AccessController;
|
||||
allowReadAccessToFiles(paths: string[]): AccessController;
|
||||
|
||||
// file-media/ URLs
|
||||
setMediaAccessEnabled(enabled: boolean): void;
|
||||
getMediaAccessKey(): string;
|
||||
}
|
||||
|
||||
export interface ContentScriptRegistration {
|
||||
uri: string;
|
||||
revoke: ()=> void;
|
||||
}
|
||||
|
||||
export interface CustomPluginProtocolHandler {
|
||||
registerContentScript(id: string, js: string): ContentScriptRegistration;
|
||||
}
|
||||
|
||||
export interface CustomProtocolHandlers {
|
||||
appContent: CustomContentProtocolHandler;
|
||||
pluginContent: CustomPluginProtocolHandler;
|
||||
}
|
||||
|
||||
|
||||
// In some cases, the NodeJS built-in adapter (Readable.toWeb) closes its controller twice,
|
||||
// leading to an error dialog. See:
|
||||
// - https://github.com/nodejs/node/blob/e578c0b1e8d3dd817e692a0c5df1b97580bc7c7f/lib/internal/webstreams/adapters.js#L454
|
||||
// - https://github.com/nodejs/node/issues/54205
|
||||
// We work around this by creating a more-error-tolerant custom adapter.
|
||||
const nodeStreamToWeb = (resultStream: fs.ReadStream) => {
|
||||
resultStream.pause();
|
||||
|
||||
let closed = false;
|
||||
|
||||
return new ReadableStream({
|
||||
start: (controller) => {
|
||||
resultStream.on('data', (chunk) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(chunk)) {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
} else {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
|
||||
if (controller.desiredSize <= 0) {
|
||||
resultStream.pause();
|
||||
}
|
||||
});
|
||||
|
||||
resultStream.on('error', (error) => {
|
||||
controller.error(error);
|
||||
});
|
||||
|
||||
resultStream.on('end', () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
pull: (_controller) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
resultStream.resume();
|
||||
},
|
||||
cancel: () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
resultStream.close();
|
||||
}
|
||||
},
|
||||
}, { highWaterMark: resultStream.readableHighWaterMark });
|
||||
};
|
||||
|
||||
// Allows seeking videos.
|
||||
// See https://github.com/electron/electron/issues/38749 for why this is necessary.
|
||||
const handleRangeRequest = async (request: Request, targetPath: string) => {
|
||||
const makeUnsupportedRangeResponse = () => {
|
||||
return new Response('unsupported range', {
|
||||
status: 416, // Range Not Satisfiable
|
||||
});
|
||||
};
|
||||
|
||||
const rangeHeader = request.headers.get('Range');
|
||||
if (!rangeHeader.startsWith('bytes=')) {
|
||||
return makeUnsupportedRangeResponse();
|
||||
}
|
||||
|
||||
const stat = await fs.stat(targetPath);
|
||||
// Ranges are requested using one of the following formats
|
||||
// bytes=1234-5679
|
||||
// bytes=-5678
|
||||
// bytes=1234-
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
|
||||
const startByte = Number(rangeHeader.match(/(\d+)-/)?.[1] || '0');
|
||||
const endByte = Number(rangeHeader.match(/-(\d+)/)?.[1] || `${stat.size - 1}`);
|
||||
|
||||
if (endByte > stat.size || startByte < 0) {
|
||||
return makeUnsupportedRangeResponse();
|
||||
}
|
||||
|
||||
// Note: end is inclusive.
|
||||
const resultStream = createReadStream(targetPath, { start: startByte, end: endByte });
|
||||
|
||||
// See the HTTP range requests guide: https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
|
||||
const headers = new Headers([
|
||||
['Accept-Ranges', 'bytes'],
|
||||
['Content-Type', fromFilename(targetPath)],
|
||||
['Content-Length', `${endByte + 1 - startByte}`],
|
||||
['Content-Range', `bytes ${startByte}-${endByte}/${stat.size}`],
|
||||
]);
|
||||
|
||||
|
||||
return new Response(
|
||||
nodeStreamToWeb(resultStream),
|
||||
{ headers, status: 206 },
|
||||
);
|
||||
};
|
||||
|
||||
const makeAccessDeniedResponse = (message: string) => {
|
||||
return new Response(message, {
|
||||
status: 403, // Forbidden
|
||||
});
|
||||
};
|
||||
|
||||
const makeNotFoundResponse = () => {
|
||||
return new Response('not found', {
|
||||
status: 404,
|
||||
});
|
||||
};
|
||||
|
||||
type LoggerSlice = {
|
||||
debug: (...message: unknown[])=> void;
|
||||
};
|
||||
|
||||
const handleContentProtocol = (logger: LoggerSlice, session: Session) => {
|
||||
// Allow-listed files/directories for joplin-content://note-viewer/
|
||||
const readableDirectories: string[] = [];
|
||||
const readableFiles = new Map<string, number>();
|
||||
// Access for joplin-content://file-media/
|
||||
let mediaAccessKey: string|false = false;
|
||||
|
||||
const appBundleDirectory = dirname(dirname(__dirname));
|
||||
|
||||
// Serves main app content (e.g. the note viewer)
|
||||
session.protocol.handle(contentProtocolName, async request => {
|
||||
const url = new URL(request.url);
|
||||
const host = url.host;
|
||||
|
||||
let pathname = normalize(fileURLToPath(`file://${url.pathname}`));
|
||||
|
||||
// See https://security.stackexchange.com/a/123723
|
||||
if (pathname.startsWith('..')) {
|
||||
return new Response('Invalid URL (not absolute)', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
pathname = resolve(appBundleDirectory, pathname);
|
||||
|
||||
let canRead = false;
|
||||
let mediaOnly = true;
|
||||
if (host === 'note-viewer' || host === 'plugin-webview') {
|
||||
if (readableFiles.has(pathname)) {
|
||||
canRead = true;
|
||||
} else {
|
||||
for (const readableDirectory of readableDirectories) {
|
||||
if (resolvePathWithinDir(readableDirectory, pathname)) {
|
||||
canRead = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mediaOnly = false;
|
||||
} else if (host === 'file-media') {
|
||||
if (!mediaAccessKey) {
|
||||
return makeAccessDeniedResponse('Media access denied. This must be enabled with .setMediaAccessEnabled');
|
||||
}
|
||||
|
||||
canRead = true;
|
||||
mediaOnly = true;
|
||||
|
||||
const accessKey = url.searchParams.get('access-key');
|
||||
if (accessKey !== mediaAccessKey) {
|
||||
return makeAccessDeniedResponse('Invalid or missing media access key. An allow-listed ?access-key= parameter must be provided.');
|
||||
}
|
||||
} else {
|
||||
return new Response(`Invalid request URL (${request.url})`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!canRead) {
|
||||
return makeAccessDeniedResponse(`Read access not granted for URL (${request.url})`);
|
||||
}
|
||||
|
||||
const asFileUrl = pathToFileURL(pathname).toString();
|
||||
logger.debug('protocol handler: Fetch file URL', asFileUrl);
|
||||
|
||||
const rangeHeader = request.headers.get('Range');
|
||||
let response;
|
||||
try {
|
||||
if (!rangeHeader) {
|
||||
response = await net.fetch(asFileUrl);
|
||||
} else {
|
||||
response = await handleRangeRequest(request, pathname);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
// Errors from NodeJS fs methods (e.g. fs.stat()
|
||||
error.code === 'ENOENT'
|
||||
// Errors from Electron's net.fetch(). Use error.message since these errors don't
|
||||
// seem to have a specific .code or .name.
|
||||
|| error.message === 'net::ERR_FILE_NOT_FOUND'
|
||||
) {
|
||||
response = makeNotFoundResponse();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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)\//)) {
|
||||
return makeAccessDeniedResponse(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
const result: CustomContentProtocolHandler = {
|
||||
allowReadAccessToDirectory: (path: string) => {
|
||||
path = resolve(appBundleDirectory, path);
|
||||
logger.debug('protocol handler: Allow read access to directory', path);
|
||||
|
||||
readableDirectories.push(path);
|
||||
},
|
||||
allowReadAccessToFile: (path: string) => {
|
||||
path = resolve(appBundleDirectory, path);
|
||||
logger.debug('protocol handler: Allow read access to file', path);
|
||||
|
||||
if (readableFiles.has(path)) {
|
||||
readableFiles.set(path, readableFiles.get(path) + 1);
|
||||
} else {
|
||||
readableFiles.set(path, 1);
|
||||
}
|
||||
|
||||
return {
|
||||
remove: () => {
|
||||
if ((readableFiles.get(path) ?? 0) <= 1) {
|
||||
logger.debug('protocol handler: Remove read access to file', path);
|
||||
readableFiles.delete(path);
|
||||
} else {
|
||||
readableFiles.set(path, readableFiles.get(path) - 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
allowReadAccessToFiles: (paths: string[]) => {
|
||||
const handles = paths.map(path => {
|
||||
return result.allowReadAccessToFile(path);
|
||||
});
|
||||
return {
|
||||
remove: () => {
|
||||
for (const handle of handles) {
|
||||
handle.remove();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
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;
|
||||
},
|
||||
};
|
||||
return result;
|
||||
};
|
||||
|
||||
const handlePluginProtocol = (logger: LoggerSlice, session: Session) => {
|
||||
const hostedContentScriptJs = new Map<string, string>(); // Maps from content script IDs to content script data
|
||||
session.protocol.handle(pluginProtocolName, async request => {
|
||||
const url = new URL(request.url);
|
||||
const host = url.host;
|
||||
|
||||
if (host !== 'plugins') {
|
||||
return new Response('Unknown hostname', { status: 400 });
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return new Response('Unsupported request method', { status: 405 });
|
||||
}
|
||||
|
||||
const contentScriptPath = '/content-script/';
|
||||
if (url.pathname.startsWith(contentScriptPath)) {
|
||||
const contentScriptId = url.pathname.substring(contentScriptPath.length);
|
||||
logger.debug('Request for content script with ID', contentScriptId);
|
||||
|
||||
const js = hostedContentScriptJs.get(contentScriptId);
|
||||
if (!js) {
|
||||
return new Response('Content script not found', { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(js, {
|
||||
headers: [
|
||||
['Content-Type', 'application/javascript'],
|
||||
],
|
||||
});
|
||||
} else {
|
||||
return new Response('Path not found', { status: 404 });
|
||||
}
|
||||
});
|
||||
|
||||
const handler: CustomPluginProtocolHandler = {
|
||||
// Hosts a content script with the given `js` using the plugin protocol.
|
||||
// This can be used to allow loading trusted plugin JavaScript without relying on eval,
|
||||
// inline scripts, or other techniques that would violate the application's
|
||||
// Content-Security-Policy.
|
||||
//
|
||||
// Caution: This assumes that `js` is provided by a trusted source. Be careful
|
||||
// when building/providing `js` to this function.
|
||||
registerContentScript: (id: string, js: string) => {
|
||||
id = encodeURIComponent(id);
|
||||
logger.debug('Registering content script with ID', id);
|
||||
|
||||
hostedContentScriptJs.set(id, js);
|
||||
|
||||
return {
|
||||
uri: `${pluginProtocolName}://plugins/content-script/${id}`,
|
||||
revoke: () => {
|
||||
hostedContentScriptJs.delete(id);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return handler;
|
||||
};
|
||||
|
||||
// Creating a custom protocol allows us to isolate iframes by giving them
|
||||
// different domain names from the main Joplin app.
|
||||
//
|
||||
// For example, an iframe with url joplin-content://note-viewer/path/to/iframe.html will run
|
||||
// in a different process from a parent frame with url file://path/to/iframe.html.
|
||||
//
|
||||
// See note_viewer_isolation.md for why this is important.
|
||||
//
|
||||
// See also the protocol.handle example: https://www.electronjs.org/docs/latest/api/protocol#protocolhandlescheme-handler
|
||||
//
|
||||
const handleCustomProtocols = (session: Session): CustomProtocolHandlers => {
|
||||
const logger = {
|
||||
// Disabled for now
|
||||
debug: (..._message: unknown[]) => {},
|
||||
};
|
||||
|
||||
const appContent = handleContentProtocol(logger, session);
|
||||
const pluginContent = handlePluginProtocol(logger, session);
|
||||
return { appContent, pluginContent };
|
||||
};
|
||||
|
||||
export default handleCustomProtocols;
|
||||
在新工单中引用
屏蔽一个用户