diff --git a/frontend/src/pages/communities/[slug]/plugins.astro b/frontend/src/pages/communities/[slug]/plugins.astro index 99e2a4e..44ae7c2 100644 --- a/frontend/src/pages/communities/[slug]/plugins.astro +++ b/frontend/src/pages/communities/[slug]/plugins.astro @@ -117,6 +117,277 @@ const { slug } = Astro.params; return res.json(); } + async function fetchPluginPolicy(communityId) { + const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-policy`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(err || 'Failed to load plugin policy'); + } + + return res.json(); + } + + async function fetchPluginPackages(communityId) { + const res = await fetch(`${apiBase}/api/communities/${communityId}/plugin-packages`, { + headers: { 'Authorization': `Bearer ${token}` }, + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(err || 'Failed to load plugin packages'); + } + + return res.json(); + } + + function normalizeStringList(text) { + return String(text || '') + .split(/\r?\n/) + .map(v => v.trim()) + .filter(v => v.length > 0); + } + + function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, chunk); + } + return btoa(binary); + } + + function renderPolicyEditor(policy) { + const trust = policy?.trust_policy || 'signed_only'; + const installSources = new Set(policy?.install_sources || []); + const allowOutbound = !!policy?.allow_outbound_http; + const httpAllowlist = (policy?.http_egress_allowlist || []).join('\n'); + const registryAllowlist = (policy?.registry_allowlist || []).join('\n'); + const allowBg = !!policy?.allow_background_jobs; + const publishers = (policy?.trusted_publishers || []).join('\n'); + + return ` +
+
+

Plugin Policy

+
+ + +
+
+ +
+
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ `; + } + + function packageSettingsSchema(pkg) { + const schema = pkg?.manifest?.settings_schema; + if (schema && schema.properties) return schema; + return null; + } + + function renderPackageSettingsForm(pkg) { + const schema = packageSettingsSchema(pkg); + const name = String(pkg?.package_id || ''); + const settings = pkg?.settings || {}; + if (!schema) { + const settingsText = JSON.stringify(settings ?? {}, null, 2); + return ` +
+

No schema defined. Edit raw JSON:

+ +
+ + +
+
+ `; + } + + const fields = Object.entries(schema.properties).map(([key, propSchema]) => { + return renderSchemaField(`pkg-${name}`, key, propSchema, settings?.[key]); + }).join(''); + + return ` +
+ ${fields} +
+ + +
+
+ `; + } + + function renderPackages(packages) { + const list = Array.isArray(packages) ? packages : []; + + return ` +
+
+

WASM Plugin Packages

+
+ +
+
+ +
+
+ +
+ + +
+ +
+ +
+ + Upload package + WASM + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ ${list.length === 0 ? ` +
+

No packages installed.

+

Install from registry or upload a WASM bundle.

+
+ ` : list.map(pkg => { + const pkgId = String(pkg.package_id); + const title = `${escapeHtml(pkg.name)} v${escapeHtml(pkg.version)}`; + return ` +
+
+
+

${title}

+ ${pkg.signature_present ? `signed` : `unsigned`} + ${pkg.source ? `${escapeHtml(pkg.source)}` : ''} +
+
+ +
+
+

${escapeHtml(pkg.description || '')}

+
+
publisher ${escapeHtml(pkg.publisher || '')}
+
sha256 ${escapeHtml(pkg.wasm_sha256 || '')}
+ ${pkg.registry_url ? `
registry ${escapeHtml(pkg.registry_url)}
` : ''} +
+
+ + Settings + ${packageSettingsSchema(pkg) ? 'Schema' : 'JSON'} + +
+ ${renderPackageSettingsForm(pkg)} +
+
+
+ `; + }).join('')} +
+
+ `; + } + async function fetchPlugins(communityId) { const res = await fetch(`${apiBase}/api/communities/${communityId}/plugins`, { headers: { 'Authorization': `Bearer ${token}` }, @@ -207,7 +478,7 @@ const { slug } = Astro.params; `; } - function renderPlugins(community, membership, plugins) { + function renderPlugins(community, membership, policy, packages, plugins) { const container = document.getElementById('plugins-content'); if (!container) return; @@ -218,21 +489,7 @@ const { slug } = Astro.params; return; } - if (!plugins || plugins.length === 0) { - container.innerHTML = ` -
-

No plugins available.

-

If this seems wrong, try reloading the page.

-
- - Back to community -
-
- `; - - document.getElementById('retry-plugins-empty')?.addEventListener('click', load); - return; - } + const safePlugins = Array.isArray(plugins) ? plugins : []; container.innerHTML = `
@@ -243,8 +500,16 @@ const { slug } = Astro.params;
Community plugin settings
+ ${renderPolicyEditor(policy)} + + ${renderPackages(packages)} +
- ${plugins.map(p => { + ${safePlugins.length === 0 ? ` +
+

No built-in plugins available.

+
+ ` : safePlugins.map(p => { const disabledGlobally = !p.global_is_active; const isCore = !!p.is_core; const checked = !!p.community_is_active; @@ -286,6 +551,296 @@ const { slug } = Astro.params;
`; + document.getElementById('refresh-packages')?.addEventListener('click', load); + + document.getElementById('save-policy')?.addEventListener('click', async () => { + const statusEl = document.getElementById('policy-status'); + const btn = document.getElementById('save-policy'); + if (btn) btn.disabled = true; + if (statusEl) statusEl.textContent = 'Saving...'; + + try { + const current = { + trust_policy: (document.getElementById('policy-trust'))?.value, + install_sources: [ + (document.getElementById('policy-source-upload'))?.checked ? 'upload' : null, + (document.getElementById('policy-source-registry'))?.checked ? 'registry' : null, + ].filter(Boolean), + allow_outbound_http: (document.getElementById('policy-allow-outbound'))?.checked, + http_egress_allowlist: normalizeStringList((document.getElementById('policy-http-allowlist'))?.value), + registry_allowlist: normalizeStringList((document.getElementById('policy-registry-allowlist'))?.value), + trusted_publishers: normalizeStringList((document.getElementById('policy-trusted-publishers'))?.value), + allow_background_jobs: (document.getElementById('policy-allow-background'))?.checked, + }; + + const patch = {}; + if (!policy || current.trust_policy !== policy.trust_policy) patch.trust_policy = current.trust_policy; + if (!policy || JSON.stringify(current.install_sources) !== JSON.stringify(policy.install_sources || [])) patch.install_sources = current.install_sources; + if (!policy || current.allow_outbound_http !== !!policy.allow_outbound_http) patch.allow_outbound_http = current.allow_outbound_http; + if (!policy || JSON.stringify(current.http_egress_allowlist) !== JSON.stringify(policy.http_egress_allowlist || [])) patch.http_egress_allowlist = current.http_egress_allowlist; + if (!policy || JSON.stringify(current.registry_allowlist) !== JSON.stringify(policy.registry_allowlist || [])) patch.registry_allowlist = current.registry_allowlist; + if (!policy || JSON.stringify(current.trusted_publishers) !== JSON.stringify(policy.trusted_publishers || [])) patch.trusted_publishers = current.trusted_publishers; + if (!policy || current.allow_background_jobs !== !!policy.allow_background_jobs) patch.allow_background_jobs = current.allow_background_jobs; + + const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-policy`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(patch), + }); + + if (!res.ok) { + throw new Error(await res.text()); + } + + if (statusEl) statusEl.textContent = 'Saved'; + setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500); + await load(); + } catch (err) { + if (statusEl) statusEl.textContent = 'Error'; + alert(err?.message || 'Failed to save policy'); + } finally { + if (btn) btn.disabled = false; + } + }); + + document.getElementById('install-registry')?.addEventListener('click', async () => { + const input = document.getElementById('registry-url'); + const statusEl = document.getElementById('registry-status'); + const btn = document.getElementById('install-registry'); + const url = input?.value; + + if (!url) return; + if (btn) btn.disabled = true; + if (statusEl) statusEl.textContent = 'Installing...'; + + try { + const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/install-registry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ url }), + }); + + if (!res.ok) { + throw new Error(await res.text()); + } + + if (statusEl) statusEl.textContent = 'Installed'; + setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500); + if (input) input.value = ''; + await load(); + } catch (err) { + if (statusEl) statusEl.textContent = 'Error'; + alert(err?.message || 'Failed to install from registry'); + } finally { + if (btn) btn.disabled = false; + } + }); + + document.getElementById('upload-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.getElementById('upload-btn'); + const statusEl = document.getElementById('upload-status'); + if (btn) btn.disabled = true; + if (statusEl) statusEl.textContent = 'Uploading...'; + + try { + const name = (document.getElementById('upload-name'))?.value; + const version = (document.getElementById('upload-version'))?.value; + const description = (document.getElementById('upload-description'))?.value || null; + const publisher = (document.getElementById('upload-publisher'))?.value || null; + const signature_base64 = (document.getElementById('upload-signature'))?.value || null; + const manifestText = (document.getElementById('upload-manifest'))?.value; + const wasmFile = (document.getElementById('upload-wasm'))?.files?.[0]; + + if (!name || !version || !manifestText || !wasmFile) { + throw new Error('Missing required fields'); + } + + let manifest; + try { + manifest = JSON.parse(manifestText); + } catch { + throw new Error('Invalid manifest JSON'); + } + + const wasmBuf = await wasmFile.arrayBuffer(); + const wasm_base64 = arrayBufferToBase64(wasmBuf); + + const body = { + name, + version, + description, + publisher, + manifest, + wasm_base64, + signature_base64, + }; + + const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + throw new Error(await res.text()); + } + + if (statusEl) statusEl.textContent = 'Uploaded'; + setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500); + await load(); + } catch (err) { + if (statusEl) statusEl.textContent = 'Error'; + alert(err?.message || 'Failed to upload package'); + } finally { + if (btn) btn.disabled = false; + } + }); + + document.querySelectorAll('.package-toggle').forEach(el => { + el.addEventListener('change', async (e) => { + const input = e.target; + const packageId = input.dataset.packageId; + const next = input.checked; + + input.disabled = true; + + try { + const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ is_active: next }), + }); + + if (!res.ok) { + const err = await res.text(); + input.checked = !next; + alert(err || 'Failed to update package'); + } else { + await load(); + } + } catch (err) { + input.checked = !next; + alert('Failed to update package'); + } finally { + input.disabled = false; + } + }); + }); + + document.querySelectorAll('.js-save-package-json').forEach(el => { + el.addEventListener('click', async (e) => { + const btn = e.target; + const packageId = btn.dataset.packageId; + const textarea = document.querySelector(`textarea.settings-json[data-package-id="${packageId}"]`); + const statusEl = document.getElementById(`pkg-status-${packageId}`); + if (!textarea) return; + + let settings; + try { + settings = JSON.parse(textarea.value || '{}'); + } catch { + if (statusEl) statusEl.textContent = 'Invalid JSON'; + return; + } + + btn.disabled = true; + if (statusEl) statusEl.textContent = 'Saving...'; + + try { + const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ settings }), + }); + + if (res.ok) { + if (statusEl) statusEl.textContent = 'Saved'; + setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500); + await load(); + } else { + const err = await res.text(); + if (statusEl) statusEl.textContent = 'Error'; + alert(err || 'Failed to save settings'); + } + } catch { + if (statusEl) statusEl.textContent = 'Error'; + alert('Failed to save settings'); + } finally { + btn.disabled = false; + } + }); + }); + + document.querySelectorAll('.pkg-settings-form').forEach(form => { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const packageId = form.dataset.packageId; + const statusEl = document.getElementById(`pkg-status-${packageId}`); + const btn = form.querySelector('button[type="submit"]'); + + const settings = {}; + form.querySelectorAll('[data-key]').forEach(input => { + const key = input.dataset.key; + const type = input.dataset.type; + + if (type === 'boolean') { + settings[key] = input.checked; + } else if (type === 'integer') { + settings[key] = input.value ? parseInt(input.value, 10) : null; + } else if (type === 'number') { + settings[key] = input.value ? parseFloat(input.value) : null; + } else { + settings[key] = input.value; + } + }); + + if (btn) btn.disabled = true; + if (statusEl) statusEl.textContent = 'Saving...'; + + try { + const res = await fetch(`${apiBase}/api/communities/${community.id}/plugin-packages/${packageId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ settings }), + }); + + if (res.ok) { + if (statusEl) statusEl.textContent = 'Saved'; + setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 1500); + await load(); + } else { + const err = await res.text(); + if (statusEl) statusEl.textContent = 'Error'; + alert(err || 'Failed to save settings'); + } + } catch { + if (statusEl) statusEl.textContent = 'Error'; + alert('Failed to save settings'); + } finally { + if (btn) btn.disabled = false; + } + }); + }); + document.querySelectorAll('.plugin-toggle').forEach(el => { el.addEventListener('change', async (e) => { const input = e.target; @@ -436,9 +991,19 @@ const { slug } = Astro.params; } const membership = await fetchMembership(community.id); - const plugins = await fetchPlugins(community.id); + const allowed = membership?.role === 'admin' || membership?.role === 'moderator'; + if (!allowed) { + renderForbiddenState(community?.name || 'this community'); + return; + } - renderPlugins(community, membership, plugins); + const [policy, packages, plugins] = await Promise.all([ + fetchPluginPolicy(community.id), + fetchPluginPackages(community.id), + fetchPlugins(community.id), + ]); + + renderPlugins(community, membership, policy, packages, plugins); } catch (err) { const msg = err?.message || 'Failed to load plugins.';