mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(plugins): sandboxed page/widget frames + trekBridge (M3)
Plugins can now render UI. Page plugins appear as a nav entry and open a full-page sandboxed iframe; the frame talks to TREK only over the postMessage bridge. - Server serves plugin client assets at /plugin-frame/:id/* with a strict path guard and a locked-down, per-plugin CSP (default-src none; connect-src limited to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin, so the frame can't read the session cookie or the parent DOM). Global CSP frameSrc relaxed from 'none' to 'self' for exactly these frames. - GET /api/plugins feed lists active plugins for the client. - Client: pluginStore + PluginFrame (the trekBridge host) authenticates every inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id or origin; pushes context (theme/locale/tripId/userId), validates navigation, renders notifications as text, resizes widgets, and proxies trek:invoke to the plugin's own routes host-side (session cookie stays with the host). - Page route /plugins/:id + Navbar nav injection for page plugins. Dashboard widget slot and the trek:request core-data bridge are deferred to a later slice.
This commit is contained in:
@@ -4,6 +4,8 @@ import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
import { applyAppearance } from './theme/applyAppearance'
|
||||
import { useAddonStore } from './store/addonStore'
|
||||
import { usePluginStore } from './store/pluginStore'
|
||||
import PluginPage from './pages/PluginPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
@@ -111,6 +113,7 @@ export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
@@ -168,6 +171,7 @@ export default function App() {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
loadPlugins()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
@@ -284,6 +288,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/:pluginId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PluginPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/vacay"
|
||||
element={
|
||||
|
||||
@@ -543,6 +543,14 @@ export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
// Active plugins the client renders (page nav entries, dashboard widgets).
|
||||
active: () => apiClient.get('/plugins').then(r => r.data),
|
||||
// Call one of a plugin's own declared routes through the host proxy.
|
||||
invoke: (id: string, sub: string, init?: { method?: string; body?: unknown }) =>
|
||||
apiClient.request({ url: `/plugins/${id}${sub}`, method: init?.method || 'GET', data: init?.body }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airtrailApi = {
|
||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark, Blocks } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
@@ -52,6 +53,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||
const pagePlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'page')
|
||||
|
||||
useEffect(() => {
|
||||
if (user) loadAddons()
|
||||
@@ -135,7 +137,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff). Absolutely positioned so
|
||||
the left brand block and the right action cluster keep their layout. */}
|
||||
{globalAddons.length > 0 && !tripTitle && (
|
||||
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
|
||||
<div
|
||||
className="trek-nav-pill"
|
||||
style={{
|
||||
@@ -147,7 +149,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
}}
|
||||
>
|
||||
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
|
||||
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays }))
|
||||
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
|
||||
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: Blocks }))
|
||||
].map(tab => {
|
||||
const isActive = location.pathname === tab.path
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// FE-PLUGINS-FRAME-001 to 004
|
||||
import { render, cleanup, waitFor } from '@testing-library/react';
|
||||
import PluginFrame from './PluginFrame';
|
||||
|
||||
const navigate = vi.fn();
|
||||
const toast = { info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn() };
|
||||
const invoke = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true }));
|
||||
|
||||
vi.mock('react-router-dom', () => ({ useNavigate: () => navigate }));
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en' }) }));
|
||||
vi.mock('../../store/authStore', () => ({ useAuthStore: (sel: (s: unknown) => unknown) => sel({ user: { id: 7 } }) }));
|
||||
vi.mock('../../api/client', () => ({ pluginsApi: { invoke: (id: string, sub: string, init?: unknown) => invoke(id, sub, init) } }));
|
||||
|
||||
function fromFrame(frame: HTMLIFrameElement, data: unknown) {
|
||||
window.dispatchEvent(new MessageEvent('message', { source: frame.contentWindow, data } as MessageEventInit));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
navigate.mockClear();
|
||||
Object.values(toast).forEach((f) => f.mockClear());
|
||||
invoke.mockClear();
|
||||
});
|
||||
|
||||
describe('PluginFrame', () => {
|
||||
it('FE-PLUGINS-FRAME-001: renders an opaque sandboxed iframe (no allow-same-origin)', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
expect(iframe.getAttribute('src')).toBe('/plugin-frame/demo/index.html');
|
||||
const sandbox = iframe.getAttribute('sandbox') || '';
|
||||
expect(sandbox).toContain('allow-scripts');
|
||||
expect(sandbox).not.toContain('allow-same-origin');
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-002: authenticates messages by sender window — a foreign source is ignored', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
// message NOT from our iframe -> ignored
|
||||
window.dispatchEvent(new MessageEvent('message', { source: window, data: { type: 'trek:navigate', to: '/admin' } }));
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
// message from our iframe -> handled
|
||||
fromFrame(iframe, { type: 'trek:navigate', to: '/dashboard' });
|
||||
expect(navigate).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-003: blocks unsafe navigation targets and renders notifications as text', () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
fromFrame(iframe, { type: 'trek:navigate', to: '//evil.example' }); // protocol-relative
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
fromFrame(iframe, { type: 'trek:notify', level: 'success', message: 'saved' });
|
||||
expect(toast.success).toHaveBeenCalledWith('saved');
|
||||
});
|
||||
|
||||
it('FE-PLUGINS-FRAME-004: trek:invoke calls the host proxy and replies to the frame', async () => {
|
||||
const { container } = render(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: unknown[] = [];
|
||||
// capture host->frame messages
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => posted.push(m);
|
||||
|
||||
fromFrame(iframe, { type: 'trek:invoke', requestId: 'r1', sub: '/status', method: 'GET' });
|
||||
await waitFor(() => expect(invoke).toHaveBeenCalledWith('demo', '/status', { method: 'GET', body: undefined }));
|
||||
await waitFor(() => expect(posted.some((m) => (m as { type?: string }).type === 'trek:response')).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Renders a plugin's sandboxed page/widget iframe and hosts the trekBridge
|
||||
* (#plugins, M3).
|
||||
*
|
||||
* The frame is served same-origin from /plugin-frame/:id but sandboxed WITHOUT
|
||||
* allow-same-origin, so it runs at an OPAQUE origin: no access to the trek_session
|
||||
* cookie, no parent DOM, no credentialed fetch. Its only channel is postMessage,
|
||||
* and we authenticate every inbound message by the SENDER WINDOW IDENTITY
|
||||
* (event.source === our iframe), never by a claimed id or by origin (which is
|
||||
* "null" for opaque frames). Data reads go through the host (app-origin, session
|
||||
* cookie) so the plugin never handles credentials.
|
||||
*/
|
||||
|
||||
interface PluginFrameProps {
|
||||
pluginId: string
|
||||
tripId?: string | null
|
||||
className?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
type Inbound =
|
||||
| { type: 'trek:ready' }
|
||||
| { type: 'trek:context:request' }
|
||||
| { type: 'trek:navigate'; to: string }
|
||||
| { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string }
|
||||
| { type: 'trek:resize'; height?: number }
|
||||
| { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown }
|
||||
|
||||
export default function PluginFrame({ pluginId, tripId = null, className, title }: PluginFrameProps) {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null)
|
||||
const { locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const userId = useAuthStore((s) => s.user?.id)
|
||||
const [height, setHeight] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current
|
||||
if (!frame) return
|
||||
|
||||
const post = (msg: unknown) => frame.contentWindow?.postMessage(msg, '*') // opaque frame -> targetOrigin must be '*'
|
||||
|
||||
const context = () => ({
|
||||
type: 'trek:context',
|
||||
tripId,
|
||||
userId: userId != null ? String(userId) : null,
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
locale,
|
||||
hostOrigin: window.location.origin,
|
||||
})
|
||||
|
||||
const onMessage = async (ev: MessageEvent) => {
|
||||
// The ONLY trusted identity: the message came from OUR iframe's window.
|
||||
if (ev.source !== frame.contentWindow) return
|
||||
const msg = ev.data as Inbound
|
||||
if (!msg || typeof msg !== 'object') return
|
||||
|
||||
switch (msg.type) {
|
||||
case 'trek:ready':
|
||||
case 'trek:context:request':
|
||||
post(context())
|
||||
break
|
||||
case 'trek:navigate': {
|
||||
const to = typeof msg.to === 'string' ? msg.to : ''
|
||||
// In-app paths only; block protocol-relative and admin unless allowed by the app itself.
|
||||
if (/^\/[a-zA-Z0-9/_?=&%.-]*$/.test(to) && !to.startsWith('//')) navigate(to)
|
||||
break
|
||||
}
|
||||
case 'trek:notify': {
|
||||
const text = String(msg.message ?? '').slice(0, 200)
|
||||
const level = msg.level ?? 'info'
|
||||
if (text) (toast[level] ?? toast.info)(text)
|
||||
break
|
||||
}
|
||||
case 'trek:resize':
|
||||
if (typeof msg.height === 'number' && msg.height > 0) setHeight(Math.min(msg.height, 2000))
|
||||
break
|
||||
case 'trek:invoke': {
|
||||
// The plugin's own route, called host-side with the user's session.
|
||||
try {
|
||||
const data = await pluginsApi.invoke(pluginId, msg.sub, { method: msg.method, body: msg.body })
|
||||
post({ type: 'trek:response', requestId: msg.requestId, data })
|
||||
} catch (e) {
|
||||
const err = e as { response?: { status?: number }; message?: string }
|
||||
post({ type: 'trek:error', requestId: msg.requestId, code: err.response?.status ?? 'error', message: err.message ?? 'invoke failed' })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => window.removeEventListener('message', onMessage)
|
||||
}, [pluginId, tripId, userId, locale, navigate, toast])
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src={`/plugin-frame/${pluginId}/index.html`}
|
||||
sandbox="allow-scripts allow-forms allow-popups"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
title={title || pluginId}
|
||||
className={className}
|
||||
style={{ width: '100%', height: height ?? '100%', border: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import PageShell from '../components/Layout/PageShell'
|
||||
import PluginFrame from '../components/Plugins/PluginFrame'
|
||||
import { usePluginStore } from '../store/pluginStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
|
||||
/**
|
||||
* Full-page host for a `page` plugin (#plugins, M3). Thin by design (no state /
|
||||
* effects — the page-pattern rule): it resolves the plugin from the store and
|
||||
* renders its sandboxed iframe; PluginFrame owns the bridge.
|
||||
*/
|
||||
export default function PluginPage(): React.ReactElement {
|
||||
const { pluginId = '' } = useParams()
|
||||
const { t } = useTranslation()
|
||||
const plugin = usePluginStore((s) => s.getById(pluginId))
|
||||
const loaded = usePluginStore((s) => s.loaded)
|
||||
|
||||
if (loaded && (!plugin || plugin.type !== 'page')) {
|
||||
return (
|
||||
<PageShell background="var(--bg-secondary)">
|
||||
<div className="w-full px-6 py-16 text-center text-sm text-content-faint">{t('plugins.notFound')}</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell background="var(--bg-secondary)" navOffset="var(--nav-h, 56px)">
|
||||
<div style={{ height: 'calc(100vh - var(--nav-h, 56px))' }}>
|
||||
<PluginFrame pluginId={pluginId} title={plugin?.name} className="w-full h-full" />
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// FE-STORE-PLUGIN-001 to 004
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { usePluginStore } from './pluginStore';
|
||||
|
||||
const initial = usePluginStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
usePluginStore.setState(initial, true);
|
||||
});
|
||||
|
||||
describe('pluginStore', () => {
|
||||
it('FE-STORE-PLUGIN-001: loads active plugins and splits pages/widgets', async () => {
|
||||
server.use(
|
||||
http.get('/api/plugins', () =>
|
||||
HttpResponse.json({
|
||||
plugins: [
|
||||
{ id: 'flights', name: 'Flights', type: 'widget', icon: 'Plane' },
|
||||
{ id: 'report', name: 'Report', type: 'page', icon: 'FileText' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await usePluginStore.getState().loadPlugins();
|
||||
|
||||
const s = usePluginStore.getState();
|
||||
expect(s.loaded).toBe(true);
|
||||
expect(s.plugins).toHaveLength(2);
|
||||
expect(s.pages().map((p) => p.id)).toEqual(['report']);
|
||||
expect(s.widgets().map((p) => p.id)).toEqual(['flights']);
|
||||
expect(s.getById('flights')?.name).toBe('Flights');
|
||||
expect(s.getById('nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-STORE-PLUGIN-002: a failed fetch still marks the store loaded (no crash)', async () => {
|
||||
server.use(http.get('/api/plugins', () => HttpResponse.error()));
|
||||
await usePluginStore.getState().loadPlugins();
|
||||
expect(usePluginStore.getState().loaded).toBe(true);
|
||||
expect(usePluginStore.getState().plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-STORE-PLUGIN-003: tolerates a missing plugins array', async () => {
|
||||
server.use(http.get('/api/plugins', () => HttpResponse.json({})));
|
||||
await usePluginStore.getState().loadPlugins();
|
||||
expect(usePluginStore.getState().plugins).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand'
|
||||
import { pluginsApi } from '../api/client'
|
||||
|
||||
/**
|
||||
* Active plugins the client renders (#plugins, M3). Page plugins become nav
|
||||
* entries + a full-page iframe route; widget plugins mount on the dashboard.
|
||||
* Cloned from addonStore — plugins have their own feed and lifecycle, so they
|
||||
* don't overload the addon store.
|
||||
*/
|
||||
export interface ActivePlugin {
|
||||
id: string
|
||||
name: string
|
||||
type: 'integration' | 'page' | 'widget'
|
||||
icon: string | null
|
||||
}
|
||||
|
||||
interface PluginState {
|
||||
plugins: ActivePlugin[]
|
||||
loaded: boolean
|
||||
loadPlugins: () => Promise<void>
|
||||
getById: (id: string) => ActivePlugin | undefined
|
||||
pages: () => ActivePlugin[]
|
||||
widgets: () => ActivePlugin[]
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginState>((set, get) => ({
|
||||
plugins: [],
|
||||
loaded: false,
|
||||
|
||||
loadPlugins: async () => {
|
||||
try {
|
||||
const data = await pluginsApi.active()
|
||||
set({ plugins: (data.plugins as ActivePlugin[]) || [], loaded: true })
|
||||
} catch {
|
||||
set({ loaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
getById: (id) => get().plugins.find((p) => p.id === id),
|
||||
pages: () => get().plugins.filter((p) => p.type === 'page'),
|
||||
widgets: () => get().plugins.filter((p) => p.type === 'widget'),
|
||||
}))
|
||||
@@ -123,7 +123,10 @@ export function applyGlobalMiddleware(
|
||||
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
|
||||
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
|
||||
objectSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
// 'self' so the app can embed same-origin, sandboxed plugin frames
|
||||
// (/plugin-frame/*). Those frames are sandboxed WITHOUT allow-same-origin,
|
||||
// so they run at an opaque origin and get their own locked-down CSP.
|
||||
frameSrc: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
// Restrict <form> submission targets (form-action has no default-src
|
||||
// fallback, so it must be set explicitly).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { pluginCodeDir } from './paths';
|
||||
|
||||
/**
|
||||
* Serves a page/widget plugin's static client from /plugin-frame/:id/* (#plugins,
|
||||
* M3). The document is embedded in a sandbox WITHOUT allow-same-origin, so it
|
||||
* runs at an OPAQUE origin: it cannot read the trek_session cookie, cannot reach
|
||||
* the parent DOM, and its only channel to TREK is the postMessage bridge.
|
||||
*
|
||||
* Each response gets a locked-down, per-plugin CSP (default-src none; own scripts
|
||||
* only; connect-src limited to declared outbound hosts) and a strict path guard
|
||||
* so a plugin can only serve files under its own client/ directory.
|
||||
*/
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
@Controller('plugin-frame/:pluginId')
|
||||
export class PluginFrameController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get('*path')
|
||||
serve(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): void {
|
||||
if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) {
|
||||
res.status(404).send('Plugin not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = (req.params as Record<string, unknown>).path ?? (req.params as Record<string, unknown>)[0] ?? '';
|
||||
const rel = (Array.isArray(rest) ? rest.join('/') : String(rest)).replace(/^\/+/, '') || 'index.html';
|
||||
|
||||
const clientDir = path.join(pluginCodeDir(pluginId), 'client');
|
||||
const resolved = path.resolve(clientDir, rel);
|
||||
// Containment: never escape the plugin's own client/ dir.
|
||||
if (!resolved.startsWith(path.resolve(clientDir) + path.sep) && resolved !== path.resolve(clientDir)) {
|
||||
res.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||||
res.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId));
|
||||
res.sendFile(resolved);
|
||||
}
|
||||
|
||||
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
|
||||
private frameCsp(pluginId: string): string {
|
||||
const outbound = this.runtime.outboundHostsOf(pluginId);
|
||||
const connect = ["'self'", ...outbound.map((h) => (h.includes('://') ? h : `https://${h}`))].join(' ');
|
||||
return [
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
`connect-src ${connect}`,
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
'sandbox allow-scripts allow-forms allow-popups',
|
||||
].join('; ');
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,18 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
isActive(id: string): boolean {
|
||||
return this.supervisor.isActive(id);
|
||||
}
|
||||
|
||||
/** Declared outbound hosts (from http:outbound:<host> grants) for the frame CSP. */
|
||||
outboundHostsOf(id: string): string[] {
|
||||
const row = db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as
|
||||
| { granted_permissions: string }
|
||||
| undefined;
|
||||
if (!row) return [];
|
||||
return parseArray(row.granted_permissions)
|
||||
.filter((p) => p.startsWith('http:outbound:'))
|
||||
.map((p) => p.slice('http:outbound:'.length))
|
||||
.filter(Boolean);
|
||||
}
|
||||
routesOf(id: string): PluginRouteInfo[] {
|
||||
return this.supervisor.routesOf(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
|
||||
/**
|
||||
* GET /api/plugins — the authenticated feed of ACTIVE plugins the client renders
|
||||
* (#plugins, M3): page plugins become nav entries, widget plugins become
|
||||
* dashboard widgets. Empty when the runtime is disabled. Distinct from the
|
||||
* admin surface (/api/admin/plugins) and the per-plugin proxy
|
||||
* (/api/plugins/:id/*) — this is the exact /api/plugins path.
|
||||
*/
|
||||
interface ActivePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
@Controller('api/plugins')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PluginsFeedController {
|
||||
@Get()
|
||||
list(): { plugins: ActivePlugin[] } {
|
||||
if (!pluginsEnabled()) return { plugins: [] };
|
||||
const plugins = db
|
||||
.prepare("SELECT id, name, type, icon FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
|
||||
.all() as ActivePlugin[];
|
||||
return { plugins };
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PluginsController } from './plugins.controller';
|
||||
import { PluginsFeedController } from './plugins-feed.controller';
|
||||
import { PluginsProxyController } from './plugins-proxy.controller';
|
||||
import { PluginFrameController } from './plugin-frame.controller';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
|
||||
/**
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime: the runtime
|
||||
* service owns the process supervisor and boots active plugins on startup; the
|
||||
* proxy controller forwards /api/plugins/:id/* to the isolated child.
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
|
||||
* the runtime service owns the process supervisor and boots active plugins on
|
||||
* startup; the proxy forwards /api/plugins/:id/* to the child; the feed lists
|
||||
* active plugins for the client; the frame controller serves sandboxed page/
|
||||
* widget assets at /plugin-frame/:id/*.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PluginsController, PluginsProxyController],
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController],
|
||||
providers: [PluginsService, PluginRuntimeService],
|
||||
})
|
||||
export class PluginsModule {}
|
||||
|
||||
@@ -143,6 +143,13 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
await rt.deactivate('booter');
|
||||
});
|
||||
|
||||
it('outboundHostsOf extracts declared http:outbound hosts', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run();
|
||||
const rt = new PluginRuntimeService();
|
||||
expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
|
||||
expect(rt.outboundHostsOf('missing')).toEqual([]);
|
||||
});
|
||||
|
||||
it('onModuleDestroy tears down cleanly', async () => {
|
||||
await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The sandboxed plugin frame server (#plugins, M3): serves a plugin's client/
|
||||
* assets with a locked-down per-frame CSP and a strict path guard, only when the
|
||||
* plugin is active and the runtime is enabled.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const { pluginsEnabledMock } = vi.hoisted(() => ({ pluginsEnabledMock: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: pluginsEnabledMock }));
|
||||
|
||||
import { PluginFrameController } from '../../../src/nest/plugins/plugin-frame.controller';
|
||||
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
|
||||
let codeRoot: string;
|
||||
beforeAll(() => {
|
||||
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-frame-'));
|
||||
process.env.TREK_PLUGINS_DIR = codeRoot;
|
||||
const dir = path.join(codeRoot, 'widget', 'client');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'index.html'), '<!doctype html><body>hi</body>');
|
||||
});
|
||||
afterAll(() => {
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
fs.rmSync(codeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fakeRes() {
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {} as Record<string, string>,
|
||||
sent: undefined as unknown,
|
||||
filePath: undefined as string | undefined,
|
||||
status(c: number) { res.statusCode = c; return res; },
|
||||
setHeader(k: string, v: string) { res.headers[k] = v; },
|
||||
send(b: unknown) { res.sent = b; return res; },
|
||||
sendFile(p: string) { res.filePath = p; res.statusCode ||= 200; },
|
||||
};
|
||||
return res;
|
||||
}
|
||||
const req = (p: string) => ({ params: { path: p } }) as never;
|
||||
|
||||
function runtime(active = true, hosts: string[] = []): PluginRuntimeService {
|
||||
return { isActive: vi.fn(() => active), outboundHostsOf: vi.fn(() => hosts) } as unknown as PluginRuntimeService;
|
||||
}
|
||||
|
||||
describe('PluginFrameController', () => {
|
||||
it('serves index.html with the opaque-frame CSP + sandbox', () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true, ['api.weather.com'])).serve('widget', req(''), res as never);
|
||||
expect(res.filePath).toContain(path.join('widget', 'client', 'index.html'));
|
||||
const csp = res.headers['Content-Security-Policy'];
|
||||
expect(csp).toContain("default-src 'none'");
|
||||
expect(csp).toContain('sandbox allow-scripts allow-forms allow-popups');
|
||||
expect(csp).not.toContain('allow-same-origin');
|
||||
expect(csp).toContain('connect-src \'self\' https://api.weather.com');
|
||||
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
|
||||
});
|
||||
|
||||
it('404 when the plugin is inactive', () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(false)).serve('widget', req(''), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('404 when the runtime is disabled', () => {
|
||||
pluginsEnabledMock.mockReturnValueOnce(false);
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req(''), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('403 on a path-traversal attempt', () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req('../../../etc/passwd'), res as never);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('404 for a missing file', () => {
|
||||
const res = fakeRes();
|
||||
new PluginFrameController(runtime(true)).serve('widget', req('missing.js'), res as never);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ vi.mock('../../../src/db/database', () => ({ db: testDb }));
|
||||
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
|
||||
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.exec('DELETE FROM plugins');
|
||||
@@ -56,6 +57,21 @@ describe('PluginsService.list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginsFeedController (client feed)', () => {
|
||||
it('returns active plugins when enabled, nothing when disabled', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
|
||||
const feed = new PluginsFeedController();
|
||||
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const active = feed.list();
|
||||
expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box' }]);
|
||||
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
expect(feed.list().plugins).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginsController M2 endpoints', () => {
|
||||
const svc = {
|
||||
getInstanceConfig: vi.fn(() => ({ a: 1 })),
|
||||
|
||||
@@ -178,6 +178,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'فشل الحفظ',
|
||||
'admin.tabs.addons': 'الإضافات',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -157,6 +157,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Falha ao salvar',
|
||||
'admin.tabs.addons': 'Complementos',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -158,6 +158,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Uložení se nezdařilo',
|
||||
'admin.tabs.addons': 'Doplňky',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -160,6 +160,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Fehler beim Speichern',
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin nicht gefunden',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Plugins von Drittanbietern auf deiner Instanz installieren und verwalten.',
|
||||
'admin.plugins.disabledTitle': 'Plugins sind deaktiviert',
|
||||
|
||||
@@ -212,6 +212,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Failed to save',
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -159,6 +159,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Error al guardar',
|
||||
'admin.tabs.addons': 'Complementos',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -161,6 +161,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Erreur de sauvegarde',
|
||||
'admin.tabs.addons': 'Extensions',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -206,6 +206,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Αποτυχία αποθήκευσης',
|
||||
'admin.tabs.addons': 'Πρόσθετα',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -160,6 +160,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Nem sikerült menteni',
|
||||
'admin.tabs.addons': 'Bővítmények',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -202,6 +202,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Gagal menyimpan',
|
||||
'admin.tabs.addons': 'Addon',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -159,6 +159,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Impossibile salvare',
|
||||
'admin.tabs.addons': 'Moduli',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -191,6 +191,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': '保存に失敗しました',
|
||||
'admin.tabs.addons': 'アドオン',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -197,6 +197,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': '저장 실패',
|
||||
'admin.tabs.addons': '애드온',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -160,6 +160,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Fout bij opslaan',
|
||||
'admin.tabs.addons': 'Add-ons',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -153,6 +153,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Nie udało się zapisać szablonu',
|
||||
'admin.tabs.addons': 'Dodatki',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -160,6 +160,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Ошибка сохранения',
|
||||
'admin.tabs.addons': 'Дополнения',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -212,6 +212,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Det gick inte att spara',
|
||||
'admin.tabs.addons': 'Tillägg',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -204,6 +204,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Kaydedilemedi',
|
||||
'admin.tabs.addons': 'Eklentiler',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -160,6 +160,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Помилка збереження',
|
||||
'admin.tabs.addons': 'Дополнения',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -212,6 +212,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': 'Không lưu được',
|
||||
'admin.tabs.addons': 'Tiện ích bổ sung',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -194,6 +194,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': '儲存失敗',
|
||||
'admin.tabs.addons': '擴充套件',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
@@ -155,6 +155,7 @@ const admin: TranslationStrings = {
|
||||
'admin.packingTemplates.saveError': '保存失败',
|
||||
'admin.tabs.addons': '扩展',
|
||||
'admin.tabs.plugins': 'Plugins',
|
||||
'plugins.notFound': 'Plugin not found',
|
||||
'admin.plugins.title': 'Plugins',
|
||||
'admin.plugins.subtitle': 'Install and manage third-party plugins on your instance.',
|
||||
'admin.plugins.disabledTitle': 'Plugins are disabled',
|
||||
|
||||
Reference in New Issue
Block a user