feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -24,6 +24,19 @@ Plugins can inspect the current interaction context through `api.context.getCurr
Plugins can add quick actions to the server sidebar's View plugins menu with `api.ui.registerToolbarAction(id, { icon, label, run })`. The menu is rendered from the room side-panel plugin area as an overlay grid, and callbacks receive a `toolbarAction` interaction context.
## Trust model
Plugins run in the product-client renderer with the same origin and capability surface as the host app. Process isolation (separate `BrowserView` or worker sandboxes) is intentionally out of scope because the runtime loads entrypoints through dynamic `import()`.
Remote plugin fetches are constrained as follows:
- Store and host installs require **HTTPS** entrypoints and bundle URLs; `file://` fetches from the renderer are blocked.
- When a source manifest entry or cached bundle declares `bundle.integrity` (SHA-256), `PluginHostService` verifies the digest before `import()`.
- Desktop local plugins and cached bundles continue to load from Electron-controlled paths under app data (`plugins/`, `plugin-bundles/`).
- Capability grants remain user-consented; integrity checks do not replace the existing capability model.
Treat third-party plugin code as trusted only after the user installs it and grants the declared capabilities.
Plugins can register `/` slash commands with `api.commands.register(id, { name, description, icon, options, scope, run })` (capability `ui.commands`). A command's `scope` is `global` (default — available in chat servers and direct messages) or `server` (only while a chat server is the active surface). The chat composer renders a Discord-style autocomplete menu when the user types `/`: results come from `PluginUiRegistryService.slashCommandRecords` filtered by surface via `selectAvailableSlashCommands` and by query via `filterSlashCommands` (both in `domain/logic/slash-command.rules.ts`). Picking a command (click, Enter, or Tab) either runs it immediately when it declares no options, or fills `/name ` so the user can type arguments before sending. On submit, `parseSlashCommandInput` + `findSlashCommand` resolve the command, `parseSlashCommandArguments` maps positional tokens (or a single `rest` option) to `args`, and `PluginClientApiService.createSlashCommandContext` builds a `slashCommand`-source context. Slash command input is intercepted in the composer and never sent as a chat message; unmatched `/text` falls through to a normal message. `api.commands.list()` returns every registered command across plugins.
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.

View File

@@ -30,6 +30,8 @@ import {
type PluginClientApiMethodPath
} from '../../domain/logic/plugin-client-api-surface.rules';
import { PluginCapabilityError, PluginCapabilityService } from './plugin-capability.service';
import { MessageRevisionService } from '../../../chat/application/services/message-revision.service';
import { MessageSigningService } from '../../../authentication/application/services/message-signing.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginLoggerService } from './plugin-logger.service';
@@ -106,17 +108,31 @@ describe('PluginClientApiService', () => {
}));
});
it('sends plugin messages and broadcasts them to peers', () => {
it('sends plugin messages and broadcasts them to peers', async () => {
const api = context.service.createApi(TEST_MANIFEST);
const message = api.messages.send('hello plugin');
expect(message.content).toBe('hello plugin');
expect(message.roomId).toBe('room-1');
expect(context.store.dispatch).toHaveBeenCalledWith(MessagesActions.sendMessageSuccess({ message }));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(context.store.dispatch).toHaveBeenCalledWith(
MessagesActions.sendMessageSuccess({
message: expect.objectContaining({
content: 'hello plugin',
roomId: 'room-1'
})
})
);
expect(context.voice.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'chat-message',
message
message: expect.objectContaining({
content: 'hello plugin',
roomId: 'room-1'
})
}));
expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
});
it('publishes typing state through the realtime facade', () => {
@@ -264,6 +280,11 @@ interface ServiceTestContext {
setLocalStream: ReturnType<typeof vi.fn>;
setOutputVolume: ReturnType<typeof vi.fn>;
};
messageRevisions: {
broadcastRevision: ReturnType<typeof vi.fn>;
createSignedRevision: ReturnType<typeof vi.fn>;
persistRevision: ReturnType<typeof vi.fn>;
};
}
function createServiceTestContext(): ServiceTestContext {
@@ -305,6 +326,28 @@ function createServiceTestContext(): ServiceTestContext {
setLocalStream: vi.fn(async () => undefined),
setOutputVolume: vi.fn()
};
const messageRevisions = {
createSignedRevision: vi.fn(async (input: Parameters<MessageRevisionService['createSignedRevision']>[0]) => ({
messageId: input.message.id,
revision: input.type === 'create' ? 0 : (input.message.revision ?? 0) + 1,
prevRevisionHash: input.type === 'create' ? '' : (input.message.headHash ?? ''),
headHash: 'test-head-hash',
type: input.type,
actorId: input.actorId,
senderId: input.message.senderId,
roomId: input.message.roomId,
channelId: input.message.channelId,
senderName: input.message.senderName,
content: input.content ?? input.message.content,
editedAt: input.editedAt,
isDeleted: input.isDeleted ?? false,
replyToId: input.message.replyToId,
pluginId: input.pluginId,
signature: input.sign === false ? undefined : 'test-signature'
})),
persistRevision: vi.fn(async () => undefined),
broadcastRevision: vi.fn()
};
const realtime = {
onSignalingMessage: new Subject<unknown>(),
sendRawMessage: vi.fn()
@@ -357,11 +400,24 @@ function createServiceTestContext(): ServiceTestContext {
{
provide: DatabaseService,
useValue: {
getMessageById: vi.fn(async () => null),
saveMessage: vi.fn(async () => undefined),
updateMessage: vi.fn(async () => undefined),
updateRoom: vi.fn(async () => undefined)
}
},
{
provide: MessageRevisionService,
useValue: messageRevisions
},
{
provide: MessageSigningService,
useValue: {
signRevision: vi.fn(async () => 'test-signature'),
fetchSigningPublicKey: vi.fn(async () => null),
verifyRevisionSignature: vi.fn(async () => true)
}
},
{
provide: PluginDesktopStateService,
useValue: {
@@ -426,7 +482,8 @@ function createServiceTestContext(): ServiceTestContext {
storage,
store,
uiRegistry,
voice
voice,
messageRevisions
};
}

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Action, Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
@@ -11,6 +11,7 @@ import type {
Channel,
ChatEvent,
Message,
MessageRevision,
PluginCapabilityId,
PluginEventEnvelope,
TojuPluginManifest,
@@ -49,6 +50,8 @@ import { PluginLoggerService } from './plugin-logger.service';
import { PluginMessageBusService } from './plugin-message-bus.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
import { MessageRevisionService } from '../../../chat/application/services/message-revision.service';
import { materializeMessageFromRevision } from '../../../chat/domain/rules/message-revision.builder.rules';
@Injectable({ providedIn: 'root' })
export class PluginClientApiService {
@@ -63,6 +66,7 @@ export class PluginClientApiService {
private readonly storage = inject(PluginStorageService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly messageRevisions = inject(MessageRevisionService);
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -380,7 +384,6 @@ export class PluginClientApiService {
this.serverDirectory.updateServer(room.id, {
actingRole: isOwner ? 'host' : undefined,
currentOwnerId: currentUser.id,
icon,
iconUpdatedAt
}, {
@@ -601,7 +604,8 @@ export class PluginClientApiService {
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
const roomId = this.requireRoomId();
const message: Message = {
const timestamp = Date.now();
const draftMessage: Message = {
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
content: request.content,
id: createId(),
@@ -610,53 +614,74 @@ export class PluginClientApiService {
roomId,
senderId: request.pluginUserId,
senderName: request.pluginUserId,
timestamp: Date.now()
timestamp,
revision: 0
};
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
this.persistPluginMessage(pluginId, message);
this.store.dispatch(MessagesActions.receiveMessage({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
void this.emitPluginMessageRevision(pluginId, {
draftMessage,
type: 'create',
actorId: request.pluginUserId,
editedAt: timestamp,
pluginId,
sign: false,
dispatch: (message) => MessagesActions.receiveMessage({ message })
});
}
private deletePluginMessage(pluginId: string, messageId: string): void {
this.persistPluginMessageUpdate(pluginId, messageId, {
content: '[Message deleted]',
editedAt: Date.now(),
isDeleted: true
void this.emitPluginMessageMutation(pluginId, messageId, {
type: 'plugin-delete',
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
message: existing,
type: 'plugin-delete',
actorId: this.currentUser()?.id ?? pluginId,
editedAt,
isDeleted: true,
pluginId,
sign: false
}),
legacyBroadcast: (editedAt) => ({
deletedAt: editedAt,
messageId,
type: 'message-deleted'
}),
dispatch: () => MessagesActions.deleteMessageSuccess({ messageId })
});
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
this.voice.broadcastMessage({
deletedAt: Date.now(),
messageId,
type: 'message-deleted'
} as unknown as ChatEvent);
}
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
const editedAt = Date.now();
this.persistPluginMessageUpdate(pluginId, messageId, { content, editedAt });
this.store.dispatch(MessagesActions.editMessageSuccess({
void this.emitPluginMessageMutation(pluginId, messageId, {
type: 'plugin-edit',
content,
editedAt,
messageId
}));
this.voice.broadcastMessage({
content,
editedAt,
messageId,
type: 'message-edited'
} as unknown as ChatEvent);
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
message: existing,
type: 'plugin-edit',
actorId: this.currentUser()?.id ?? pluginId,
content,
editedAt,
pluginId,
sign: false
}),
legacyBroadcast: (editedAt) => ({
content,
editedAt,
messageId,
type: 'message-edited'
}),
dispatch: (message) => MessagesActions.editMessageSuccess({
content: message.content,
editedAt: message.editedAt ?? Date.now(),
messageId
})
});
}
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
const currentUser = this.currentUser();
const roomId = this.requireRoomId();
const message: Message = {
const timestamp = Date.now();
const draftMessage: Message = {
channelId: channelId ?? this.activeChannelId() ?? 'general',
content,
id: createId(),
@@ -665,14 +690,89 @@ export class PluginClientApiService {
roomId,
senderId: currentUser?.id ?? 'plugin',
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
timestamp: Date.now()
timestamp,
revision: 0
};
this.persistPluginMessage(pluginId, message);
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
void this.emitPluginMessageRevision(pluginId, {
draftMessage,
type: 'create',
actorId: currentUser?.id ?? pluginId,
editedAt: timestamp,
pluginId,
sign: !!currentUser,
dispatch: (message) => MessagesActions.sendMessageSuccess({ message })
});
return message;
return draftMessage;
}
private async emitPluginMessageRevision(
pluginId: string,
input: {
draftMessage: Message;
type: 'create';
actorId: string;
editedAt: number;
pluginId: string;
sign: boolean;
dispatch: (message: Message) => Action;
}
): Promise<void> {
try {
const revision = await this.messageRevisions.createSignedRevision({
message: input.draftMessage,
type: input.type,
actorId: input.actorId,
editedAt: input.editedAt,
pluginId: input.pluginId,
sign: input.sign
});
const message = materializeMessageFromRevision(null, revision);
this.logger.info(pluginId, 'Plugin message emitted', { messageId: message.id });
await this.db.saveMessage(message);
await this.messageRevisions.persistRevision(revision);
this.store.dispatch(input.dispatch(message));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
this.messageRevisions.broadcastRevision(revision);
} catch (error: unknown) {
this.logger.warn(pluginId, 'Failed to emit plugin message revision', error);
}
}
private async emitPluginMessageMutation(
pluginId: string,
messageId: string,
input: {
type: 'plugin-edit' | 'plugin-delete';
content?: string;
apply: (existing: Message, editedAt: number) => Promise<MessageRevision>;
legacyBroadcast: (editedAt: number) => ChatEvent;
dispatch: (message: Message) => Action;
}
): Promise<void> {
try {
const existing = await this.db.getMessageById(messageId);
if (!existing) {
this.logger.warn(pluginId, 'Plugin message mutation target not found', { messageId });
return;
}
const editedAt = Date.now();
const revision = await input.apply(existing, editedAt);
const message = materializeMessageFromRevision(existing, revision);
await this.db.saveMessage(message);
await this.messageRevisions.persistRevision(revision);
this.store.dispatch(input.dispatch(message));
this.voice.broadcastMessage(input.legacyBroadcast(editedAt) as unknown as ChatEvent);
this.messageRevisions.broadcastRevision(revision);
} catch (error: unknown) {
this.logger.warn(pluginId, 'Failed to emit plugin message mutation revision', error);
}
}
private setTyping(pluginId: string, isTyping: boolean, channelId?: string): void {
@@ -831,8 +931,7 @@ export class PluginClientApiService {
this.serverDirectory.updateServer(room.id, {
actingRole: isOwner ? 'host' : undefined,
channels,
currentOwnerId: currentUser.id
channels
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl

View File

@@ -24,6 +24,10 @@ import { PluginClientApiService } from './plugin-client-api.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
import {
fileUrlToPath,
grantPluginReadRoots
} from '../../domain/rules/plugin-local-file.rules';
interface ActivePluginRuntime {
context: TojuPluginActivationContext;
@@ -369,43 +373,57 @@ export class PluginHostService {
const entrypointUrl = this.resolveEntrypoint(manifest, sourcePath);
if (entrypointUrl.startsWith('file://')) {
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl);
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl, sourcePath);
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
return { module, moduleObjectUrl };
}
if (!entrypointUrl.startsWith('file://') && !entrypointUrl.startsWith('https://')) {
throw new Error('Remote plugin entrypoints must use HTTPS');
}
try {
return {
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
};
} catch (error) {
if (!entrypointUrl.startsWith('http://') && !entrypointUrl.startsWith('https://')) {
if (!entrypointUrl.startsWith('https://')) {
throw error;
}
const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl);
const moduleObjectUrl = await this.createRemoteModuleObjectUrl(entrypointUrl, manifest);
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
return { module, moduleObjectUrl };
}
}
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
private async createLocalModuleObjectUrl(entrypointUrl: string, sourcePath?: string): Promise<string> {
const api = this.electronBridge?.getApi();
if (!api) {
throw new Error('Local plugin entrypoints require the desktop app');
}
await grantPluginReadRoots(api, sourcePath, entrypointUrl);
const base64Data = await api.readFile(fileUrlToPath(entrypointUrl));
if (!base64Data) {
throw new Error('Plugin entrypoint is not readable from app data');
}
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
const source = new TextDecoder().decode(bytes);
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
}
private async createRemoteModuleObjectUrl(entrypointUrl: string): Promise<string> {
private async createRemoteModuleObjectUrl(entrypointUrl: string, manifest: TojuPluginManifest): Promise<string> {
if (!entrypointUrl.startsWith('https://')) {
throw new Error('Remote plugin entrypoints must use HTTPS');
}
const response = await fetch(entrypointUrl, { headers: { Accept: 'text/javascript,*/*' } });
if (!response.ok) {
@@ -413,10 +431,26 @@ export class PluginHostService {
}
const source = await response.text();
const expectedIntegrity = manifest.bundle?.integrity?.trim();
if (expectedIntegrity) {
const actualDigest = await this.sha256Hex(source);
if (actualDigest !== expectedIntegrity.replace(/^sha256-/i, '').toLowerCase()) {
throw new Error('Plugin entrypoint integrity check failed');
}
}
return URL.createObjectURL(new Blob([`${source}\n//# sourceURL=${entrypointUrl}`], { type: 'text/javascript' }));
}
private async sha256Hex(source: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
const bytes = new Uint8Array(digest);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
private revokeModuleObjectUrl(pluginId: string): void {
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
@@ -478,17 +512,6 @@ export class PluginHostService {
}
}
function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
try {
disposable.dispose();

View File

@@ -10,7 +10,6 @@ import type {
} from '../../../../shared-kernel';
export interface UpsertPluginRequirementRequest {
actorUserId: string;
installUrl?: string;
manifest?: TojuPluginManifest;
reason?: string;
@@ -20,7 +19,6 @@ export interface UpsertPluginRequirementRequest {
}
export interface UpsertPluginEventDefinitionRequest {
actorUserId: string;
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
maxPayloadBytes?: number;
rateLimitJson?: string;
@@ -48,10 +46,9 @@ export class PluginRequirementService {
);
}
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string, actorUserId: string): Observable<{ ok: boolean }> {
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string): Observable<{ ok: boolean }> {
return this.http.delete<{ ok: boolean }>(
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
{ body: { actorUserId } }
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`
);
}

View File

@@ -63,6 +63,7 @@ describe('PluginStoreService', () => {
const service = createService(registerLocalManifest, unregister);
await new Promise((resolve) => setTimeout(resolve, 0));
await service.addSourceUrl('https://plugins.example.test/index.json#latest');
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'https://plugins.example.test/index.json']);
@@ -81,9 +82,11 @@ describe('PluginStoreService', () => {
}));
});
it('seeds the official plugin repository for new users', () => {
it('seeds the official plugin repository for new users', async () => {
const service = createService(registerLocalManifest, unregister);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL]);
expect(fetchMock).toHaveBeenCalledWith(
OFFICIAL_PLUGIN_SOURCE_URL,
@@ -132,12 +135,15 @@ describe('PluginStoreService', () => {
],
title: 'Local Plugins'
};
const grantPluginReadRoot = vi.fn(async () => true);
const readFile = vi.fn(async () => toBase64(JSON.stringify(localSourceManifest)));
const service = createService(registerLocalManifest, unregister, { readFile });
const service = createService(registerLocalManifest, unregister, { grantPluginReadRoot, readFile });
await new Promise((resolve) => setTimeout(resolve, 0));
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(fetchMock).not.toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json', expect.anything());
expect(grantPluginReadRoot).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin');
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(service.sourceUrls()).toEqual([OFFICIAL_PLUGIN_SOURCE_URL, 'file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
@@ -255,7 +261,8 @@ function createService(
electronApi: {
ensureDir?: (dirPath: string) => Promise<boolean>;
getAppDataPath?: () => Promise<string>;
readFile?: (filePath: string) => Promise<string>;
grantPluginReadRoot?: (rootPath: string) => Promise<boolean>;
readFile?: (filePath: string) => Promise<string | null>;
writeFile?: (filePath: string, data: string) => Promise<boolean>;
} | null = null
): PluginStoreService {

View File

@@ -37,7 +37,6 @@ import type {
PersistedPluginStoreState,
PluginStoreEntry,
PluginStoreInstallState,
PluginStoreActionLabel,
PluginStoreReadme,
PluginStoreSourceResult
} from '../../domain/models/plugin-store.models';
@@ -46,6 +45,10 @@ import { PluginCapabilityService } from './plugin-capability.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service';
import {
fileUrlToPath,
grantPluginReadRoots
} from '../../domain/rules/plugin-local-file.rules';
const STORE_SCHEMA_VERSION = 2;
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
@@ -138,7 +141,7 @@ export class PluginStoreService {
void this.applyInstalledPlugins(state.installedPlugins, 'client');
if (state.sourceUrls.length > 0) {
void this.refreshSources();
void this.bootstrapSourceRefresh(state.sourceUrls);
}
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
@@ -169,6 +172,7 @@ export class PluginStoreService {
}
this.sourceUrlsSignal.update((sourceUrls) => [...sourceUrls, sourceUrl]);
await this.ensurePluginSourceReadRoot(sourceUrl);
this.saveState();
await this.refreshSources();
}
@@ -190,6 +194,7 @@ export class PluginStoreService {
this.loadingSignal.set(true);
try {
await this.ensurePluginSourceReadRoots(this.sourceUrls());
const sources = await Promise.all(this.sourceUrls().map((sourceUrl) => this.loadSource(sourceUrl, abortController.signal)));
if (this.refreshVersion === currentRefresh) {
@@ -292,7 +297,13 @@ export class PluginStoreService {
return;
}
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
const sourcePath = installedPlugin.cachedSourcePath ?? installedPlugin.installUrl;
if (sourcePath?.startsWith('file://')) {
await this.ensurePluginSourceReadRoot(sourcePath);
}
this.host.registerLocalManifest(installedPlugin.manifest, sourcePath);
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
@@ -503,6 +514,10 @@ export class PluginStoreService {
return await this.readLocalFileUrl(url);
}
if (!url.startsWith('https://')) {
throw new Error('Remote plugin store requests must use HTTPS');
}
const response = await fetch(url, { headers: { Accept: accept }, signal });
if (!response.ok) {
@@ -512,6 +527,28 @@ export class PluginStoreService {
return await response.text();
}
private async bootstrapSourceRefresh(sourceUrls: readonly string[]): Promise<void> {
await this.ensurePluginSourceReadRoots(sourceUrls);
if (this.stateMutated) {
return;
}
await this.refreshSources();
}
private async ensurePluginSourceReadRoots(sourceUrls: readonly string[]): Promise<void> {
await Promise.all(sourceUrls.map((sourceUrl) => this.ensurePluginSourceReadRoot(sourceUrl)));
}
private async ensurePluginSourceReadRoot(sourceUrl: string): Promise<void> {
if (!sourceUrl.startsWith('file://')) {
return;
}
await grantPluginReadRoots(this.electronBridge.getApi(), sourceUrl);
}
private async readLocalFileUrl(fileUrl: string): Promise<string> {
const api = this.electronBridge.getApi();
@@ -519,7 +556,13 @@ export class PluginStoreService {
throw new Error('Local plugin source paths require the desktop app');
}
await this.ensurePluginSourceReadRoot(fileUrl);
const base64Data = await api.readFile(fileUrlToPath(fileUrl));
if (!base64Data) {
throw new Error(`Local plugin source is not readable: ${fileUrlToPath(fileUrl)}`);
}
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
@@ -874,7 +917,6 @@ export class PluginStoreService {
roomId,
installedPlugin.manifest.id,
{
actorUserId,
installUrl: installedPlugin.installUrl,
manifest: installedPlugin.manifest,
reason: installedPlugin.manifest.description,
@@ -893,7 +935,7 @@ export class PluginStoreService {
throw new Error('Open a chat server before removing server-scoped plugins');
}
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId, actorUserId));
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId));
}
private getPluginApiBaseUrl(serverId: string): string {
@@ -994,7 +1036,7 @@ export class PluginStoreService {
if (sourceUrlsChanged) {
this.sourceUrlsSignal.set(normalized.sourceUrls);
void this.refreshSources();
void this.bootstrapSourceRefresh(normalized.sourceUrls);
}
await this.applyInstalledPlugins(normalized.installedPlugins, 'client');
@@ -1400,17 +1442,6 @@ function localPathToFileUrl(filePath: string): string | undefined {
.join('/')}`;
}
function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}
function isAbsoluteLocalPath(filePath: string): boolean {
return filePath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(filePath);
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
collectPluginReadRoots,
fileUrlToPath,
pluginFileParentDir
} from './plugin-local-file.rules';
describe('plugin-local-file.rules', () => {
it('resolves linux file URLs to absolute paths', () => {
expect(fileUrlToPath('file:///home/ludde/Desktop/TestPlugin/plugin-source.json'))
.toBe('/home/ludde/Desktop/TestPlugin/plugin-source.json');
});
it('collects plugin read roots from source and entrypoint URLs', () => {
expect(collectPluginReadRoots(
'file:///home/ludde/Desktop/TestPlugin/plugin-source.json',
'file:///home/ludde/Desktop/TestPlugin/dist/main.js'
)).toEqual([
'/home/ludde/Desktop/TestPlugin',
'/home/ludde/Desktop/TestPlugin/dist'
]);
});
it('treats directory file URLs as their own read roots', () => {
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin/')).toEqual([
'/home/ludde/Desktop/TestPlugin'
]);
expect(collectPluginReadRoots('file:///home/ludde/Desktop/TestPlugin')).toEqual([
'/home/ludde/Desktop/TestPlugin'
]);
});
});

View File

@@ -0,0 +1,59 @@
import type { ElectronApi } from '../../../../core/platform/electron/electron-api.models';
export function pluginFileParentDir(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/').replace(/\/+$/, '');
const index = normalized.lastIndexOf('/');
return index > 0 ? normalized.slice(0, index) : normalized;
}
export function pluginReadRootForFileUrl(fileUrl: string): string {
const filePath = fileUrlToPath(fileUrl).replace(/\\/g, '/').replace(/\/+$/, '');
const basename = filePath.split('/').pop() ?? '';
if (fileUrl.endsWith('/') || !basename.includes('.')) {
return filePath;
}
return pluginFileParentDir(filePath);
}
export function collectPluginReadRoots(...fileUrls: Array<string | undefined>): string[] {
const roots = new Set<string>();
for (const fileUrl of fileUrls) {
if (!fileUrl?.startsWith('file://')) {
continue;
}
roots.add(pluginReadRootForFileUrl(fileUrl));
}
return [...roots];
}
export async function grantPluginReadRoots(
api: Pick<ElectronApi, 'grantPluginReadRoot'> | null | undefined,
...fileUrls: Array<string | undefined>
): Promise<void> {
if (!api?.grantPluginReadRoot) {
return;
}
const roots = collectPluginReadRoots(...fileUrls);
for (const root of roots) {
await api.grantPluginReadRoot(root);
}
}
export function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}