mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 12:22:07 +00:00
A self-hosted plugin directory, with no third-party service: `figcraft-server` keeps plugin bundles,
the app browses, installs and updates from it, and `figcraft-cli` publishes to it.
- **Bundles** (`figcraft_plugin::bundle`): a plugin's manifest, module and up to 32 plain-named
assets in one uncompressed, length-prefixed binary form (nothing can expand beyond the bytes
sent). Its SHA-256 names it. `Bundle::decode` never allocates beyond its input and refuses
trailing bytes; `Bundle::check` parses the manifest (refusing a plugin API this build doesn't
run), requires a version of 1-4 numbers, compiles the module without running it (only the
sandbox's imports allowed) and checks asset names (no paths, no dot files, no Windows device
names, unique ignoring case). Manifests gain an optional `author`.
- **Protocol**: a new connection kind next to managing and team-library connections:
`OpenPlugins {key}` then `Plugins {list | fetch | publish | unpublish}`. Browsing needs no
credential; publishing and unpublishing need the server key (a wrong key is refused). Older
servers refuse the connection and the app says the server has no plugin directory. The protocol
version is unchanged; old clients are unaffected.
- **Server** (`apps/figcraft-server/src/directory.rs`): `<data>/plugins/index.json` plus
`<data>/plugins/<sha256>.bin`, so no publisher-chosen name reaches the file system. Every publish
is checked in full and must be a newer version; caps `max_plugins` (versions, 0 turns it off),
`max_plugin_mb` (<= 32), `max_plugins_mb`. Fetches re-hash the file before sending (a damaged
bundle is never sent) and share a download budget (~32 MiB/s) so a flood is refused, not queued.
Damaged index entries are skipped and reported, never deleted; an unreadable index lists nothing
and refuses writes until the operator fixes it. Operator moderation:
`figcraft-server plugins [list]` and `plugins remove ID [--version V]` (offline or through the
running server). Logs name ids and versions, never keys.
- **App**: Plugins > Manage plugins gets Installed / Browse tabs. Browse lists a server's plugins
(version, author as typed, size, access) with a search; nothing is fetched until Browse is
pressed. Install / Update open a confirmation listing the permissions asked for, the source and
the SHA-256; only its button downloads. The download must match the listed SHA-256 (and the one
shown), then is checked like an imported plugin before anything is written. Installed plugins
live per user under `<settings>/FigCraft/Plugins/<hash of id>/` and run in the same sandbox;
Remove deletes the folder.
- **Commands**: `plugin.directory.list {server?, query?}`, `plugin.directory.install {id, version?,
confirm, server?, sha256?}` (refused without `confirm: true`; never opens a dialog),
`plugin.directory.publish {path | plugin, server}`, `plugin.directory.unpublish {id, version?,
server}`. `ui.set {pluginsTab, pluginConfirm}` on the control channel.
- **CLI**: `figcraft-cli plugin publish FOLDER --server ws://host:port/` with the key from
`FIGCRAFT_SERVER_KEY` (or `?key=`), never printed.
- Docs: plugins.md (directories, publishing, commands, manifest), server.md (operating the
directory: access, checks, caps, moderation, backups, security), collab-protocol.md, mcp.md,
cli.md, control-protocol.md, target-app-parity area 18 and Ecosystem, ROADMAP, roadmap.md M11,
gaps.md gap 6. Plugins & widgets 55%/32% -> 60%/35%, 10-30 h -> 8-26 h.
309 lines
14 KiB
Rust
309 lines
14 KiB
Rust
//! Plugins from a server's plugin directory (`figcraft-server`): listing it, installing and updating
|
|
//! plugins from it, and publishing and unpublishing them with the server's key. See
|
|
//! `docs/plugins.md` and `docs/server.md`.
|
|
//!
|
|
//! Everything a server sends is untrusted. An install needs the user's say-so (`confirm: true`,
|
|
//! which the app passes only from its confirmation step; nothing here opens a dialog), and the
|
|
//! download must match the SHA-256 the directory lists before anything else is read: then the
|
|
//! bundle is decoded within a size cap, its manifest and module are checked like an imported
|
|
//! plugin's (the module compiled, never run) and it runs in the same sandbox with its manifest's
|
|
//! permissions. Nothing here reaches the network except these commands.
|
|
|
|
use std::cmp::Ordering;
|
|
|
|
pub use figcraft_collab::message::PluginEntry;
|
|
use figcraft_collab::message::{PluginReply, PluginReq};
|
|
pub use figcraft_plugin::bundle;
|
|
pub use figcraft_plugin::bundle::newer as newer_version;
|
|
use figcraft_plugin::bundle::{Asset, Bundle, MAX_ASSETS, check_asset_name, compare_versions, newer, sha256_hex};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::cmd::CommandSpec;
|
|
use crate::params::{get, string};
|
|
use crate::plugins::{InstalledPlugin, MANIFEST_FILE, Manifest, installed, register};
|
|
use crate::{EngineError, Result, Session, bad, spec};
|
|
|
|
/// Why there's no plugin directory here (the browser build, sessions without the network).
|
|
pub const NO_DIRECTORY: &str = "plugin directories on a server need the desktop app, figcraft-cli or the MCP server";
|
|
/// Largest bundle downloaded or published (a server's own cap is at most this).
|
|
pub const MAX_BUNDLE: usize = 32 << 20;
|
|
/// The module's file name in an installed plugin's folder, and the folder its assets go in.
|
|
const MODULE: &str = "module.wasm";
|
|
const ASSETS: &str = "assets";
|
|
|
|
/// A directory as it was listed last.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct Listing {
|
|
/// Its address (`ws://host:port/`, without a key).
|
|
pub server: String,
|
|
pub entries: Vec<PluginEntry>,
|
|
}
|
|
|
|
/// A server address without its query (the key), as it's shown and remembered.
|
|
pub fn address(server: &str) -> String {
|
|
server.trim().split('?').next().unwrap_or_default().to_string()
|
|
}
|
|
|
|
/// The installed version of plugin `id`, if it is installed.
|
|
pub fn installed_version<'a>(s: &'a Session, id: &str) -> Option<&'a str> {
|
|
s.prefs.plugins.iter().find(|p| p.manifest.id == id).map(|p| p.manifest.version.as_str())
|
|
}
|
|
|
|
/// Entry `e` with whether it's installed (its version) and whether that's older.
|
|
fn describe(s: &Session, e: &PluginEntry) -> Value {
|
|
let mut v = serde_json::to_value(e).unwrap_or_else(|_| json!({"id": e.id}));
|
|
let have = installed_version(s, &e.id);
|
|
v["installed"] = json!(have);
|
|
v["update"] = json!(have.is_some_and(|h| newer(&e.version, h)));
|
|
v
|
|
}
|
|
|
|
/// `server`, or the directory browsed last.
|
|
fn server(s: &Session, cmd: &str, p: &Value) -> Result<String> {
|
|
string(p, "server")
|
|
.filter(|v| !v.trim().is_empty())
|
|
.or_else(|| s.prefs.plugin_directory.clone())
|
|
.ok_or_else(|| bad(cmd, "`server` required: the server's address, like ws://host:47900/"))
|
|
}
|
|
|
|
/// One request to the directory at `server`; a refusal is an error.
|
|
fn request(s: &mut Session, server: &str, req: PluginReq) -> Result<PluginReply> {
|
|
let link = s.live.link().ok_or_else(|| EngineError::Other(NO_DIRECTORY.into()))?;
|
|
match link.plugins(server, req).map_err(EngineError::Other)? {
|
|
PluginReply::Failed { reason } => Err(EngineError::Other(reason)),
|
|
r => Ok(r),
|
|
}
|
|
}
|
|
|
|
fn unexpected(cmd: &str) -> EngineError {
|
|
EngineError::Other(format!("{cmd}: the server answered something else"))
|
|
}
|
|
|
|
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "plugin.directory.list";
|
|
let server = server(s, cmd, p)?;
|
|
let query = string(p, "query").unwrap_or_default();
|
|
let PluginReply::List(entries) = request(s, &server, PluginReq::List { query })? else { return Err(unexpected(cmd)) };
|
|
let server = address(&server);
|
|
let plugins: Vec<Value> = entries.iter().map(|e| describe(s, e)).collect();
|
|
s.prefs.plugin_directory = Some(server.clone());
|
|
s.plugins.directory = Some(Listing { server: server.clone(), entries });
|
|
Ok(json!({"server": server, "plugins": plugins}))
|
|
}
|
|
|
|
/// Download, check and install a plugin (see the module docs).
|
|
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "plugin.directory.install";
|
|
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required (see plugin.directory.list)"))?;
|
|
if get(p, "confirm") != Some(&Value::Bool(true)) {
|
|
return Err(bad(
|
|
cmd,
|
|
"installing a plugin runs its code on this machine: check the permissions it asks for (plugin.directory.list) and pass `confirm: true`",
|
|
));
|
|
}
|
|
let server = server(s, cmd, p)?;
|
|
let version = string(p, "version");
|
|
let PluginReply::Bundle { entry, bundle: bytes } = request(s, &server, PluginReq::Fetch { id: id.clone(), version: version.clone() })? else {
|
|
return Err(unexpected(cmd));
|
|
};
|
|
let fail = |why: &str| EngineError::Other(format!("{id} wasn't installed: {why}"));
|
|
if entry.id != id || version.as_deref().is_some_and(|v| compare_versions(&entry.version, v) != Some(Ordering::Equal)) {
|
|
return Err(fail("the server sent another plugin than the one asked for"));
|
|
}
|
|
if bytes.len() > MAX_BUNDLE {
|
|
return Err(fail("the download is too large"));
|
|
}
|
|
let sha = sha256_hex(&bytes);
|
|
if sha != entry.sha256 {
|
|
return Err(fail("the download doesn't match the SHA-256 the directory lists"));
|
|
}
|
|
if string(p, "sha256").is_some_and(|want| !want.trim().eq_ignore_ascii_case(&sha)) {
|
|
return Err(fail("the download isn't the version that was shown (its SHA-256 differs)"));
|
|
}
|
|
let bundle = Bundle::decode(&bytes, MAX_BUNDLE).map_err(|e| fail(&e))?;
|
|
let mut manifest = bundle.check().map_err(|e| fail(&e))?;
|
|
if manifest.id != entry.id || manifest.version != entry.version {
|
|
return Err(fail("its manifest doesn't match the directory's entry"));
|
|
}
|
|
let source = address(&server);
|
|
let path = save(s, &mut manifest, &bundle).map_err(|e| fail(&e))?;
|
|
let out = json!({
|
|
"installed": manifest.id,
|
|
"name": manifest.name,
|
|
"version": manifest.version,
|
|
"permissions": manifest.permissions,
|
|
"sha256": sha,
|
|
"path": path,
|
|
"source": source,
|
|
});
|
|
let replaced = register(s, InstalledPlugin { manifest, path, source: Some(source) }, bundle.wasm);
|
|
let mut out = out;
|
|
out["replaced"] = json!(replaced);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Keep an installed plugin's files in its own folder under the install folder (named by the hash
|
|
/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without
|
|
/// an install folder (the browser, the CLI and tests) the plugin lasts for the session.
|
|
fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option<String>, String> {
|
|
let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) };
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
use std::fs;
|
|
let hash = sha256_hex(m.id.as_bytes());
|
|
let folder = root.join(hash.get(..32).unwrap_or(&hash));
|
|
let partial = folder.with_extension("partial");
|
|
let _ = fs::remove_dir_all(&partial);
|
|
m.main = MODULE.into();
|
|
let write = || -> std::io::Result<()> {
|
|
fs::create_dir_all(&partial)?;
|
|
fs::write(partial.join(MODULE), &b.wasm)?;
|
|
if !b.assets.is_empty() {
|
|
fs::create_dir_all(partial.join(ASSETS))?;
|
|
for a in &b.assets {
|
|
// Checked plain names (`Bundle::check`): never a path.
|
|
fs::write(partial.join(ASSETS).join(&a.name), &a.data)?;
|
|
}
|
|
}
|
|
fs::write(partial.join(MANIFEST_FILE), serde_json::to_vec_pretty(&*m).map_err(std::io::Error::other)?)
|
|
};
|
|
let done = write().and_then(|()| {
|
|
if folder.exists() {
|
|
fs::remove_dir_all(&folder)?;
|
|
}
|
|
fs::rename(&partial, &folder)
|
|
});
|
|
if let Err(e) = done {
|
|
let _ = fs::remove_dir_all(&partial);
|
|
return Err(format!("couldn't write it to {}: {e}", root.display()));
|
|
}
|
|
Ok(Some(folder.join(MANIFEST_FILE).to_string_lossy().into_owned()))
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let _ = (root, m, b);
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// Delete the folder of a plugin installed from a directory (only one inside the install folder).
|
|
pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir)
|
|
&& let Some(folder) = std::path::Path::new(path).parent()
|
|
&& folder.parent() == Some(root.as_path())
|
|
&& let Err(e) = std::fs::remove_dir_all(folder)
|
|
{
|
|
log::warn!("plugins: couldn't delete {}: {e}", folder.display());
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
let _ = (s, p);
|
|
}
|
|
|
|
/// The bundle of the plugin folder (or manifest) at `path`: its manifest as written, its module and
|
|
/// the files in its `assets` folder.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
|
|
use crate::plugins::{module_file, read_file};
|
|
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
|
if file.is_dir() {
|
|
file.push(MANIFEST_FILE);
|
|
}
|
|
let text =
|
|
String::from_utf8(read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?).map_err(|_| bad(cmd, "the manifest isn't UTF-8 text"))?;
|
|
let m = Manifest::parse(&text).map_err(|e| bad(cmd, e))?;
|
|
let wasm = module_file(&file, &m)?;
|
|
let mut assets = Vec::new();
|
|
let dir = file.parent().map(|d| d.join(ASSETS));
|
|
if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) {
|
|
let mut names: Vec<(String, std::path::PathBuf)> =
|
|
entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
|
|
names.sort();
|
|
if names.len() > MAX_ASSETS {
|
|
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
|
|
}
|
|
for (name, path) in names {
|
|
check_asset_name(&name).map_err(|e| bad(cmd, e))?;
|
|
assets.push(Asset { name, data: read_file(&path, MAX_BUNDLE)? });
|
|
}
|
|
}
|
|
Ok(Bundle { manifest: text, wasm, assets })
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn bundle_from_disk(cmd: &str, _path: &str) -> Result<Bundle> {
|
|
Err(bad(cmd, "there are no files in the browser: publish an installed plugin with `plugin`"))
|
|
}
|
|
|
|
/// Publish a plugin folder (or an installed plugin) as a new version (needs the server's key).
|
|
fn publish(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "plugin.directory.publish";
|
|
let server = string(p, "server").ok_or_else(|| bad(cmd, "`server` required: ws://host:port/?key=… (`figcraft-server key` prints it)"))?;
|
|
let bundle = match (string(p, "path"), string(p, "plugin")) {
|
|
(Some(path), _) => bundle_from_disk(cmd, &path)?,
|
|
(None, Some(id)) => {
|
|
let ip = installed(s, cmd, &id)?.clone();
|
|
match &ip.path {
|
|
Some(path) => bundle_from_disk(cmd, path)?,
|
|
None => {
|
|
let wasm = crate::plugins::module_of(s, &ip)?;
|
|
let manifest = serde_json::to_string(&ip.manifest).map_err(|e| EngineError::Other(e.to_string()))?;
|
|
Bundle { manifest, wasm: wasm.to_vec(), assets: Vec::new() }
|
|
}
|
|
}
|
|
}
|
|
(None, None) => return Err(bad(cmd, "pass `path` (a plugin folder or its figcraft-plugin.json) or `plugin` (an installed plugin's id)")),
|
|
};
|
|
// Checked here too, for a clear message before anything is sent.
|
|
bundle.check().map_err(|e| bad(cmd, e))?;
|
|
let bytes = bundle.encode();
|
|
if bytes.len() > MAX_BUNDLE {
|
|
return Err(bad(cmd, format!("the plugin is larger than {} MiB", MAX_BUNDLE >> 20)));
|
|
}
|
|
let PluginReply::Published(entry) = request(s, &server, PluginReq::Publish { bundle: bytes })? else { return Err(unexpected(cmd)) };
|
|
Ok(json!({"published": entry, "server": address(&server)}))
|
|
}
|
|
|
|
fn unpublish(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "plugin.directory.unpublish";
|
|
let id = string(p, "id").ok_or_else(|| bad(cmd, "`id` required"))?;
|
|
let server = string(p, "server").ok_or_else(|| bad(cmd, "`server` required: ws://host:port/?key=…"))?;
|
|
let PluginReply::Removed(versions) = request(s, &server, PluginReq::Unpublish { id: id.clone(), version: string(p, "version") })? else {
|
|
return Err(unexpected(cmd));
|
|
};
|
|
Ok(json!({"id": id, "removed": versions}))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!(
|
|
"plugin.directory.list",
|
|
"Browse plugin directory",
|
|
"{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}",
|
|
false,
|
|
list
|
|
),
|
|
spec!(
|
|
"plugin.directory.install",
|
|
"Install plugin from directory",
|
|
"{id, version? (default: the latest), confirm: true (required: installing runs the plugin's code), server?, sha256? (the one shown)} downloads, checks the SHA-256 and installs or updates it → {installed, name, version, permissions, sha256, path, source, replaced}",
|
|
false,
|
|
install
|
|
),
|
|
spec!(
|
|
"plugin.directory.publish",
|
|
"Publish plugin to directory",
|
|
"{path (a plugin folder or its figcraft-plugin.json) | plugin (an installed plugin's id), server (ws://host:port/?key=…)} → {published: entry, server}",
|
|
false,
|
|
publish
|
|
),
|
|
spec!(
|
|
"plugin.directory.unpublish",
|
|
"Unpublish plugin from directory",
|
|
"{id, version? (default: every version), server (ws://host:port/?key=…)} → {id, removed: [versions]}",
|
|
false,
|
|
unpublish
|
|
),
|
|
]
|
|
}
|